EagleCast
publish-github-pages / deploy (push) Has been cancelled

This commit is contained in:
h202-wq
2026-07-09 10:03:32 -04:00
commit 66d9a033c9
446 changed files with 162542 additions and 0 deletions
@@ -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>;
}
}
@@ -0,0 +1,82 @@
import React, { useState } from 'react';
import { AspectRatioOutlined } from '@mui/icons-material';
import { ToggleButton } from '@mui/material';
import { AvatarProps, AvatarPropsDefaults, AvatarPropsSchema } from '@usewaypoint/block-avatar';
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
import RadioGroupInput from './helpers/inputs/RadioGroupInput';
import SliderInput from './helpers/inputs/SliderInput';
import TextInput from './helpers/inputs/TextInput';
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
type AvatarSidebarPanelProps = {
data: AvatarProps;
setData: (v: AvatarProps) => void;
};
export default function AvatarSidebarPanel({ data, setData }: AvatarSidebarPanelProps) {
const [, setErrors] = useState<Zod.ZodError | null>(null);
const updateData = (d: unknown) => {
const res = AvatarPropsSchema.safeParse(d);
if (res.success) {
setData(res.data);
setErrors(null);
} else {
setErrors(res.error);
}
};
const size = data.props?.size ?? AvatarPropsDefaults.size;
const imageUrl = data.props?.imageUrl ?? AvatarPropsDefaults.imageUrl;
const alt = data.props?.alt ?? AvatarPropsDefaults.alt;
const shape = data.props?.shape ?? AvatarPropsDefaults.shape;
return (
<BaseSidebarPanel title="Avatar block">
<SliderInput
label="Size"
iconLabel={<AspectRatioOutlined sx={{ color: 'text.secondary' }} />}
units="px"
step={3}
min={32}
max={256}
defaultValue={size}
onChange={(size) => {
updateData({ ...data, props: { ...data.props, size } });
}}
/>
<RadioGroupInput
label="Shape"
defaultValue={shape}
onChange={(shape) => {
updateData({ ...data, props: { ...data.props, shape } });
}}
>
<ToggleButton value="circle">Circle</ToggleButton>
<ToggleButton value="square">Square</ToggleButton>
<ToggleButton value="rounded">Rounded</ToggleButton>
</RadioGroupInput>
<TextInput
label="Image URL"
className="image-url"
defaultValue={imageUrl}
onChange={(imageUrl) => {
updateData({ ...data, props: { ...data.props, imageUrl } });
}}
/>
<TextInput
label="Alt text"
defaultValue={alt}
onChange={(alt) => {
updateData({ ...data, props: { ...data.props, alt } });
}}
/>
<MultiStylePropertyPanel
names={['textAlign', 'padding']}
value={data.style}
onChange={(style) => updateData({ ...data, style })}
/>
</BaseSidebarPanel>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { RoundedCornerOutlined } from '@mui/icons-material';
import EmailLayoutPropsSchema, {
EmailLayoutProps,
} from '../../../../documents/blocks/EmailLayout/EmailLayoutPropsSchema';
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
import BooleanInput from './helpers/inputs/BooleanInput';
import ColorInput, { NullableColorInput } from './helpers/inputs/ColorInput';
import { NullableFontFamily } from './helpers/inputs/FontFamily';
import SliderInput from './helpers/inputs/SliderInput';
type EmailLayoutSidebarFieldsProps = {
data: EmailLayoutProps;
setData: (v: EmailLayoutProps) => void;
};
export default function EmailLayoutSidebarFields({ data, setData }: EmailLayoutSidebarFieldsProps) {
const [, setErrors] = useState<Zod.ZodError | null>(null);
const updateData = (d: unknown) => {
const res = EmailLayoutPropsSchema.safeParse(d);
if (res.success) {
setData(res.data);
setErrors(null);
} else {
setErrors(res.error);
}
};
return (
<BaseSidebarPanel title="Global">
<ColorInput
label="Backdrop color"
defaultValue={data.backdropColor ?? '#F5F5F5'}
onChange={(backdropColor) => updateData({ ...data, backdropColor })}
/>
<ColorInput
label="Canvas color"
defaultValue={data.canvasColor ?? '#FFFFFF'}
onChange={(canvasColor) => updateData({ ...data, canvasColor })}
/>
<NullableColorInput
label="Canvas border color"
defaultValue={data.borderColor ?? null}
onChange={(borderColor) => updateData({ ...data, borderColor })}
/>
<SliderInput
iconLabel={<RoundedCornerOutlined />}
units="px"
step={4}
marks
min={0}
max={48}
label="Canvas border radius"
defaultValue={data.borderRadius ?? 0}
onChange={(borderRadius) => updateData({ ...data, borderRadius })}
/>
<NullableFontFamily
label="Font family"
defaultValue="MODERN_SANS"
onChange={(fontFamily) => updateData({ ...data, fontFamily })}
/>
<ColorInput
label="Text color"
defaultValue={data.textColor ?? '#262626'}
onChange={(textColor) => updateData({ ...data, textColor })}
/>
<BooleanInput
label="Outlook compatibility"
defaultValue={data.outlook ?? false}
onChange={(outlook) => updateData({ ...data, outlook })}
/>
</BaseSidebarPanel>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -0,0 +1,116 @@
import {
VerticalAlignBottomOutlined,
VerticalAlignCenterOutlined,
VerticalAlignTopOutlined,
} from '@mui/icons-material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import { Checkbox, FormControlLabel, Stack, ToggleButton } from '@mui/material';
import { ImageProps } from '@usewaypoint/block-image';
import React, { useState } from 'react';
import { ImgPropsSchema, EagleCastImageProps } from '../../../../documents/editor/core';
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
import RadioGroupInput from './helpers/inputs/RadioGroupInput';
import TextDimensionInput from './helpers/inputs/TextDimensionInput';
import TextInput from './helpers/inputs/TextInput';
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
type ImageSidebarPanelProps = {
data: ImageProps;
setData: (v: ImageProps) => void;
};
export default function ImageSidebarPanel({ data, setData }: ImageSidebarPanelProps) {
const [, setErrors] = useState<Zod.ZodError | null>(null);
const updateData = (d: unknown) => {
const res = ImgPropsSchema.safeParse(d);
if (res.success) {
setData(res.data as ImageProps);
setErrors(null);
} else {
setErrors(res.error);
}
};
const props = (data && (data as EagleCastImageProps).props) || {};
return (
<BaseSidebarPanel title="Image block">
<TextInput
label="Source URL"
className="image-url"
defaultValue={data.props?.url ?? ''}
onChange={(v) => {
const url = v.trim().length === 0 ? null : v.trim();
updateData({ ...data, props: { ...data.props, url } });
}}
/>
<a href="#" class="select-media"
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', marginTop: '5px' }}
onClick={(e) => {
// @ts-ignore
window.parent.postMessage('visualeditor.select-media', '*');
e.preventDefault();
}}><CloudUploadIcon style={{fontSize: '1rem'}} /> Select media</a>
<TextInput
label="Alt text"
defaultValue={data.props?.alt ?? ''}
onChange={(alt) => updateData({ ...data, props: { ...data.props, alt } })}
/>
<TextInput
label="Click through URL"
defaultValue={data.props?.linkHref ?? ''}
onChange={(v) => {
const linkHref = v.trim().length === 0 ? null : v.trim();
updateData({ ...data, props: { ...data.props, linkHref } });
}}
/>
<Stack direction="row" spacing={2}>
<TextDimensionInput
label="Width"
defaultValue={data.props?.width}
onChange={(width) => updateData({ ...data, props: { ...data.props, width } })}
/>
<TextDimensionInput
label="Height"
defaultValue={data.props?.height}
onChange={(height) => updateData({ ...data, props: { ...data.props, height } })}
/>
</Stack>
<RadioGroupInput
label="Alignment"
defaultValue={data.props?.contentAlignment ?? 'middle'}
onChange={(contentAlignment) => updateData({ ...data, props: { ...data.props, contentAlignment } })}
>
<ToggleButton value="top">
<VerticalAlignTopOutlined fontSize="small" />
</ToggleButton>
<ToggleButton value="middle">
<VerticalAlignCenterOutlined fontSize="small" />
</ToggleButton>
<ToggleButton value="bottom">
<VerticalAlignBottomOutlined fontSize="small" />
</ToggleButton>
</RadioGroupInput>
<FormControlLabel
control={
<Checkbox
size="small"
checked={Boolean(props.embed)}
onChange={(e) => updateData({ ...data, props: { ...data.props, embed: e.target.checked } })}
/>
}
label="Embed inline (CID)"
/>
<MultiStylePropertyPanel
names={['backgroundColor', 'textAlign', 'padding']}
value={data.style}
onChange={(style) => updateData({ ...data, style })}
/>
</BaseSidebarPanel>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -0,0 +1,25 @@
import React from 'react';
import { FormControlLabel, Switch } from '@mui/material';
type Props = {
label: string;
defaultValue: boolean;
onChange: (value: boolean) => void;
};
export default function BooleanInput({ label, defaultValue, onChange }: Props) {
return (
<FormControlLabel
label={label}
control={
<Switch
checked={defaultValue}
onChange={(_, checked: boolean) => {
onChange(checked);
}}
/>
}
/>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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 />;
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
),
}}
/>
);
}
@@ -0,0 +1,37 @@
import React, { useState } from 'react';
import { InputProps, TextField } from '@mui/material';
type Props = {
label: string;
rows?: number;
placeholder?: string;
helperText?: string | JSX.Element;
InputProps?: InputProps;
defaultValue: string;
className?: string;
onChange: (v: string) => void;
};
export default function TextInput({ helperText, label, placeholder, rows, InputProps, defaultValue, className, onChange }: Props) {
const [value, setValue] = useState(defaultValue);
const isMultiline = typeof rows === 'number' && rows > 1;
return (
<TextField
fullWidth
multiline={isMultiline}
minRows={rows}
variant={isMultiline ? 'outlined' : 'standard'}
label={label}
placeholder={placeholder}
helperText={helperText}
InputProps={InputProps}
className={className}
value={value}
onChange={(ev) => {
const v = ev.target.value;
setValue(v);
onChange(v);
}}
/>
);
}
@@ -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>
);
}
@@ -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} />
))}
</>
);
}
@@ -0,0 +1,58 @@
import React from 'react';
import { RoundedCornerOutlined } from '@mui/icons-material';
import { TStyle } from '../../../../../../documents/blocks/helpers/TStyle';
import { NullableColorInput } from '../inputs/ColorInput';
import { NullableFontFamily } from '../inputs/FontFamily';
import FontSizeInput from '../inputs/FontSizeInput';
import FontWeightInput from '../inputs/FontWeightInput';
import PaddingInput from '../inputs/PaddingInput';
import SliderInput from '../inputs/SliderInput';
import TextAlignInput from '../inputs/TextAlignInput';
type StylePropertyPanelProps = {
name: keyof TStyle;
value: TStyle;
onChange: (style: TStyle) => void;
};
export default function SingleStylePropertyPanel({ name, value, onChange }: StylePropertyPanelProps) {
const defaultValue = value[name] ?? null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleChange = (v: any) => {
onChange({ ...value, [name]: v });
};
switch (name) {
case 'backgroundColor':
return <NullableColorInput label="Background color" defaultValue={defaultValue} onChange={handleChange} />;
case 'borderColor':
return <NullableColorInput label="Border color" defaultValue={defaultValue} onChange={handleChange} />;
case 'borderRadius':
return (
<SliderInput
iconLabel={<RoundedCornerOutlined />}
units="px"
step={4}
marks
min={0}
max={48}
label="Border radius"
defaultValue={defaultValue}
onChange={handleChange}
/>
);
case 'color':
return <NullableColorInput label="Text color" defaultValue={defaultValue} onChange={handleChange} />;
case 'fontFamily':
return <NullableFontFamily label="Font family" defaultValue={defaultValue} onChange={handleChange} />;
case 'fontSize':
return <FontSizeInput label="Font size" defaultValue={defaultValue} onChange={handleChange} />;
case 'fontWeight':
return <FontWeightInput label="Font weight" defaultValue={defaultValue} onChange={handleChange} />;
case 'textAlign':
return <TextAlignInput label="Alignment" defaultValue={defaultValue} onChange={handleChange} />;
case 'padding':
return <PaddingInput label="Padding" defaultValue={defaultValue} onChange={handleChange} />;
}
}
@@ -0,0 +1,19 @@
import React from 'react';
import { setDocument, useDocument } from '../../documents/editor/EditorContext';
import EmailLayoutSidebarPanel from './ConfigurationPanel/input-panels/EmailLayoutSidebarPanel';
export default function StylesPanel() {
const block = useDocument().root;
if (!block) {
return <p>Block not found</p>;
}
const { data, type } = block;
if (type !== 'EmailLayout') {
throw new Error('Expected "root" element to be of type EmailLayout');
}
return <EmailLayoutSidebarPanel key="root" data={data} setData={(data) => setDocument({ root: { type, data } })} />;
}
@@ -0,0 +1,26 @@
import React from 'react';
import { AppRegistrationOutlined, LastPageOutlined } from '@mui/icons-material';
import { IconButton } from '@mui/material';
import { toggleInspectorDrawerOpen, useInspectorDrawerOpen } from '../../documents/editor/EditorContext';
export default function ToggleInspectorPanelButton() {
const inspectorDrawerOpen = useInspectorDrawerOpen();
const handleClick = () => {
toggleInspectorDrawerOpen();
};
if (inspectorDrawerOpen) {
return (
<IconButton onClick={handleClick}>
<LastPageOutlined fontSize="small" />
</IconButton>
);
}
return (
<IconButton onClick={handleClick}>
<AppRegistrationOutlined fontSize="small" />
</IconButton>
);
}
@@ -0,0 +1,59 @@
import React from 'react';
import {
Box, Drawer, Tab, Tabs,
} from '@mui/material';
import { setSidebarTab, useInspectorDrawerOpen, useSelectedSidebarTab } from '../../documents/editor/EditorContext';
import ConfigurationPanel from './ConfigurationPanel';
import StylesPanel from './StylesPanel';
export const INSPECTOR_DRAWER_WIDTH = 320;
export default function InspectorDrawer() {
const selectedSidebarTab = useSelectedSidebarTab();
const inspectorDrawerOpen = useInspectorDrawerOpen();
const renderCurrentSidebarPanel = () => {
switch (selectedSidebarTab) {
case 'block-configuration':
return <ConfigurationPanel />;
case 'styles':
return <StylesPanel />;
}
};
return (
<Drawer
variant="persistent"
anchor="right"
className="sidebar"
open={inspectorDrawerOpen}
sx={{
width: inspectorDrawerOpen ? INSPECTOR_DRAWER_WIDTH : 0,
}}
// Make the drawer relative to the wrapper instead of body.
PaperProps={{ style: { position: 'absolute', zIndex: 0 } }}
ModalProps={{
container: document.querySelector('.email-builder-container'),
style: { position: 'absolute', zIndex: 0 },
}}
>
<Box sx={{
width: INSPECTOR_DRAWER_WIDTH, height: 49, borderBottom: 1, borderColor: 'divider',
}}
>
<Box px={2}>
<Tabs value={selectedSidebarTab} onChange={(_, v) => setSidebarTab(v)}>
<Tab value="styles" label="Styles" />
<Tab value="block-configuration" label="Inspect" />
</Tabs>
</Box>
</Box>
<Box sx={{ width: INSPECTOR_DRAWER_WIDTH, height: 'calc(100% - 49px)', overflow: 'auto' }}>
{renderCurrentSidebarPanel()}
</Box>
</Drawer>
);
}
@@ -0,0 +1,20 @@
import React, { useMemo } from 'react';
import { FileDownloadOutlined } from '@mui/icons-material';
import { IconButton, Tooltip } from '@mui/material';
import { useDocument } from '../../../documents/editor/EditorContext';
export default function DownloadJson() {
const doc = useDocument();
const href = useMemo(() => {
return `data:text/plain,${encodeURIComponent(JSON.stringify(doc, null, ' '))}`;
}, [doc]);
return (
<Tooltip title="Download JSON file">
<IconButton href={href} download="emailTemplate.json">
<FileDownloadOutlined fontSize="small" />
</IconButton>
</Tooltip>
);
}
@@ -0,0 +1,15 @@
import React, { useMemo } from 'react';
import { useDocument } from '../../documents/editor/EditorContext';
import { renderHtmlWithMeta } from '../../utils';
import HighlightedCodePanel from './helper/HighlightedCodePanel';
export default function HtmlPanel() {
const document = useDocument();
const code = useMemo(
() => renderHtmlWithMeta(document, { rootBlockId: 'root', outlook: Boolean(document.root?.data?.outlook) }),
[document]
);
return <HighlightedCodePanel type="html" value={code} />;
}
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import {
Alert,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Link,
TextField,
Typography,
} from '@mui/material';
import { resetDocument } from '../../../documents/editor/EditorContext';
import validateJsonStringValue from './validateJsonStringValue';
type ImportJsonDialogProps = {
onClose: () => void;
};
export default function ImportJsonDialog({ onClose }: ImportJsonDialogProps) {
const [value, setValue] = useState('');
const [error, setError] = useState<string | null>(null);
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement> = (ev) => {
const v = ev.currentTarget.value;
setValue(v);
const { error } = validateJsonStringValue(v);
setError(error ?? null);
};
let errorAlert = null;
if (error) {
errorAlert = <Alert color="error">{error}</Alert>;
}
return (
<Dialog open onClose={onClose}>
<DialogTitle>Import JSON</DialogTitle>
<form
onSubmit={(ev) => {
ev.preventDefault();
const { error, data } = validateJsonStringValue(value);
setError(error ?? null);
if (!data) {
return;
}
resetDocument(data);
onClose();
}}
>
<DialogContent>
<Typography color="text.secondary" paragraph>
Copy and paste an EmailBuilder.js JSON (
<Link
href="https://gist.githubusercontent.com/jordanisip/efb61f56ba71bd36d3a9440122cb7f50/raw/30ea74a6ac7e52ebdc309bce07b71a9286ce2526/emailBuilderTemplate.json"
target="_blank"
underline="none"
>
example
</Link>
).
</Typography>
{errorAlert}
<TextField
error={error !== null}
value={value}
onChange={handleChange}
type="text"
helperText="This will override your current template."
variant="outlined"
fullWidth
rows={10}
multiline
/>
</DialogContent>
<DialogActions>
<Button type="button" onClick={onClose}>
Cancel
</Button>
<Button variant="contained" type="submit" disabled={error !== null}>
Import
</Button>
</DialogActions>
</form>
</Dialog>
);
}
@@ -0,0 +1,26 @@
import React, { useState } from 'react';
import { FileUploadOutlined } from '@mui/icons-material';
import { IconButton, Tooltip } from '@mui/material';
import ImportJsonDialog from './ImportJsonDialog';
export default function ImportJson() {
const [open, setOpen] = useState(false);
let dialog = null;
if (open) {
dialog = <ImportJsonDialog onClose={() => setOpen(false)} />;
}
return (
<>
<Tooltip title="Import JSON">
<IconButton onClick={() => setOpen(true)}>
<FileUploadOutlined fontSize="small" />
</IconButton>
</Tooltip>
{dialog}
</>
);
}
@@ -0,0 +1,23 @@
import { EditorConfigurationSchema, TEditorConfiguration } from '../../../documents/editor/core';
type TResult = { error: string; data?: undefined } | { data: TEditorConfiguration; error?: undefined };
export default function validateTextAreaValue(value: string): TResult {
let jsonObject = undefined;
try {
jsonObject = JSON.parse(value);
} catch {
return { error: 'Invalid json' };
}
const parseResult = EditorConfigurationSchema.safeParse(jsonObject);
if (!parseResult.success) {
return { error: 'Invalid JSON schema' };
}
if (!parseResult.data.root) {
return { error: 'Missing "root" node' };
}
return { data: parseResult.data };
}
@@ -0,0 +1,11 @@
import React, { useMemo } from 'react';
import { useDocument } from '../../documents/editor/EditorContext';
import HighlightedCodePanel from './helper/HighlightedCodePanel';
export default function JsonPanel() {
const document = useDocument();
const code = useMemo(() => JSON.stringify(document, null, ' '), [document]);
return <HighlightedCodePanel type="json" value={code} />;
}
@@ -0,0 +1,59 @@
import React from 'react';
import { CodeOutlined, DataObjectOutlined, EditOutlined, PreviewOutlined } from '@mui/icons-material';
import { Tab, Tabs, Tooltip } from '@mui/material';
import { setSelectedMainTab, useSelectedMainTab } from '../../documents/editor/EditorContext';
export default function MainTabsGroup() {
const selectedMainTab = useSelectedMainTab();
const handleChange = (_: unknown, v: unknown) => {
switch (v) {
case 'json':
case 'preview':
case 'editor':
case 'html':
setSelectedMainTab(v);
return;
default:
setSelectedMainTab('editor');
}
};
return (
<Tabs value={selectedMainTab} onChange={handleChange}>
<Tab
value="editor"
label={
<Tooltip title="Edit">
<EditOutlined fontSize="small" />
</Tooltip>
}
/>
<Tab
value="preview"
label={
<Tooltip title="Preview">
<PreviewOutlined fontSize="small" />
</Tooltip>
}
/>
<Tab
value="html"
label={
<Tooltip title="HTML output">
<CodeOutlined fontSize="small" />
</Tooltip>
}
/>
<Tab
value="json"
label={
<Tooltip title="JSON output">
<DataObjectOutlined fontSize="small" />
</Tooltip>
}
/>
</Tabs>
);
}
@@ -0,0 +1,37 @@
import React, { useState } from 'react';
import { IosShareOutlined } from '@mui/icons-material';
import { IconButton, Snackbar, Tooltip } from '@mui/material';
import { useDocument } from '../../documents/editor/EditorContext';
export default function ShareButton() {
const document = useDocument();
const [message, setMessage] = useState < string | null > (null);
const onClick = async () => {
const c = encodeURIComponent(JSON.stringify(document));
location.hash = `#code/${btoa(c)}`;
setMessage('The URL was updated. Copy it to share your current template.');
};
const onClose = () => {
setMessage(null);
};
return (
<>
<IconButton onClick={onClick}>
<Tooltip title="Share current template">
<IosShareOutlined fontSize="small" />
</Tooltip>
</IconButton>
<Snackbar
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
open={message !== null}
onClose={onClose}
message={message}
/>
</>
);
}
@@ -0,0 +1,40 @@
import React, { useEffect, useState } from 'react';
import { html, json } from './highlighters';
type TextEditorPanelProps = {
type: 'json' | 'html' | 'javascript';
value: string;
};
export default function HighlightedCodePanel({ type, value }: TextEditorPanelProps) {
const [code, setCode] = useState<string | null>(null);
useEffect(() => {
switch (type) {
case 'html':
html(value).then(setCode);
return;
case 'json':
json(value).then(setCode);
return;
}
}, [setCode, value, type]);
if (code === null) {
return null;
}
return (
<pre
style={{ margin: 0, padding: 16, height: '100%', overflow: 'auto' }}
dangerouslySetInnerHTML={{ __html: code }}
onClick={(ev) => {
const s = window.getSelection();
if (s === null) {
return;
}
s.selectAllChildren(ev.currentTarget);
}}
/>
);
}
@@ -0,0 +1,28 @@
import hljs from 'highlight.js';
import jsonHighlighter from 'highlight.js/lib/languages/json';
import xmlHighlighter from 'highlight.js/lib/languages/xml';
import prettierPluginBabel from 'prettier/plugins/babel';
import prettierPluginEstree from 'prettier/plugins/estree';
import prettierPluginHtml from 'prettier/plugins/html';
import { format } from 'prettier/standalone';
hljs.registerLanguage('json', jsonHighlighter);
hljs.registerLanguage('html', xmlHighlighter);
export async function html(value: string): Promise<string> {
const prettyValue = await format(value, {
parser: 'html',
plugins: [prettierPluginHtml],
});
return hljs.highlight(prettyValue, { language: 'html' }).value;
}
export async function json(value: string): Promise<string> {
const prettyValue = await format(value, {
parser: 'json',
printWidth: 0,
trailingComma: 'all',
plugins: [prettierPluginBabel, prettierPluginEstree],
});
return hljs.highlight(prettyValue, { language: 'javascript' }).value;
}
@@ -0,0 +1,114 @@
import React from 'react';
import { MonitorOutlined, PhoneIphoneOutlined } from '@mui/icons-material';
import { Box, Stack, SxProps, ToggleButton, ToggleButtonGroup, Tooltip } from '@mui/material';
import { Reader } from '@usewaypoint/email-builder';
import EditorBlock from '../../documents/editor/EditorBlock';
import {
setSelectedScreenSize,
useDocument,
useSelectedMainTab,
useSelectedScreenSize,
} from '../../documents/editor/EditorContext';
import ToggleInspectorPanelButton from '../InspectorDrawer/ToggleInspectorPanelButton';
import DownloadJson from './DownloadJson';
import HtmlPanel from './HtmlPanel';
import ImportJson from './ImportJson';
import JsonPanel from './JsonPanel';
import MainTabsGroup from './MainTabsGroup';
export default function TemplatePanel() {
const document = useDocument();
const selectedMainTab = useSelectedMainTab();
const selectedScreenSize = useSelectedScreenSize();
let mainBoxSx: SxProps = {
height: '100%',
};
if (selectedScreenSize === 'mobile') {
mainBoxSx = {
...mainBoxSx,
margin: '32px auto',
width: 370,
height: 800,
boxShadow:
'rgba(33, 36, 67, 0.04) 0px 10px 20px, rgba(33, 36, 67, 0.04) 0px 2px 6px, rgba(33, 36, 67, 0.04) 0px 0px 1px',
};
}
const handleScreenSizeChange = (_: unknown, value: unknown) => {
switch (value) {
case 'mobile':
case 'desktop':
setSelectedScreenSize(value);
return;
default:
setSelectedScreenSize('desktop');
}
};
const renderMainPanel = () => {
switch (selectedMainTab) {
case 'editor':
return (
<Box sx={mainBoxSx}>
<EditorBlock id="root" />
</Box>
);
case 'preview':
return (
<Box sx={mainBoxSx}>
<Reader document={document} rootBlockId="root" />
</Box>
);
case 'html':
return <HtmlPanel />;
case 'json':
return <JsonPanel />;
}
};
return (
<>
<Stack
sx={{
height: 49,
borderBottom: 1,
borderColor: 'divider',
backgroundColor: 'white',
top: 0,
px: 1,
}}
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Stack px={2} direction="row" gap={2} width="100%" justifyContent="space-between" alignItems="center">
<Stack direction="row" spacing={2}>
<MainTabsGroup />
</Stack>
<Stack direction="row" spacing={2}>
<DownloadJson />
<ImportJson />
<ToggleButtonGroup value={selectedScreenSize} exclusive size="small" onChange={handleScreenSizeChange}>
<ToggleButton value="desktop">
<Tooltip title="Desktop view">
<MonitorOutlined fontSize="small" />
</Tooltip>
</ToggleButton>
<ToggleButton value="mobile">
<Tooltip title="Mobile view">
<PhoneIphoneOutlined fontSize="small" />
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
</Stack>
</Stack>
<ToggleInspectorPanelButton />
</Stack>
<Box sx={{ height: 'calc(100vh - 49px)', overflow: 'auto', minWidth: 370 }}>{renderMainPanel()}</Box>
</>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { Stack, useTheme } from '@mui/material';
import React from 'react';
import { TEditorConfiguration } from '../documents/editor/core';
import { setDocument, subscribeDocument, useInspectorDrawerOpen, useSamplesDrawerOpen } from '../documents/editor/EditorContext';
import { renderHtmlWithMeta } from '../utils';
import InspectorDrawer, { INSPECTOR_DRAWER_WIDTH } from './InspectorDrawer';
import TemplatePanel from './TemplatePanel';
export const DEFAULT_SOURCE: TEditorConfiguration = {
"root": {
"type": "EmailLayout",
"data": {}
}
}
function useDrawerTransition(cssProperty: 'margin-left' | 'margin-right', open: boolean) {
const { transitions } = useTheme();
return transitions.create(cssProperty, {
easing: !open ? transitions.easing.sharp : transitions.easing.easeOut,
duration: !open ? transitions.duration.leavingScreen : transitions.duration.enteringScreen,
});
}
export interface AppProps {
// Initial configuration to load. Optional.
data?: TEditorConfiguration,
// Callback for any change in document. Optional.
onChange?: (json: TEditorConfiguration, html: String) => void,
// Optional height for the Stack component.
height?: string,
}
export default function App(props: AppProps) {
const inspectorDrawerOpen = useInspectorDrawerOpen();
const samplesDrawerOpen = useSamplesDrawerOpen();
const marginLeftTransition = useDrawerTransition('margin-left', samplesDrawerOpen);
const marginRightTransition = useDrawerTransition('margin-right', inspectorDrawerOpen);
if (props.data) {
setDocument(props.data)
} else {
setDocument(DEFAULT_SOURCE)
}
if (props.onChange) {
subscribeDocument ((document) => {
props.onChange?.(
document,
renderHtmlWithMeta(document, { rootBlockId: 'root', outlook: Boolean(document.root?.data?.outlook) })
)
})
}
return (
<>
<InspectorDrawer />
<Stack
sx={{
marginRight: inspectorDrawerOpen ? `${INSPECTOR_DRAWER_WIDTH}px` : 0,
transition: [marginLeftTransition, marginRightTransition].join(', '),
height: props.height ? props.height : 'auto',
}}
>
<TemplatePanel />
</Stack>
</>
);
}
@@ -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)} />,
]}
/>
);
}
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { ColumnsContainerPropsSchema as BaseColumnsContainerPropsSchema } from '@usewaypoint/block-columns-container';
const BasePropsShape = BaseColumnsContainerPropsSchema.shape.props.unwrap().unwrap().shape;
const ColumnsContainerPropsSchema = z.object({
style: BaseColumnsContainerPropsSchema.shape.style,
props: z
.object({
...BasePropsShape,
columns: z.tuple([
z.object({ childrenIds: z.array(z.string()) }),
z.object({ childrenIds: z.array(z.string()) }),
z.object({ childrenIds: z.array(z.string()) }),
]),
})
.optional()
.nullable(),
});
export type ColumnsContainerProps = z.infer<typeof ColumnsContainerPropsSchema>;
export default ColumnsContainerPropsSchema;
@@ -0,0 +1,37 @@
import React from 'react';
import { Container as BaseContainer } from '@usewaypoint/block-container';
import { useCurrentBlockId } from '../../editor/EditorBlock';
import { setDocument, setSelectedBlockId, useDocument } from '../../editor/EditorContext';
import EditorChildrenIds from '../helpers/EditorChildrenIds';
import { ContainerProps } from './ContainerPropsSchema';
export default function ContainerEditor({ style, props }: ContainerProps) {
const childrenIds = props?.childrenIds ?? [];
const document = useDocument();
const currentBlockId = useCurrentBlockId();
return (
<BaseContainer style={style}>
<EditorChildrenIds
childrenIds={childrenIds}
onChange={({ block, blockId, childrenIds }) => {
setDocument({
[blockId]: block,
[currentBlockId]: {
type: 'Container',
data: {
...document[currentBlockId].data,
props: { childrenIds: childrenIds },
},
},
});
setSelectedBlockId(blockId);
}}
/>
</BaseContainer>
);
}
@@ -0,0 +1,17 @@
import { z } from 'zod';
import { ContainerPropsSchema as BaseContainerPropsSchema } from '@usewaypoint/block-container';
const ContainerPropsSchema = z.object({
style: BaseContainerPropsSchema.shape.style,
props: z
.object({
childrenIds: z.array(z.string()).optional().nullable(),
})
.optional()
.nullable(),
});
export default ContainerPropsSchema;
export type ContainerProps = z.infer<typeof ContainerPropsSchema>;
@@ -0,0 +1,103 @@
import React from 'react';
import { useCurrentBlockId } from '../../editor/EditorBlock';
import { setDocument, setSelectedBlockId, useDocument } from '../../editor/EditorContext';
import EditorChildrenIds from '../helpers/EditorChildrenIds';
import { EmailLayoutProps } from './EmailLayoutPropsSchema';
function getFontFamily(fontFamily: EmailLayoutProps['fontFamily']) {
const f = fontFamily ?? 'MODERN_SANS';
switch (f) {
case 'MODERN_SANS':
return '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif';
case 'BOOK_SANS':
return 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif';
case 'ORGANIC_SANS':
return 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif';
case 'GEOMETRIC_SANS':
return 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif';
case 'HEAVY_SANS':
return 'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif';
case 'ROUNDED_SANS':
return 'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif';
case 'MODERN_SERIF':
return 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif';
case 'BOOK_SERIF':
return '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif';
case 'MONOSPACE':
return '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace';
}
}
export default function EmailLayoutEditor(props: EmailLayoutProps) {
const childrenIds = props.childrenIds ?? [];
const document = useDocument();
const currentBlockId = useCurrentBlockId();
return (
<div
onClick={() => {
setSelectedBlockId(null);
}}
style={{
backgroundColor: props.backdropColor ?? '#F5F5F5',
color: props.textColor ?? '#262626',
fontFamily: getFontFamily(props.fontFamily),
fontSize: '16px',
fontWeight: '400',
letterSpacing: '0.15008px',
lineHeight: '1.5',
margin: '0',
padding: '32px 0',
width: '100%',
minHeight: '100%',
}}
>
<table
align="center"
width="100%"
style={{
margin: '0 auto',
maxWidth: '600px',
backgroundColor: props.canvasColor ?? '#FFFFFF',
borderRadius: props.borderRadius ?? undefined,
border: (() => {
const v = props.borderColor;
if (!v) {
return undefined;
}
return `1px solid ${v}`;
})(),
}}
role="presentation"
cellSpacing="0"
cellPadding="0"
border={0}
>
<tbody>
<tr style={{ width: '100%' }}>
<td>
<EditorChildrenIds
childrenIds={childrenIds}
onChange={({ block, blockId, childrenIds }) => {
setDocument({
[blockId]: block,
[currentBlockId]: {
type: 'EmailLayout',
data: {
...document[currentBlockId].data,
childrenIds: childrenIds,
},
},
});
setSelectedBlockId(blockId);
}}
/>
</td>
</tr>
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,37 @@
import { z } from 'zod';
const COLOR_SCHEMA = z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.nullable()
.optional();
const FONT_FAMILY_SCHEMA = z
.enum([
'MODERN_SANS',
'BOOK_SANS',
'ORGANIC_SANS',
'GEOMETRIC_SANS',
'HEAVY_SANS',
'ROUNDED_SANS',
'MODERN_SERIF',
'BOOK_SERIF',
'MONOSPACE',
])
.nullable()
.optional();
const EmailLayoutPropsSchema = z.object({
backdropColor: COLOR_SCHEMA,
borderColor: COLOR_SCHEMA,
borderRadius: z.number().optional().nullable(),
canvasColor: COLOR_SCHEMA,
textColor: COLOR_SCHEMA,
fontFamily: FONT_FAMILY_SCHEMA,
childrenIds: z.array(z.string()).optional().nullable(),
outlook: z.boolean().optional().nullable(),
});
export default EmailLayoutPropsSchema;
export type EmailLayoutProps = z.infer<typeof EmailLayoutPropsSchema>;
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -0,0 +1,160 @@
import React from 'react';
import {
AccountCircleOutlined,
Crop32Outlined,
HMobiledataOutlined,
HorizontalRuleOutlined,
HtmlOutlined,
ImageOutlined,
LibraryAddOutlined,
NotesOutlined,
SmartButtonOutlined,
ViewColumnOutlined,
} from '@mui/icons-material';
import { TEditorBlock } from '../../../../editor/core';
type TButtonProps = {
label: string;
icon: JSX.Element;
block: () => TEditorBlock;
};
export const BUTTONS: TButtonProps[] = [
{
label: 'Heading',
icon: <HMobiledataOutlined />,
block: () => ({
type: 'Heading',
data: {
props: { text: 'Heading' },
style: {
padding: { top: 16, bottom: 16, left: 24, right: 24 },
},
},
}),
},
{
label: 'Text',
icon: <NotesOutlined />,
block: () => ({
type: 'Text',
data: {
props: { text: 'My new text block' },
style: {
padding: { top: 16, bottom: 16, left: 24, right: 24 },
fontWeight: 'normal',
},
},
}),
},
{
label: 'Button',
icon: <SmartButtonOutlined />,
block: () => ({
type: 'Button',
data: {
props: {
text: 'Button',
url: 'https://example.com',
},
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
},
}),
},
{
label: 'Image',
icon: <ImageOutlined />,
block: () => ({
type: 'Image',
data: {
props: {
url: 'https://upload.wikimedia.org/wikipedia/commons/3/3f/Placeholder_view_vector.svg',
alt: 'Sample product',
contentAlignment: 'middle',
linkHref: null,
},
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
},
}),
},
{
label: 'Avatar',
icon: <AccountCircleOutlined />,
block: () => ({
type: 'Avatar',
data: {
props: {
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/8/89/Portrait_Placeholder.png',
shape: 'circle',
},
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
},
}),
},
{
label: 'Divider',
icon: <HorizontalRuleOutlined />,
block: () => ({
type: 'Divider',
data: {
style: { padding: { top: 16, right: 0, bottom: 16, left: 0 } },
props: {
lineColor: '#CCCCCC',
},
},
}),
},
{
label: 'Spacer',
icon: <Crop32Outlined />,
block: () => ({
type: 'Spacer',
data: {},
}),
},
{
label: 'Html',
icon: <HtmlOutlined />,
block: () => ({
type: 'Html',
data: {
props: { contents: '<strong>Hello world</strong>' },
style: {
fontSize: 16,
textAlign: null,
padding: { top: 16, bottom: 16, left: 24, right: 24 },
},
},
}),
},
{
label: 'Columns',
icon: <ViewColumnOutlined />,
block: () => ({
type: 'ColumnsContainer',
data: {
props: {
columnsGap: 16,
columnsCount: 3,
columns: [{ childrenIds: [] }, { childrenIds: [] }, { childrenIds: [] }],
},
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
},
}),
},
{
label: 'Container',
icon: <LibraryAddOutlined />,
block: () => ({
type: 'Container',
data: {
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
},
}),
},
// { label: 'ProgressBar', icon: <ProgressBarOutlined />, block: () => ({}) },
// { label: 'LoopContainer', icon: <ViewListOutlined />, block: () => ({}) },
];
@@ -0,0 +1,37 @@
import React, { useState } from 'react';
import { TEditorBlock } from '../../../../editor/core';
import BlocksMenu from './BlocksMenu';
import DividerButton from './DividerButton';
import PlaceholderButton from './PlaceholderButton';
type Props = {
placeholder?: boolean;
onSelect: (block: TEditorBlock) => void;
};
export default function AddBlockButton({ onSelect, placeholder }: Props) {
const [menuAnchorEl, setMenuAnchorEl] = useState<HTMLElement | null>(null);
const [buttonElement, setButtonElement] = useState<HTMLElement | null>(null);
const handleButtonClick = () => {
setMenuAnchorEl(buttonElement);
};
const renderButton = () => {
if (placeholder) {
return <PlaceholderButton onClick={handleButtonClick} />;
} else {
return <DividerButton buttonElement={buttonElement} onClick={handleButtonClick} />;
}
};
return (
<>
<div ref={setButtonElement} style={{ position: 'relative' }}>
{renderButton()}
</div>
<BlocksMenu anchorEl={menuAnchorEl} setAnchorEl={setMenuAnchorEl} onSelect={onSelect} />
</>
);
}
@@ -0,0 +1,58 @@
import React, { Fragment } from 'react';
import { TEditorBlock } from '../../../editor/core';
import EditorBlock from '../../../editor/EditorBlock';
import AddBlockButton from './AddBlockMenu';
export type EditorChildrenChange = {
blockId: string;
block: TEditorBlock;
childrenIds: string[];
};
function generateId() {
return `block-${Date.now()}`;
}
export type EditorChildrenIdsProps = {
childrenIds: string[] | null | undefined;
onChange: (val: EditorChildrenChange) => void;
};
export default function EditorChildrenIds({ childrenIds, onChange }: EditorChildrenIdsProps) {
const appendBlock = (block: TEditorBlock) => {
const blockId = generateId();
return onChange({
blockId,
block,
childrenIds: [...(childrenIds || []), blockId],
});
};
const insertBlock = (block: TEditorBlock, index: number) => {
const blockId = generateId();
const newChildrenIds = [...(childrenIds || [])];
newChildrenIds.splice(index, 0, blockId);
return onChange({
blockId,
block,
childrenIds: newChildrenIds,
});
};
if (!childrenIds || childrenIds.length === 0) {
return <AddBlockButton placeholder onSelect={appendBlock} />;
}
return (
<>
{childrenIds.map((childId, i) => (
<Fragment key={childId}>
<AddBlockButton onSelect={(block) => insertBlock(block, i)} />
<EditorBlock id={childId} />
</Fragment>
))}
<AddBlockButton onSelect={appendBlock} />
</>
);
}
@@ -0,0 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export type TStyle = {
backgroundColor?: any;
borderColor?: any;
borderRadius?: any;
color?: any;
fontFamily?: any;
fontSize?: any;
fontWeight?: any;
padding?: any;
textAlign?: any;
};
@@ -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>
);
}
@@ -0,0 +1,26 @@
import React, { CSSProperties } from 'react';
import { TStyle } from '../TStyle';
type TReaderBlockWrapperProps = {
style: TStyle;
children: JSX.Element;
};
export default function ReaderBlockWrapper({ style, children }: TReaderBlockWrapperProps) {
const { padding, borderColor, ...restStyle } = style;
const cssStyle: CSSProperties = {
...restStyle,
};
if (padding) {
const { top, bottom, left, right } = padding;
cssStyle.padding = `${top}px ${right}px ${bottom}px ${left}px`;
}
if (borderColor) {
cssStyle.border = `1px solid ${borderColor}`;
}
return <div style={{ maxWidth: '100%', ...cssStyle }}>{children}</div>;
}
@@ -0,0 +1,272 @@
import React from 'react';
import { ArrowDownwardOutlined, ArrowUpwardOutlined, ContentCopyOutlined, DeleteOutlined } from '@mui/icons-material';
import { IconButton, Paper, Stack, SxProps, Tooltip } from '@mui/material';
import { TEditorBlock } from '../../../editor/core';
import { resetDocument, setSelectedBlockId, useDocument } from '../../../editor/EditorContext';
import { ColumnsContainerProps } from '../../ColumnsContainer/ColumnsContainerPropsSchema';
const sx: SxProps = {
position: 'absolute',
top: 0,
left: -56,
borderRadius: 64,
paddingX: 0.5,
paddingY: 1
};
type Props = {
blockId: string;
};
export default function TuneMenu({ blockId }: Props) {
const document = useDocument();
const handleDeleteClick = () => {
const filterChildrenIds = (childrenIds: string[] | null | undefined) => {
if (!childrenIds) {
return childrenIds;
}
return childrenIds.filter((f) => f !== blockId);
};
const nDocument: typeof document = { ...document };
for (const [id, b] of Object.entries(nDocument)) {
const block = b as TEditorBlock;
if (id === blockId) {
continue;
}
switch (block.type) {
case 'EmailLayout':
nDocument[id] = {
...block,
data: {
...block.data,
childrenIds: filterChildrenIds(block.data.childrenIds),
},
};
break;
case 'Container':
nDocument[id] = {
...block,
data: {
...block.data,
props: {
...block.data.props,
childrenIds: filterChildrenIds(block.data.props?.childrenIds),
},
},
};
break;
case 'ColumnsContainer':
nDocument[id] = {
type: 'ColumnsContainer',
data: {
style: block.data.style,
props: {
...block.data.props,
columns: block.data.props?.columns?.map((c) => ({
childrenIds: filterChildrenIds(c.childrenIds),
})),
},
} as ColumnsContainerProps,
};
break;
default:
nDocument[id] = block;
}
}
delete nDocument[blockId];
resetDocument(nDocument);
};
const handleDuplicateClick = () => {
const block = document[blockId] as TEditorBlock;
if (!block) {
return;
}
// Recursively clone a block and all its descendants, assigning new IDs.
const clones: Record<string, TEditorBlock> = {};
const cloneBlock = (srcId: string): string => {
const newId = `block-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const src = document[srcId] as TEditorBlock;
if (!src) {
return newId;
}
const cloned: TEditorBlock = JSON.parse(JSON.stringify(src));
// Remap childrenIds for container types so each child is also cloned.
if (cloned.type === 'Container' && cloned.data.props?.childrenIds) {
cloned.data.props.childrenIds = cloned.data.props.childrenIds.map(cloneBlock);
} else if (cloned.type === 'ColumnsContainer' && cloned.data.props?.columns) {
cloned.data.props.columns = cloned.data.props.columns.map((c) => ({
childrenIds: c.childrenIds?.map(cloneBlock) ?? [],
}));
}
clones[newId] = cloned;
return newId;
};
const newBlockId = cloneBlock(blockId);
const insertAfter = (ids: string[] | null | undefined) => {
if (!ids) {
return ids;
}
const index = ids.indexOf(blockId);
if (index < 0) {
return ids;
}
const newIds = [...ids];
newIds.splice(index + 1, 0, newBlockId);
return newIds;
};
const nDocument: typeof document = {
...document,
...clones,
};
for (const [id, b] of Object.entries(nDocument)) {
const entry = b as TEditorBlock;
if (id === blockId || id in clones) {
continue;
}
switch (entry.type) {
case 'EmailLayout':
nDocument[id] = {
...entry,
data: {
...entry.data,
childrenIds: insertAfter(entry.data.childrenIds),
},
};
break;
case 'Container':
nDocument[id] = {
...entry,
data: {
...entry.data,
props: {
...entry.data.props,
childrenIds: insertAfter(entry.data.props?.childrenIds),
},
},
};
break;
case 'ColumnsContainer':
nDocument[id] = {
type: 'ColumnsContainer',
data: {
style: entry.data.style,
props: {
...entry.data.props,
columns: entry.data.props?.columns?.map((c) => ({
childrenIds: insertAfter(c.childrenIds),
})),
},
} as ColumnsContainerProps,
};
break;
default:
nDocument[id] = entry;
}
}
resetDocument(nDocument);
setSelectedBlockId(newBlockId);
};
const handleMoveClick = (direction: 'up' | 'down') => {
const moveChildrenIds = (ids: string[] | null | undefined) => {
if (!ids) {
return ids;
}
const index = ids.indexOf(blockId);
if (index < 0) {
return ids;
}
const childrenIds = [...ids];
if (direction === 'up' && index > 0) {
[childrenIds[index], childrenIds[index - 1]] = [childrenIds[index - 1], childrenIds[index]];
} else if (direction === 'down' && index < childrenIds.length - 1) {
[childrenIds[index], childrenIds[index + 1]] = [childrenIds[index + 1], childrenIds[index]];
}
return childrenIds;
};
const nDocument: typeof document = { ...document };
for (const [id, b] of Object.entries(nDocument)) {
const block = b as TEditorBlock;
if (id === blockId) {
continue;
}
switch (block.type) {
case 'EmailLayout':
nDocument[id] = {
...block,
data: {
...block.data,
childrenIds: moveChildrenIds(block.data.childrenIds),
},
};
break;
case 'Container':
nDocument[id] = {
...block,
data: {
...block.data,
props: {
...block.data.props,
childrenIds: moveChildrenIds(block.data.props?.childrenIds),
},
},
};
break;
case 'ColumnsContainer':
nDocument[id] = {
type: 'ColumnsContainer',
data: {
style: block.data.style,
props: {
...block.data.props,
columns: block.data.props?.columns?.map((c) => ({
childrenIds: moveChildrenIds(c.childrenIds),
})),
},
} as ColumnsContainerProps,
};
break;
default:
nDocument[id] = block;
}
}
resetDocument(nDocument);
setSelectedBlockId(blockId);
};
return (
<Paper sx={sx} onClick={(ev) => ev.stopPropagation()}>
<Stack>
<Tooltip title="Move up" placement="left-start">
<IconButton onClick={() => handleMoveClick('up')} sx={{ color: 'text.primary' }}>
<ArrowUpwardOutlined fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Move down" placement="left-start">
<IconButton onClick={() => handleMoveClick('down')} sx={{ color: 'text.primary' }}>
<ArrowDownwardOutlined fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Duplicate" placement="left-start">
<IconButton onClick={handleDuplicateClick} sx={{ color: 'text.primary' }}>
<ContentCopyOutlined fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete" placement="left-start">
<IconButton onClick={handleDeleteClick} sx={{ color: 'text.primary' }}>
<DeleteOutlined fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Paper>
);
}
@@ -0,0 +1,61 @@
export const FONT_FAMILIES = [
{
key: 'MODERN_SANS',
label: 'Modern sans',
value: '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif',
},
{
key: 'BOOK_SANS',
label: 'Book sans',
value: 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif',
},
{
key: 'ORGANIC_SANS',
label: 'Organic sans',
value: 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif',
},
{
key: 'GEOMETRIC_SANS',
label: 'Geometric sans',
value: 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif',
},
{
key: 'HEAVY_SANS',
label: 'Heavy sans',
value:
'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif',
},
{
key: 'ROUNDED_SANS',
label: 'Rounded sans',
value:
'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif',
},
{
key: 'MODERN_SERIF',
label: 'Modern serif',
value: 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif',
},
{
key: 'BOOK_SERIF',
label: 'Book serif',
value: '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif',
},
{
key: 'MONOSPACE',
label: 'Monospace',
value: '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace',
},
];
export const FONT_FAMILY_NAMES = [
'MODERN_SANS',
'BOOK_SANS',
'ORGANIC_SANS',
'GEOMETRIC_SANS',
'HEAVY_SANS',
'ROUNDED_SANS',
'MODERN_SERIF',
'BOOK_SERIF',
'MONOSPACE',
] as const;
@@ -0,0 +1,28 @@
import { z } from 'zod';
import { FONT_FAMILY_NAMES } from './fontFamily';
export function zColor() {
return z.string().regex(/^#[0-9a-fA-F]{6}$/);
}
export function zFontFamily() {
return z.enum(FONT_FAMILY_NAMES);
}
export function zFontWeight() {
return z.enum(['bold', 'normal']);
}
export function zTextAlign() {
return z.enum(['left', 'center', 'right']);
}
export function zPadding() {
return z.object({
top: z.number(),
bottom: z.number(),
right: z.number(),
left: z.number(),
});
}
@@ -0,0 +1,29 @@
import React, { createContext, useContext } from 'react';
import { EditorBlock as CoreEditorBlock } from './core';
import { useDocument } from './EditorContext';
const EditorBlockContext = createContext<string | null>(null);
export const useCurrentBlockId = () => useContext(EditorBlockContext)!;
type EditorBlockProps = {
id: string;
};
/**
*
* @param id - Block id
* @returns EditorBlock component that loads data from the EditorDocumentContext
*/
export default function EditorBlock({ id }: EditorBlockProps) {
const document = useDocument();
const block = document[id];
if (!block) {
throw new Error('Could not find block');
}
return (
<EditorBlockContext.Provider value={id}>
<CoreEditorBlock {...block} />
</EditorBlockContext.Provider>
);
}
@@ -0,0 +1,114 @@
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import getConfiguration from '../../getConfiguration';
import { TEditorConfiguration } from './core';
type TValue = {
document: TEditorConfiguration;
selectedBlockId: string | null;
selectedSidebarTab: 'block-configuration' | 'styles';
selectedMainTab: 'editor' | 'preview' | 'json' | 'html';
selectedScreenSize: 'desktop' | 'mobile';
inspectorDrawerOpen: boolean;
samplesDrawerOpen: boolean;
};
const editorStateStore = create(subscribeWithSelector<TValue>(() => ({
document: getConfiguration(window.location.hash),
selectedBlockId: null,
selectedSidebarTab: 'styles',
selectedMainTab: 'editor',
selectedScreenSize: 'desktop',
inspectorDrawerOpen: true,
samplesDrawerOpen: true,
})));
export function useDocument() {
return editorStateStore((s) => s.document);
}
export function subscribeDocument (listener: (selectedState: TEditorConfiguration, previousSelectedState: TEditorConfiguration) => void) {
editorStateStore.subscribe((state) => state.document, listener)
}
export function useSelectedBlockId() {
return editorStateStore((s) => s.selectedBlockId);
}
export function useSelectedScreenSize() {
return editorStateStore((s) => s.selectedScreenSize);
}
export function useSelectedMainTab() {
return editorStateStore((s) => s.selectedMainTab);
}
export function setSelectedMainTab(selectedMainTab: TValue['selectedMainTab']) {
return editorStateStore.setState({ selectedMainTab });
}
export function useSelectedSidebarTab() {
return editorStateStore((s) => s.selectedSidebarTab);
}
export function useInspectorDrawerOpen() {
return editorStateStore((s) => s.inspectorDrawerOpen);
}
export function useSamplesDrawerOpen() {
return editorStateStore((s) => s.samplesDrawerOpen);
}
export function setSelectedBlockId(selectedBlockId: TValue['selectedBlockId']) {
const selectedSidebarTab = selectedBlockId === null ? 'styles' : 'block-configuration';
const options: Partial<TValue> = {};
if (selectedBlockId !== null) {
options.inspectorDrawerOpen = true;
}
return editorStateStore.setState({
selectedBlockId,
selectedSidebarTab,
...options,
});
}
export function setSidebarTab(selectedSidebarTab: TValue['selectedSidebarTab']) {
return editorStateStore.setState({ selectedSidebarTab });
}
export function resetDocument(document: TValue['document']) {
return editorStateStore.setState({
document,
selectedSidebarTab: 'styles',
selectedBlockId: null,
});
}
export function setDocument(document: TValue['document']) {
const originalDocument = editorStateStore.getState().document;
return editorStateStore.setState({
document: {
...originalDocument,
...document,
},
});
}
export function toggleInspectorDrawerOpen() {
const inspectorDrawerOpen = !editorStateStore.getState().inspectorDrawerOpen;
return editorStateStore.setState({ inspectorDrawerOpen });
}
export function toggleSamplesDrawerOpen() {
const samplesDrawerOpen = !editorStateStore.getState().samplesDrawerOpen;
return editorStateStore.setState({ samplesDrawerOpen });
}
export function setSelectedScreenSize(selectedScreenSize: TValue['selectedScreenSize']) {
return editorStateStore.setState({ selectedScreenSize });
}
@@ -0,0 +1,143 @@
import React from 'react';
import { z } from 'zod';
import { Avatar, AvatarPropsSchema } from '@usewaypoint/block-avatar';
import { Button, ButtonPropsSchema } from '@usewaypoint/block-button';
import { Divider, DividerPropsSchema } from '@usewaypoint/block-divider';
import { Heading, HeadingPropsSchema } from '@usewaypoint/block-heading';
import { Html, HtmlPropsSchema } from '@usewaypoint/block-html';
import { Image, ImagePropsSchema } from '@usewaypoint/block-image';
import { Spacer, SpacerPropsSchema } from '@usewaypoint/block-spacer';
import { Text, TextPropsSchema } from '@usewaypoint/block-text';
import {
buildBlockComponent,
buildBlockConfigurationDictionary,
buildBlockConfigurationSchema,
} from '@usewaypoint/document-core';
import ColumnsContainerEditor from '../blocks/ColumnsContainer/ColumnsContainerEditor';
import ColumnsContainerPropsSchema from '../blocks/ColumnsContainer/ColumnsContainerPropsSchema';
import ContainerEditor from '../blocks/Container/ContainerEditor';
import ContainerPropsSchema from '../blocks/Container/ContainerPropsSchema';
import EmailLayoutEditor from '../blocks/EmailLayout/EmailLayoutEditor';
import EmailLayoutPropsSchema from '../blocks/EmailLayout/EmailLayoutPropsSchema';
import EditorBlockWrapper from '../blocks/helpers/block-wrappers/EditorBlockWrapper';
// Adds an opt-in `embed` flag to the upstream Image props. The renderer
// (frontend/email-builder/src/utils.tsx) re-tags marked <img>s with
// `data-embed` and the backend resolves the src filename to a media item.
export const ImgPropsSchema = ImagePropsSchema.extend({
props: z.object({
width: z.number().nullable().optional(),
height: z.number().nullable().optional(),
url: z.string().nullable().optional(),
alt: z.string().nullable().optional(),
linkHref: z.string().nullable().optional(),
contentAlignment: z.enum(['top', 'middle', 'bottom']).nullable().optional(),
embed: z.boolean().nullable().optional(),
}).nullable().optional(),
});
export type EagleCastImageProps = z.infer<typeof ImgPropsSchema>;
const EDITOR_DICTIONARY = buildBlockConfigurationDictionary({
Avatar: {
schema: AvatarPropsSchema,
Component: (props) => (
<EditorBlockWrapper>
<Avatar {...props} />
</EditorBlockWrapper>
),
},
Button: {
schema: ButtonPropsSchema,
Component: (props) => (
<EditorBlockWrapper>
<Button {...props} />
</EditorBlockWrapper>
),
},
Container: {
schema: ContainerPropsSchema,
Component: (props) => (
<EditorBlockWrapper>
<ContainerEditor {...props} />
</EditorBlockWrapper>
),
},
ColumnsContainer: {
schema: ColumnsContainerPropsSchema,
Component: (props) => (
<EditorBlockWrapper>
<ColumnsContainerEditor {...props} />
</EditorBlockWrapper>
),
},
Heading: {
schema: HeadingPropsSchema,
Component: (props) => (
<EditorBlockWrapper>
<Heading {...props} />
</EditorBlockWrapper>
),
},
Html: {
schema: HtmlPropsSchema,
Component: (props) => (
<EditorBlockWrapper>
<Html {...props} />
</EditorBlockWrapper>
),
},
Image: {
schema: ImgPropsSchema,
Component: (data) => {
const props = {
...data,
props: {
...data.props,
url: data.props?.url ?? 'https://placehold.co/600x400@2x/F8F8F8/CCC?text=Your%20image',
},
};
return (
<EditorBlockWrapper>
<Image {...props} />
</EditorBlockWrapper>
);
},
},
Text: {
schema: TextPropsSchema,
Component: (props) => (
<EditorBlockWrapper>
<Text {...props} />
</EditorBlockWrapper>
),
},
EmailLayout: {
schema: EmailLayoutPropsSchema,
Component: (p) => <EmailLayoutEditor {...p} />,
},
Spacer: {
schema: SpacerPropsSchema,
Component: (props) => (
<EditorBlockWrapper>
<Spacer {...props} />
</EditorBlockWrapper>
),
},
Divider: {
schema: DividerPropsSchema,
Component: (props) => (
<EditorBlockWrapper>
<Divider {...props} />
</EditorBlockWrapper>
),
},
});
export const EditorBlock = buildBlockComponent(EDITOR_DICTIONARY);
export const EditorBlockSchema = buildBlockConfigurationSchema(EDITOR_DICTIONARY);
export const EditorConfigurationSchema = z.record(z.string(), EditorBlockSchema);
export type TEditorBlock = z.infer<typeof EditorBlockSchema>;
export type TEditorConfiguration = Record<string, TEditorBlock>;
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,15 @@
import EMPTY_EMAIL_MESSAGE from './sample/empty-email-message';
export default function getConfiguration(template: string) {
if (template.startsWith('#code/')) {
const encodedString = template.replace('#code/', '');
const configurationString = decodeURIComponent(atob(encodedString));
try {
return JSON.parse(configurationString);
} catch {
console.error(`Couldn't load configuration from hash.`);
}
}
return EMPTY_EMAIL_MESSAGE;
}
@@ -0,0 +1,16 @@
import { TEditorConfiguration } from '../../documents/editor/core';
const EMPTY_EMAIL_MESSAGE: TEditorConfiguration = {
root: {
type: 'EmailLayout',
data: {
backdropColor: '#F5F5F5',
canvasColor: '#FFFFFF',
textColor: '#262626',
fontFamily: 'MODERN_SANS',
childrenIds: [],
},
},
};
export default EMPTY_EMAIL_MESSAGE;
+34
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 };
+402
View File
@@ -0,0 +1,402 @@
type TStyleMap = Record<string, string>;
type TPaddingValues = {
top: number;
right: number;
bottom: number;
left: number;
};
const PRESENTATION_TABLE_STYLE = 'border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;';
function appendMissingStyles(style: string | null, declarations: Array<[string, string]>) {
const current = (style || '').trim();
const lower = current.toLowerCase();
const missing = declarations
.filter(([property]) => !lower.includes(`${property.toLowerCase()}:`))
.map(([property, value]) => `${property}:${value}`);
if (missing.length === 0) {
return current;
}
return [current.replace(/;+\s*$/, ''), ...missing]
.filter(Boolean)
.join(';');
}
function setStyleValues(style: string | null, declarations: Array<[string, string | null]>) {
const styleMap = parseStyleMap(style);
declarations.forEach(([property, value]) => {
const key = property.toLowerCase();
if (value === null || value === '') {
delete styleMap[key];
return;
}
styleMap[key] = value;
});
return Object.entries(styleMap)
.map(([property, value]) => `${property}:${value}`)
.join(';');
}
function parseStyleMap(style: string | null) {
return (style || '')
.split(';')
.map((entry) => entry.trim())
.filter(Boolean)
.reduce<TStyleMap>((acc, entry) => {
const separator = entry.indexOf(':');
if (separator === -1) {
return acc;
}
const property = entry.slice(0, separator).trim().toLowerCase();
const value = entry.slice(separator + 1).trim();
if (property) {
acc[property] = value;
}
return acc;
}, {});
}
function getPixelValue(value?: string) {
if (!value) {
return null;
}
const match = value.trim().match(/^(-?\d+(?:\.\d+)?)px$/i);
if (!match) {
return null;
}
return Math.round(Number(match[1]));
}
function getPixelWidthFromImage(img: HTMLImageElement) {
const attrWidth = img.getAttribute('width');
if (attrWidth && /^\d+$/.test(attrWidth)) {
return attrWidth;
}
const style = img.getAttribute('style') || '';
const widthMatch = style.match(/(?:^|;)\s*width\s*:\s*(\d+)px(?:;|$)/i);
if (widthMatch) {
return widthMatch[1];
}
const maxWidthMatch = style.match(/(?:^|;)\s*max-width\s*:\s*(\d+)px(?:;|$)/i);
if (maxWidthMatch) {
return maxWidthMatch[1];
}
return null;
}
function getPaddingValues(styleMap: TStyleMap): TPaddingValues {
const shorthand = styleMap.padding?.trim().split(/\s+/) || [];
const [
topFromShorthand,
rightFromShorthand = topFromShorthand,
bottomFromShorthand = topFromShorthand,
leftFromShorthand = rightFromShorthand,
] = shorthand;
return {
top: getPixelValue(styleMap['padding-top'] || topFromShorthand) || 0,
right: getPixelValue(styleMap['padding-right'] || rightFromShorthand) || 0,
bottom: getPixelValue(styleMap['padding-bottom'] || bottomFromShorthand) || 0,
left: getPixelValue(styleMap['padding-left'] || leftFromShorthand) || 0,
};
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function escapeAttribute(value: string) {
return escapeHtml(value).replace(/"/g, '&quot;');
}
function createFragmentFromHtml(node: Element, html: string) {
const range = node.ownerDocument.createRange();
range.selectNode(node);
return range.createContextualFragment(html);
}
function replaceNodeWithHtml(node: Element, html: string) {
node.replaceWith(createFragmentFromHtml(node, html));
}
function escapeTemplateString(value: string) {
return value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
}
function makeSafeTemplate(raw: string) {
// Encode angle brackets so DOMParser does not consume Outlook conditional comments
// before the Go template expression is evaluated.
const escaped = escapeTemplateString(raw)
.replace(/</g, '\\x3c')
.replace(/>/g, '\\x3e');
return `{{ Safe "${escaped}" }}`;
}
function getWrapperOptions(style: string | null) {
const styleValue = style || '';
const styleMap = parseStyleMap(styleValue);
const align = styleMap['text-align'] || 'left';
const backgroundColor = styleMap['background-color'];
const bgcolorAttr = backgroundColor ? ` bgcolor="${escapeAttribute(backgroundColor)}"` : '';
return { styleValue, styleMap, align, bgcolorAttr };
}
function buildPresentationTable(contents: string, width: string = '100%') {
const widthAttr = width && width !== 'auto' ? ` width="${escapeAttribute(width)}"` : '';
return `<table role="presentation"${widthAttr} cellpadding="0" cellspacing="0" border="0" style="${PRESENTATION_TABLE_STYLE}">${contents}</table>`;
}
function hasSingleChildMatching(div: HTMLDivElement, predicate: (child: Element) => boolean) {
const children = Array.from(div.children);
return children.length === 1 && predicate(children[0]);
}
function addTableDefaults(doc: Document) {
doc.querySelectorAll('table[role="presentation"]').forEach((table) => {
if (!table.getAttribute('cellpadding')) {
table.setAttribute('cellpadding', '0');
}
if (!table.getAttribute('cellspacing')) {
table.setAttribute('cellspacing', '0');
}
if (!table.getAttribute('border')) {
table.setAttribute('border', '0');
}
table.setAttribute(
'style',
appendMissingStyles(table.getAttribute('style'), [
['border-collapse', 'collapse'],
['mso-table-lspace', '0pt'],
['mso-table-rspace', '0pt'],
])
);
});
}
function isStandaloneImage(img: HTMLImageElement) {
const parent = img.parentElement;
if (!parent) {
return false;
}
if (parent.tagName === 'DIV') {
return hasSingleChildMatching(parent as HTMLDivElement, (child) => child.tagName === 'IMG');
}
if (parent.tagName === 'A' && parent.children.length === 1) {
const grandparent = parent.parentElement;
return grandparent?.tagName === 'DIV'
&& hasSingleChildMatching(grandparent as HTMLDivElement, (child) => child.tagName === 'A');
}
return false;
}
function hardenImages(doc: Document) {
doc.querySelectorAll('img').forEach((img) => {
img.setAttribute('border', '0');
const width = getPixelWidthFromImage(img);
if (width && !img.getAttribute('width')) {
img.setAttribute('width', width);
}
const standaloneImage = isStandaloneImage(img);
const declarations: Array<[string, string | null]> = [
['border', '0'],
['outline', 'none'],
['text-decoration', 'none'],
['height', 'auto'],
['-ms-interpolation-mode', 'bicubic'],
];
if (standaloneImage) {
declarations.unshift(['display', 'block']);
declarations.push(['vertical-align', null]);
}
img.setAttribute('style', setStyleValues(img.getAttribute('style'), declarations));
const parent = img.parentElement;
if (standaloneImage && parent?.tagName === 'A') {
parent.setAttribute('style', setStyleValues(parent.getAttribute('style'), [
['display', 'inline-block'],
['border', '0'],
['text-decoration', 'none'],
]));
}
});
}
function transformImageBlocks(doc: Document) {
const wrappers = Array.from(doc.querySelectorAll('div')).filter((div) => hasSingleChildMatching(div as HTMLDivElement, (child) => {
if (child.tagName === 'IMG') {
return true;
}
return child.tagName === 'A' && child.children.length === 1 && child.querySelector('img') !== null;
})) as HTMLDivElement[];
wrappers.forEach((div) => {
const { styleValue, align, bgcolorAttr } = getWrapperOptions(div.getAttribute('style'));
const content = div.innerHTML;
const innerTable = buildPresentationTable(`<tbody><tr><td align="${escapeAttribute(align)}">${content}</td></tr></tbody>`, 'auto');
const html = buildPresentationTable(
`<tbody><tr><td align="${escapeAttribute(align)}"${bgcolorAttr} style="${escapeAttribute(styleValue)}">${innerTable}</td></tr></tbody>`
);
replaceNodeWithHtml(div, html);
});
}
function transformSimpleDivBlocks(doc: Document) {
const wrappers = Array.from(doc.querySelectorAll('div')).filter((div) => {
const { styleMap } = getWrapperOptions(div.getAttribute('style'));
if (!styleMap.padding && !styleMap.height) {
return false;
}
if (div.children.length > 0) {
const firstChild = div.children[0];
if (firstChild.tagName === 'A' || firstChild.tagName === 'IMG' || firstChild.tagName === 'TABLE') {
return false;
}
}
if (styleMap['min-height'] && styleMap.width === '100%') {
return false;
}
return true;
}) as HTMLDivElement[];
wrappers.forEach((div) => {
const { styleValue, styleMap, align, bgcolorAttr } = getWrapperOptions(div.getAttribute('style'));
const height = getPixelValue(styleMap.height);
const isSpacer = div.children.length === 0 && (div.textContent || '').trim() === '' && height !== null;
if (isSpacer) {
const spacerHtml = buildPresentationTable(
`<tbody><tr><td${bgcolorAttr} height="${height}" style="${escapeAttribute(styleValue)};line-height:${height}px;font-size:${height}px;">&nbsp;</td></tr></tbody>`
);
replaceNodeWithHtml(div, spacerHtml);
return;
}
const blockHtml = buildPresentationTable(
`<tbody><tr><td align="${escapeAttribute(align)}"${bgcolorAttr} style="${escapeAttribute(styleValue)}">${div.innerHTML}</td></tr></tbody>`
);
replaceNodeWithHtml(div, blockHtml);
});
}
function buildBulletproofButton(anchor: HTMLAnchorElement, wrapperStyle: string) {
const anchorStyleMap = parseStyleMap(anchor.getAttribute('style'));
const wrapperStyleMap = parseStyleMap(wrapperStyle);
const text = anchor.textContent?.replace(/\s+/g, ' ').trim() || '';
const href = anchor.getAttribute('href') || '#';
const target = anchor.getAttribute('target');
const align = wrapperStyleMap['text-align'] || 'left';
const buttonColor = anchorStyleMap['background-color'] || '#1b3a5b';
const textColor = anchorStyleMap.color || '#ffffff';
const fontSize = getPixelValue(anchorStyleMap['font-size']) || 16;
const fontWeight = anchorStyleMap['font-weight'] || 'bold';
const fontFamily = anchorStyleMap['font-family'] || 'Arial, sans-serif';
const borderRadius = getPixelValue(anchorStyleMap['border-radius']) || 0;
const paddingValues = getPaddingValues(anchorStyleMap);
const lineHeight = getPixelValue(anchorStyleMap['line-height']) || Math.round(fontSize * 1.2);
const display = (anchorStyleMap.display || '').toLowerCase();
const fullWidth = display === 'block' || anchorStyleMap.width === '100%';
const targetAttr = target ? ` target="${escapeAttribute(target)}"` : '';
if (fullWidth) {
const anchorStyle = setStyleValues(anchor.getAttribute('style'), [
['display', 'block'],
['text-align', 'center'],
['border', '1px solid ' + buttonColor],
]);
return [
buildPresentationTable(
`<tbody><tr><td align="${escapeAttribute(align)}" style="${escapeAttribute(wrapperStyle)}">${buildPresentationTable(
`<tbody><tr><td bgcolor="${escapeAttribute(buttonColor)}" style="background-color:${escapeAttribute(buttonColor)};border-radius:${borderRadius}px;"><a href="${escapeAttribute(href)}"${targetAttr} style="${escapeAttribute(anchorStyle)}">${escapeHtml(text)}</a></td></tr></tbody>`
)}</td></tr></tbody>`
),
].join('');
}
const estimatedTextWidth = Math.max(1, Math.round(text.length * fontSize * (fontWeight.toLowerCase() === 'bold' ? 0.68 : 0.62)));
const estimatedWidth = Math.max(40, estimatedTextWidth + paddingValues.left + paddingValues.right);
const estimatedHeight = Math.max(lineHeight + paddingValues.top + paddingValues.bottom, 32);
const arcsize = Math.max(0, Math.min(50, Math.round((borderRadius / estimatedHeight) * 100)));
const cleanAnchorStyle = anchor.getAttribute('style') || '';
const msoStart = makeSafeTemplate('<!--[if mso]>');
const msoEnd = makeSafeTemplate('<![endif]-->');
const vml = `<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${escapeAttribute(href)}" style="height:${estimatedHeight}px;v-text-anchor:middle;width:${estimatedWidth}px;" arcsize="${arcsize}%" strokecolor="${escapeAttribute(buttonColor)}" fillcolor="${escapeAttribute(buttonColor)}"><w:anchorlock/><center style="color:${escapeAttribute(textColor)};font-family:${escapeAttribute(fontFamily)};font-size:${fontSize}px;font-weight:${escapeAttribute(fontWeight)};">${escapeHtml(text)}</center></v:roundrect>`;
const nonMsoStart = makeSafeTemplate('<!--[if !mso]><!-->');
const nonMsoEnd = makeSafeTemplate('<!--<![endif]-->');
return buildPresentationTable(
`<tbody><tr><td align="${escapeAttribute(align)}" style="${escapeAttribute(wrapperStyle)}">${msoStart}${vml}${msoEnd}${nonMsoStart}<a href="${escapeAttribute(href)}"${targetAttr} style="${escapeAttribute(cleanAnchorStyle)}">${escapeHtml(text)}</a>${nonMsoEnd}</td></tr></tbody>`
);
}
function transformButtonBlocks(doc: Document) {
const wrappers = Array.from(doc.querySelectorAll('div')).filter((div) => hasSingleChildMatching(div as HTMLDivElement, (child) => {
if (child.tagName !== 'A' || child.querySelector('img')) {
return false;
}
const styleMap = parseStyleMap((child as HTMLAnchorElement).getAttribute('style'));
return Boolean(styleMap['background-color'] && styleMap.padding);
})) as HTMLDivElement[];
wrappers.forEach((div) => {
const anchor = div.children[0] as HTMLAnchorElement;
replaceNodeWithHtml(div, buildBulletproofButton(anchor, div.getAttribute('style') || ''));
});
}
export function postProcessForOutlook(html: string) {
if (typeof DOMParser === 'undefined') {
return html;
}
const doc = new DOMParser().parseFromString(html, 'text/html');
addTableDefaults(doc);
hardenImages(doc);
transformButtonBlocks(doc);
transformImageBlocks(doc);
transformSimpleDivBlocks(doc);
addTableDefaults(doc);
hardenImages(doc);
return `<!doctype html>\n${doc.documentElement.outerHTML}`;
}
+520
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 = 'sans-serif, "Segoe UI", Roboto, Helvetica, Arial';
const MONOSPACE_FONT_FAMILY = 'monospace, Menlo, Monaco, "Segoe UI Mono", "Roboto Mono"';
const BASE_THEME = createTheme({
palette: {
background: {
default: '#f2f5f7',
},
text: {
primary: '#1F1F21',
secondary: '#4F4F4F',
},
},
typography: {
fontFamily: STANDARD_FONT_FAMILY,
},
});
const THEME = createTheme(BASE_THEME, {
palette: {
brand: {
navy: BRAND_NAVY,
blue: BRAND_BLUE,
red: BRAND_RED,
green: BRAND_GREEN,
yellow: BRAND_YELLOW,
purple: BRAND_PURPLE,
brown: BRAND_BROWN,
},
success: {
main: BRAND_GREEN,
light: lighten(BRAND_GREEN, 0.15),
dark: darken(BRAND_GREEN, 0.15),
},
error: {
main: BRAND_RED,
light: lighten(BRAND_RED, 0.15),
dark: darken(BRAND_RED, 0.15),
},
cadet: {
100: '#F9FAFB',
200: '#F2F5F7',
300: '#DCE4EA',
400: '#A8BBCA',
500: '#6A8BA4',
},
highlight: {
100: lighten(BRAND_YELLOW, 0.8),
200: lighten(BRAND_YELLOW, 0.6),
300: lighten(BRAND_YELLOW, 0.4),
400: lighten(BRAND_YELLOW, 0.2),
500: BRAND_YELLOW,
},
info: {
main: BRAND_BLUE,
},
primary: {
main: BRAND_BLUE,
},
},
components: {
MuiCssBaseline: {
styleOverrides: `
address {
font-style: normal;
}
fieldset {
border: none;
padding: 0;
}
pre {
font-family: ${MONOSPACE_FONT_FAMILY}
white-space: pre-wrap;
font-size: 12px;
}
`,
},
MuiAlert: {
styleOverrides: {
root: {
fontSize: BASE_THEME.typography.pxToRem(14),
},
action: {
paddingTop: 0,
marginRight: 0,
},
filledSuccess: {
backgroundColor: BRAND_GREEN,
},
},
},
MuiStepLabel: {
styleOverrides: {
label: {
fontWeight: BASE_THEME.typography.fontWeightMedium,
},
},
},
MuiDialog: {
defaultProps: {
fullWidth: true,
},
},
MuiDialogContent: {
styleOverrides: {
root: {
paddingTop: BASE_THEME.spacing(1),
paddingBottom: BASE_THEME.spacing(2),
},
},
},
MuiDialogTitle: {
defaultProps: {
variant: 'h4',
},
styleOverrides: {
root: {
paddingTop: BASE_THEME.spacing(3),
paddingBottom: BASE_THEME.spacing(1),
},
},
},
MuiDialogActions: {
styleOverrides: {
root: {
borderTop: '1px solid',
borderTopColor: BASE_THEME.palette.divider,
marginTop: BASE_THEME.spacing(2.5),
padding: `${BASE_THEME.spacing(1.5)} ${BASE_THEME.spacing(3)}`,
},
},
},
MuiTableCell: {
styleOverrides: {
root: {
...BASE_THEME.typography.body2,
borderColor: BASE_THEME.palette.grey[200],
},
head: {
...BASE_THEME.typography.overline,
fontWeight: BASE_THEME.typography.fontWeightMedium,
letterSpacing: '0.075em',
color: BASE_THEME.palette.text.secondary,
},
},
},
MuiTableRow: {
styleOverrides: {
root: {
'&:last-child td': {
borderBottom: 0,
},
},
},
},
MuiAvatar: {
styleOverrides: {
root: {
textTransform: 'uppercase',
fontSize: BASE_THEME.typography.pxToRem(14),
},
},
},
MuiChip: {
styleOverrides: {
root: {
'&.MuiChip-filledError, &.MuiChip-filledSuccess': {
fill: BASE_THEME.palette.primary.contrastText,
},
},
sizeSmall: {
borderRadius: BASE_THEME.spacing(0.5),
fontSize: 12,
},
iconSmall: {
fontSize: 14,
marginLeft: BASE_THEME.spacing(1),
},
colorSecondary: {
borderColor: BASE_THEME.palette.grey[400],
color: BASE_THEME.palette.text.secondary,
},
label: {
fontWeight: BASE_THEME.typography.fontWeightMedium,
},
},
},
MuiDrawer: {
defaultProps: {
PaperProps: {
elevation: 2,
},
},
},
MuiTooltip: {
styleOverrides: {
tooltip: {
fontSize: BASE_THEME.typography.pxToRem(12),
backgroundColor: alpha(BASE_THEME.palette.text.primary, 0.9),
},
},
},
MuiSlider: {
styleOverrides: {
root: {
height: 1,
},
track: {
height: 1,
border: 'none',
},
rail: {
height: 1,
backgroundColor: BASE_THEME.palette.grey[500],
},
mark: {
backgroundColor: BASE_THEME.palette.grey[500],
},
markActive: {
height: 0,
},
thumb: {
height: 16,
width: 16,
cursor: 'col-resize',
'&:hover, &.Mui-active, &.Mui-focusVisible': {
boxShadow: `0 0 0 4px ${alpha(BRAND_BLUE, 0.2)}`,
},
'&:before': {
display: 'none',
},
},
},
},
MuiPaper: {
defaultProps: {
elevation: 2,
square: true,
},
},
MuiButtonBase: {
defaultProps: {
disableTouchRipple: true,
focusRipple: true,
},
styleOverrides: {
root: {
'&.MuiButton-containedSecondary.Mui-disabled': {
backgroundColor: BASE_THEME.palette.grey[100],
},
},
},
},
MuiButtonGroup: {
defaultProps: {
disableElevation: true,
},
},
MuiIconButton: {
styleOverrides: {
edgeStart: {
marginLeft: BASE_THEME.spacing(-1),
},
colorSecondary: {
color: BASE_THEME.palette.grey[500],
},
},
},
MuiButton: {
defaultProps: {
disableElevation: true,
},
styleOverrides: {
textPrimary: {
color: BASE_THEME.palette.text.primary,
},
textSecondary: {
color: BASE_THEME.palette.text.secondary,
},
outlinedPrimary: {
borderColor: BASE_THEME.palette.grey[300],
color: BASE_THEME.palette.text.primary,
'&:hover, &:active, &:focus': {
borderColor: BASE_THEME.palette.grey[500],
color: BASE_THEME.palette.text.primary,
},
},
containedSecondary: {
backgroundColor: BASE_THEME.palette.common.white,
border: `1px solid ${BASE_THEME.palette.grey[300]}`,
color: BASE_THEME.palette.text.primary,
'&:hover, &:active, &:focus': {
backgroundColor: BASE_THEME.palette.common.white,
borderColor: BASE_THEME.palette.grey[500],
color: BASE_THEME.palette.text.primary,
},
},
},
},
MuiToggleButton: {
styleOverrides: {
root: {
paddingLeft: BASE_THEME.spacing(1.5),
paddingRight: BASE_THEME.spacing(1.5),
},
},
},
MuiInputBase: {
styleOverrides: {
root: {
'&:not(.Mui-disabled, .Mui-error):before': {
borderBottom: `1px solid ${BASE_THEME.palette.grey[400]}`,
},
'&:hover:not(.Mui-disabled, .Mui-error):before': {
borderBottom: `1px solid ${BASE_THEME.palette.grey[500]} !important`,
},
'&:after': {
borderBottom: `1px solid ${BASE_THEME.palette.text.primary} !important`,
},
'&.MuiOutlinedInput-root:not(.Mui-error)': {
'& fieldset': {
borderColor: BASE_THEME.palette.grey[300],
transition: 'border-color 0.2s',
},
},
'&.MuiOutlinedInput-root:not(.Mui-disabled, .Mui-error)': {
'&:hover fieldset': {
borderColor: BASE_THEME.palette.grey[400],
},
'&.Mui-focused fieldset': {
borderColor: BASE_THEME.palette.text.secondary,
borderWidth: 1,
},
},
},
input: {
fontSize: BASE_THEME.typography.pxToRem(14),
'&.Mui-disabled': {
WebkitTextFillColor: 'inherit',
color: BASE_THEME.palette.text.secondary,
},
},
inputSizeSmall: {},
},
},
MuiOutlinedInput: {
styleOverrides: {
notchedOutline: {
'& legend': {
fontSize: '0.85em',
maxWidth: '100%',
},
},
},
},
MuiInputAdornment: {
styleOverrides: {
root: {
'& .MuiTypography-root': {
fontSize: BASE_THEME.typography.pxToRem(14),
color: BASE_THEME.palette.text.secondary,
},
},
},
},
MuiInputLabel: {
defaultProps: {
shrink: true,
},
styleOverrides: {
shrink: {
transform: 'scale(0.85)',
fontWeight: BASE_THEME.typography.fontWeightMedium,
'&.Mui-focused': {
color: BASE_THEME.palette.text.primary,
},
'&.MuiInputLabel-standard': {
transform: 'translate(0, -4px) scale(0.85)',
color: '#4F4F4F',
},
'&.MuiInputLabel-outlined': {
transform: 'translate(15px, -8px) scale(0.85)',
},
},
},
},
MuiTabs: {
defaultProps: {
variant: 'scrollable',
},
styleOverrides: {
indicator: {
height: 1,
backgroundColor: BASE_THEME.palette.text.primary,
},
},
},
MuiTab: {
styleOverrides: {
root: {
textTransform: 'none',
minWidth: BASE_THEME.spacing(2),
paddingLeft: BASE_THEME.spacing(1.5),
paddingRight: BASE_THEME.spacing(1.5),
fontSize: BASE_THEME.typography.pxToRem(14),
fontFamily: BASE_THEME.typography.fontFamily,
lineHeight: 1.5,
fontWeight: BASE_THEME.typography.fontWeightMedium,
transition: 'color 0.2s',
'&.Mui-selected': {
color: BASE_THEME.palette.text.primary,
},
'&:hover': {
color: BASE_THEME.palette.text.primary,
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 0,
},
},
},
MuiCardHeader: {
styleOverrides: {
title: {
fontSize: BASE_THEME.typography.pxToRem(18),
fontWeight: BASE_THEME.typography.fontWeightMedium,
},
},
},
},
typography: {
fontFamily: BASE_THEME.typography.fontFamily,
h1: {
fontFamily: BASE_THEME.typography.fontFamily,
fontSize: BASE_THEME.typography.pxToRem(40),
lineHeight: 1.2,
letterSpacing: '-0.02em',
fontWeight: BASE_THEME.typography.fontWeightMedium,
},
h2: {
fontFamily: BASE_THEME.typography.fontFamily,
fontSize: BASE_THEME.typography.pxToRem(32),
lineHeight: 1.2,
letterSpacing: '-0.02em',
fontWeight: BASE_THEME.typography.fontWeightMedium,
},
h3: {
fontFamily: BASE_THEME.typography.fontFamily,
fontSize: BASE_THEME.typography.pxToRem(24),
lineHeight: 1.5,
letterSpacing: '-0.01em',
fontWeight: BASE_THEME.typography.fontWeightMedium,
},
h4: {
fontFamily: BASE_THEME.typography.fontFamily,
fontSize: BASE_THEME.typography.pxToRem(20),
lineHeight: 1.5,
letterSpacing: '-0.01em',
fontWeight: BASE_THEME.typography.fontWeightMedium,
},
h5: {
fontFamily: BASE_THEME.typography.fontFamily,
fontSize: BASE_THEME.typography.pxToRem(18),
lineHeight: 1.5,
letterSpacing: '-0.01em',
fontWeight: BASE_THEME.typography.fontWeightMedium,
},
h6: {
fontFamily: BASE_THEME.typography.fontFamily,
fontSize: BASE_THEME.typography.pxToRem(16),
lineHeight: 1.5,
letterSpacing: '-0.005em',
fontWeight: BASE_THEME.typography.fontWeightMedium,
},
body1: {
fontSize: BASE_THEME.typography.pxToRem(14),
},
body2: {
fontSize: BASE_THEME.typography.pxToRem(12),
},
overline: {
fontWeight: BASE_THEME.typography.fontWeightMedium,
letterSpacing: '0.05em',
},
button: {
textTransform: 'none',
fontWeight: BASE_THEME.typography.fontWeightMedium,
lineHeight: 1.5,
},
caption: {
letterSpacing: 0,
lineHeight: 1.5,
},
},
shadows: [
'none',
'0px 4px 15px rgba(33, 36, 67, 0.04), 0px 0px 2px rgba(33, 36, 67, 0.04), 0px 0px 1px rgba(33, 36, 67, 0.04)',
'0px 10px 20px rgba(33, 36, 67, 0.04), 0px 2px 6px rgba(33, 36, 67, 0.04), 0px 0px 1px rgba(33, 36, 67, 0.04)',
'0px 16px 24px rgba(33, 36, 67, 0.05), 0px 2px 6px rgba(33, 36, 67, 0.05), 0px 0px 1px rgba(33, 36, 67, 0.05)',
'0px 24px 32px rgba(33, 36, 67, 0.06), 0px 16px 24px rgba(33, 36, 67, 0.06), 0px 4px 8px rgba(33, 36, 67, 0.06)',
...Array(20).fill('none'),
],
});
export default THEME;
+82
View File
@@ -0,0 +1,82 @@
import { renderToStaticMarkup } from '@usewaypoint/email-builder';
import { TEditorConfiguration } from './documents/editor/core';
import { postProcessForOutlook } from './outlook';
const VIEWPORT_META = '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
const MSO_DOCUMENT_SETTINGS = '<!--[if mso]><noscript><xml xmlns:o="urn:schemas-microsoft-com:office:office"><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->';
const HTML_ATTRIBUTE_ESCAPES: Record<string, string> = {
'&': '&amp;',
'"': '&quot;',
"'": '&#x27;',
'<': '&lt;',
'>': '&gt;',
};
function injectHeadContents(html: string, contents: string) {
const headMatch = html.match(/<head\b([^>]*)>/i);
if (headMatch) {
return html.replace(/<head\b([^>]*)>/i, `<head$1>${contents}`);
}
const htmlMatch = html.match(/<html\b([^>]*)>/i);
if (htmlMatch) {
return html.replace(/<html\b([^>]*)>/i, `<html$1><head>${contents}</head>`);
}
return `<head>${contents}</head>${html}`;
}
function collectImageEmbedURLs(document: TEditorConfiguration): string[] {
// The upstream renderer strips the custom `embed` prop before rendering, so
// collect URLs from blocks marked for embedding and re-tag the matching <img>
// with a `data-embed` flag after rendering. The backend resolves the src
// filename to a media item at compile time.
const embedURLs: string[] = [];
for (const block of Object.values(document)) {
if (!block || (block as { type?: string }).type !== 'Image') {
continue;
}
const props = ((block as { data?: { props?: { url?: string; embed?: boolean } } }).data || {}).props || {};
if (props.embed && props.url) {
embedURLs.push(props.url);
}
}
return embedURLs;
}
function applyImageEmbeds(html: string, embedURLs: string[]): string {
let output = html;
for (const url of embedURLs) {
const re = new RegExp(`<img\\b[^>]*?\\ssrc="${escapeRegExp(escapeHtmlAttribute(url))}"[^>]*>`, 'g');
output = output.replace(re, (tag) => (
/\bdata-embed\b/.test(tag) ? tag : tag.replace(/(\ssrc="[^"]*")/, '$1 data-embed="true"')
));
}
return output;
}
export function renderHtmlWithMeta(
document: TEditorConfiguration,
options: { rootBlockId: string; outlook?: boolean }
): string {
const embedURLs = collectImageEmbedURLs(document);
const html = renderToStaticMarkup(document, options);
const rendered = options.outlook ? postProcessForOutlook(html) : html;
const output = applyImageEmbeds(rendered, embedURLs);
const head = options.outlook ? `${VIEWPORT_META}${MSO_DOCUMENT_SETTINGS}` : VIEWPORT_META;
return injectHeadContents(output, head);
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function escapeHtmlAttribute(s: string): string {
return s.replace(/[&"'<>]/g, (ch) => HTML_ATTRIBUTE_ESCAPES[ch]);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />