diff --git a/packages/post-kit-editor/package.json b/packages/post-kit-editor/package.json index 2d3df55..cd30611 100644 --- a/packages/post-kit-editor/package.json +++ b/packages/post-kit-editor/package.json @@ -22,16 +22,32 @@ "scripts": { "build": "pnpm --filter @singleton-sd/post-kit-types run build && tsc -p tsconfig.json", "lint": "echo \"lint:editor — covered by root eslint on staged files\"", - "test": "pnpm --filter @singleton-sd/post-kit-types run build && tsc -p tsconfig.spec.json && node --import tsx --test \"src/**/*.spec.tsx\"" + "test": "pnpm --filter @singleton-sd/post-kit-types run build && pnpm --filter @singleton-sd/post-kit-compiler run build && tsc -p tsconfig.spec.json && node --import tsx --test \"src/**/*.spec.ts\" \"src/**/*.spec.tsx\"" }, "dependencies": { - "@singleton-sd/post-kit-types": "workspace:*" + "@singleton-sd/post-kit-types": "workspace:*", + "@usewaypoint/block-avatar": "^0.0.3", + "@usewaypoint/block-button": "^0.0.3", + "@usewaypoint/block-columns-container": "^0.0.3", + "@usewaypoint/block-container": "^0.0.2", + "@usewaypoint/block-divider": "^0.0.4", + "@usewaypoint/block-heading": "^0.0.3", + "@usewaypoint/block-html": "^0.0.3", + "@usewaypoint/block-image": "^0.0.5", + "@usewaypoint/block-spacer": "^0.0.3", + "@usewaypoint/block-text": "^0.0.7", + "@usewaypoint/document-core": "^0.0.6", + "@usewaypoint/email-builder": "0.0.9", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zod": "^3.25.76" }, "peerDependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { + "@singleton-sd/post-kit-compiler": "workspace:*", "@types/node": "^20.17.9", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", diff --git a/packages/post-kit-editor/src/__fixtures__/minimal/metadata.json b/packages/post-kit-editor/src/__fixtures__/minimal/metadata.json new file mode 100644 index 0000000..b1aad7b --- /dev/null +++ b/packages/post-kit-editor/src/__fixtures__/minimal/metadata.json @@ -0,0 +1,7 @@ +{ + "key": "test.minimal", + "name": "Minimal", + "subject": "Hello", + "variables": [], + "schemaVersion": "1" +} diff --git a/packages/post-kit-editor/src/__fixtures__/minimal/preview.json b/packages/post-kit-editor/src/__fixtures__/minimal/preview.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/packages/post-kit-editor/src/__fixtures__/minimal/preview.json @@ -0,0 +1 @@ +{} diff --git a/packages/post-kit-editor/src/__fixtures__/minimal/template.json b/packages/post-kit-editor/src/__fixtures__/minimal/template.json new file mode 100644 index 0000000..ab82e83 --- /dev/null +++ b/packages/post-kit-editor/src/__fixtures__/minimal/template.json @@ -0,0 +1,12 @@ +{ + "root": { + "type": "EmailLayout", + "data": { + "backdropColor": "#F8F8F8", + "canvasColor": "#FFFFFF", + "textColor": "#242424", + "fontFamily": "MODERN_SANS", + "childrenIds": [] + } + } +} diff --git a/packages/post-kit-editor/src/__fixtures__/nested-blocks/metadata.json b/packages/post-kit-editor/src/__fixtures__/nested-blocks/metadata.json new file mode 100644 index 0000000..a1f6462 --- /dev/null +++ b/packages/post-kit-editor/src/__fixtures__/nested-blocks/metadata.json @@ -0,0 +1,7 @@ +{ + "key": "test.nested", + "name": "Nested Blocks", + "subject": "Hello {{name}}", + "variables": ["name"], + "schemaVersion": "1" +} diff --git a/packages/post-kit-editor/src/__fixtures__/nested-blocks/preview.json b/packages/post-kit-editor/src/__fixtures__/nested-blocks/preview.json new file mode 100644 index 0000000..e4664a5 --- /dev/null +++ b/packages/post-kit-editor/src/__fixtures__/nested-blocks/preview.json @@ -0,0 +1 @@ +{ "name": "Jane Doe" } diff --git a/packages/post-kit-editor/src/__fixtures__/nested-blocks/template.json b/packages/post-kit-editor/src/__fixtures__/nested-blocks/template.json new file mode 100644 index 0000000..1939fde --- /dev/null +++ b/packages/post-kit-editor/src/__fixtures__/nested-blocks/template.json @@ -0,0 +1,44 @@ +{ + "root": { + "type": "EmailLayout", + "data": { + "backdropColor": "#F8F8F8", + "canvasColor": "#FFFFFF", + "textColor": "#242424", + "fontFamily": "MODERN_SANS", + "childrenIds": ["block-container"] + } + }, + "block-container": { + "type": "Container", + "data": { + "style": { + "padding": { "top": 16, "bottom": 16, "right": 24, "left": 24 } + }, + "props": { + "childrenIds": ["block-heading", "block-text"] + } + } + }, + "block-heading": { + "type": "Heading", + "data": { + "props": { "text": "Welcome", "level": "h1" }, + "style": { + "padding": { "top": 16, "bottom": 8, "right": 24, "left": 24 } + } + } + }, + "block-text": { + "type": "Text", + "data": { + "style": { + "fontWeight": "normal", + "padding": { "top": 8, "bottom": 16, "right": 24, "left": 24 } + }, + "props": { + "text": "Hello {{name}}" + } + } + } +} diff --git a/packages/post-kit-editor/src/__fixtures__/unknown-field/metadata.json b/packages/post-kit-editor/src/__fixtures__/unknown-field/metadata.json new file mode 100644 index 0000000..f34683c --- /dev/null +++ b/packages/post-kit-editor/src/__fixtures__/unknown-field/metadata.json @@ -0,0 +1,8 @@ +{ + "key": "test.unknown", + "name": "Unknown Fields", + "subject": "Test", + "variables": [], + "schemaVersion": "1", + "futureCatalogueKey": "reserved" +} diff --git a/packages/post-kit-editor/src/__fixtures__/unknown-field/preview.json b/packages/post-kit-editor/src/__fixtures__/unknown-field/preview.json new file mode 100644 index 0000000..332d26e --- /dev/null +++ b/packages/post-kit-editor/src/__fixtures__/unknown-field/preview.json @@ -0,0 +1 @@ +{ "unusedFutureKey": "kept" } diff --git a/packages/post-kit-editor/src/__fixtures__/unknown-field/template.json b/packages/post-kit-editor/src/__fixtures__/unknown-field/template.json new file mode 100644 index 0000000..2510c9f --- /dev/null +++ b/packages/post-kit-editor/src/__fixtures__/unknown-field/template.json @@ -0,0 +1,26 @@ +{ + "root": { + "type": "EmailLayout", + "data": { + "backdropColor": "#F8F8F8", + "canvasColor": "#FFFFFF", + "childrenIds": ["block-text"] + } + }, + "block-text": { + "type": "Text", + "data": { + "style": { + "padding": { "top": 16, "bottom": 16, "right": 24, "left": 24 } + }, + "props": { + "text": "Hello", + "futureEditorFlag": true, + "experimentalLayout": { "version": 2, "enabled": true } + } + } + }, + "_editorMeta": { + "lastOpenedAt": "2026-01-01T00:00:00.000Z" + } +} diff --git a/packages/post-kit-editor/src/canvas/EditorBlock.tsx b/packages/post-kit-editor/src/canvas/EditorBlock.tsx new file mode 100644 index 0000000..8fdce5c --- /dev/null +++ b/packages/post-kit-editor/src/canvas/EditorBlock.tsx @@ -0,0 +1,32 @@ +import React, { createContext, useContext } from 'react'; + +import { EditorBlock as CoreEditorBlock } from './editor-core'; +import { useDocument } from './editor-context'; + +const EditorBlockContext = createContext(null); + +export function useCurrentBlockId(): string { + const blockId = useContext(EditorBlockContext); + if (!blockId) { + throw new Error('useCurrentBlockId must be used within EditorBlock'); + } + return blockId; +} + +type EditorBlockProps = { + id: string; +}; + +export function EditorBlock({ id }: EditorBlockProps): JSX.Element { + const document = useDocument(); + const block = document[id]; + if (!block) { + throw new Error(`Could not find block "${id}"`); + } + + return ( + + + + ); +} diff --git a/packages/post-kit-editor/src/canvas/EmailBuilderCanvas.tsx b/packages/post-kit-editor/src/canvas/EmailBuilderCanvas.tsx new file mode 100644 index 0000000..fd4a767 --- /dev/null +++ b/packages/post-kit-editor/src/canvas/EmailBuilderCanvas.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import type { EmailBuilderDocument } from '../types'; +import { EDITOR_CLASS_PREFIX } from '../email-template-editor'; +import { CanvasEditorProvider } from './editor-context'; +import { EditorBlock } from './EditorBlock'; +import { Reader } from '@usewaypoint/email-builder'; + +export interface EmailBuilderCanvasProps { + document: EmailBuilderDocument; + onChange: (document: EmailBuilderDocument) => void; + readOnly?: boolean; +} + +export function EmailBuilderCanvas({ + document, + onChange, + readOnly = false, +}: EmailBuilderCanvasProps): JSX.Element { + if (readOnly) { + return ( +
+ +
+ ); + } + + return ( + +
+ +
+
+ ); +} diff --git a/packages/post-kit-editor/src/canvas/blocks/ColumnsContainerEditor.tsx b/packages/post-kit-editor/src/canvas/blocks/ColumnsContainerEditor.tsx new file mode 100644 index 0000000..e99640e --- /dev/null +++ b/packages/post-kit-editor/src/canvas/blocks/ColumnsContainerEditor.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import { ColumnsContainer as BaseColumnsContainer } from '@usewaypoint/block-columns-container'; + +import { useCurrentBlockId } from '../EditorBlock'; +import { usePatchDocument, useSetSelectedBlockId } from '../editor-context'; +import EditorChildrenIds, { type EditorChildrenChange } from '../helpers/EditorChildrenIds'; + +import ColumnsContainerPropsSchema, { + type ColumnsContainerProps, +} from './ColumnsContainerPropsSchema'; + +const EMPTY_COLUMNS = [{ childrenIds: [] }, { childrenIds: [] }, { childrenIds: [] }]; + +export default function ColumnsContainerEditor({ + style, + props, +}: ColumnsContainerProps): JSX.Element { + const currentBlockId = useCurrentBlockId(); + const patchDocument = usePatchDocument(); + const setSelectedBlockId = useSetSelectedBlockId(); + + const { columns, ...restProps } = props ?? {}; + const columnsValue = columns ?? EMPTY_COLUMNS; + + const updateColumn = ( + columnIndex: 0 | 1 | 2, + { block, blockId, childrenIds }: EditorChildrenChange, + ) => { + const nextColumns = [...columnsValue]; + nextColumns[columnIndex] = { childrenIds }; + patchDocument({ + [blockId]: block, + [currentBlockId]: { + type: 'ColumnsContainer', + data: ColumnsContainerPropsSchema.parse({ + style, + props: { + ...restProps, + columns: nextColumns, + }, + }), + }, + }); + setSelectedBlockId(blockId); + }; + + return ( + updateColumn(0, change)} + />, + updateColumn(1, change)} + />, + updateColumn(2, change)} + />, + ]} + /> + ); +} diff --git a/packages/post-kit-editor/src/canvas/blocks/ColumnsContainerPropsSchema.ts b/packages/post-kit-editor/src/canvas/blocks/ColumnsContainerPropsSchema.ts new file mode 100644 index 0000000..7e5d5cd --- /dev/null +++ b/packages/post-kit-editor/src/canvas/blocks/ColumnsContainerPropsSchema.ts @@ -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; +export default ColumnsContainerPropsSchema; diff --git a/packages/post-kit-editor/src/canvas/blocks/ContainerEditor.tsx b/packages/post-kit-editor/src/canvas/blocks/ContainerEditor.tsx new file mode 100644 index 0000000..64b6511 --- /dev/null +++ b/packages/post-kit-editor/src/canvas/blocks/ContainerEditor.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { Container as BaseContainer } from '@usewaypoint/block-container'; + +import { useCurrentBlockId } from '../EditorBlock'; +import { useDocument, usePatchDocument, useSetSelectedBlockId } from '../editor-context'; +import EditorChildrenIds from '../helpers/EditorChildrenIds'; + +import type { ContainerProps } from './ContainerPropsSchema'; + +export default function ContainerEditor({ style, props }: ContainerProps): JSX.Element { + const childrenIds = props?.childrenIds ?? []; + const document = useDocument(); + const currentBlockId = useCurrentBlockId(); + const patchDocument = usePatchDocument(); + const setSelectedBlockId = useSetSelectedBlockId(); + + return ( + + { + const currentBlock = document[currentBlockId]; + if (!currentBlock || currentBlock.type !== 'Container') { + return; + } + patchDocument({ + [blockId]: block, + [currentBlockId]: { + type: 'Container', + data: { + ...currentBlock.data, + props: { childrenIds: nextChildrenIds }, + }, + }, + }); + setSelectedBlockId(blockId); + }} + /> + + ); +} diff --git a/packages/post-kit-editor/src/canvas/blocks/ContainerPropsSchema.ts b/packages/post-kit-editor/src/canvas/blocks/ContainerPropsSchema.ts new file mode 100644 index 0000000..a0188f6 --- /dev/null +++ b/packages/post-kit-editor/src/canvas/blocks/ContainerPropsSchema.ts @@ -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; diff --git a/packages/post-kit-editor/src/canvas/blocks/EmailLayoutEditor.tsx b/packages/post-kit-editor/src/canvas/blocks/EmailLayoutEditor.tsx new file mode 100644 index 0000000..0dbce2c --- /dev/null +++ b/packages/post-kit-editor/src/canvas/blocks/EmailLayoutEditor.tsx @@ -0,0 +1,103 @@ +import React from 'react'; + +import { useCurrentBlockId } from '../EditorBlock'; +import { useDocument, usePatchDocument, useSetSelectedBlockId } from '../editor-context'; +import EditorChildrenIds from '../helpers/EditorChildrenIds'; + +import type { EmailLayoutProps } from './EmailLayoutPropsSchema'; + +function getFontFamily(fontFamily: EmailLayoutProps['fontFamily']): string { + 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): JSX.Element { + const childrenIds = props.childrenIds ?? []; + const document = useDocument(); + const currentBlockId = useCurrentBlockId(); + const patchDocument = usePatchDocument(); + const setSelectedBlockId = useSetSelectedBlockId(); + + return ( +
{ + 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%', + }} + > + + + + + + +
+ { + const currentBlock = document[currentBlockId]; + if (!currentBlock || currentBlock.type !== 'EmailLayout') { + return; + } + patchDocument({ + [blockId]: block, + [currentBlockId]: { + type: 'EmailLayout', + data: { + ...currentBlock.data, + childrenIds: nextChildrenIds, + }, + }, + }); + setSelectedBlockId(blockId); + }} + /> +
+
+ ); +} diff --git a/packages/post-kit-editor/src/canvas/blocks/EmailLayoutPropsSchema.ts b/packages/post-kit-editor/src/canvas/blocks/EmailLayoutPropsSchema.ts new file mode 100644 index 0000000..880f3ae --- /dev/null +++ b/packages/post-kit-editor/src/canvas/blocks/EmailLayoutPropsSchema.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +const COLOR_SCHEMA = z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .nullable() + .optional(); + +const FONT_FAMILY_SCHEMA = z + .enum([ + 'MODERN_SANS', + 'BOOK_SANS', + 'ORGANIC_SANS', + 'GEOMETRIC_SANS', + 'HEAVY_SANS', + 'ROUNDED_SANS', + 'MODERN_SERIF', + 'BOOK_SERIF', + 'MONOSPACE', + ]) + .nullable() + .optional(); + +const EmailLayoutPropsSchema = z.object({ + backdropColor: COLOR_SCHEMA, + borderColor: COLOR_SCHEMA, + borderRadius: z.number().optional().nullable(), + canvasColor: COLOR_SCHEMA, + textColor: COLOR_SCHEMA, + fontFamily: FONT_FAMILY_SCHEMA, + childrenIds: z.array(z.string()).optional().nullable(), +}); + +export default EmailLayoutPropsSchema; + +export type EmailLayoutProps = z.infer; diff --git a/packages/post-kit-editor/src/canvas/editor-context.tsx b/packages/post-kit-editor/src/canvas/editor-context.tsx new file mode 100644 index 0000000..c940c7e --- /dev/null +++ b/packages/post-kit-editor/src/canvas/editor-context.tsx @@ -0,0 +1,84 @@ +import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; + +import type { EmailBuilderDocument } from '../types'; +import type { TEditorBlock } from './editor-core'; + +type CanvasEditorContextValue = { + document: EmailBuilderDocument; + selectedBlockId: string | null; + setSelectedBlockId: (blockId: string | null) => void; + patchDocument: (partial: Record) => void; + replaceDocument: (document: EmailBuilderDocument) => void; +}; + +const CanvasEditorContext = createContext(null); + +export function CanvasEditorProvider({ + document, + onChange, + children, +}: { + document: EmailBuilderDocument; + onChange: (document: EmailBuilderDocument) => void; + children: React.ReactNode; +}): JSX.Element { + const [selectedBlockId, setSelectedBlockId] = useState(null); + + const patchDocument = useCallback( + (partial: Record) => { + onChange({ + ...document, + ...partial, + }); + }, + [document, onChange], + ); + + const replaceDocument = useCallback( + (nextDocument: EmailBuilderDocument) => { + onChange(nextDocument); + }, + [onChange], + ); + + const value = useMemo( + () => ({ + document, + selectedBlockId, + setSelectedBlockId, + patchDocument, + replaceDocument, + }), + [document, selectedBlockId, patchDocument, replaceDocument], + ); + + return {children}; +} + +export function useCanvasEditor(): CanvasEditorContextValue { + const context = useContext(CanvasEditorContext); + if (!context) { + throw new Error('useCanvasEditor must be used within CanvasEditorProvider'); + } + return context; +} + +export function useDocument(): EmailBuilderDocument { + return useCanvasEditor().document; +} + +export function useSelectedBlockId(): string | null { + return useCanvasEditor().selectedBlockId; +} + +export function useSetSelectedBlockId(): (blockId: string | null) => void { + return useCanvasEditor().setSelectedBlockId; +} + +export function usePatchDocument(): (partial: Record) => void { + return useCanvasEditor().patchDocument; +} + +export function useReplaceDocument(): (document: EmailBuilderDocument) => void { + return useCanvasEditor().replaceDocument; +} diff --git a/packages/post-kit-editor/src/canvas/editor-core.tsx b/packages/post-kit-editor/src/canvas/editor-core.tsx new file mode 100644 index 0000000..45a96fb --- /dev/null +++ b/packages/post-kit-editor/src/canvas/editor-core.tsx @@ -0,0 +1,126 @@ +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/ColumnsContainerEditor'; +import ColumnsContainerPropsSchema from './blocks/ColumnsContainerPropsSchema'; +import ContainerEditor from './blocks/ContainerEditor'; +import ContainerPropsSchema from './blocks/ContainerPropsSchema'; +import EmailLayoutEditor from './blocks/EmailLayoutEditor'; +import EmailLayoutPropsSchema from './blocks/EmailLayoutPropsSchema'; +import EditorBlockWrapper from './helpers/EditorBlockWrapper'; + +const EDITOR_DICTIONARY = buildBlockConfigurationDictionary({ + Avatar: { + schema: AvatarPropsSchema, + Component: (props) => ( + + + + ), + }, + Button: { + schema: ButtonPropsSchema, + Component: (props) => ( + + + {open ? ( +
+ {BLOCK_TEMPLATES.map((template) => ( + + ))} +
+ ) : null} + + ); +} diff --git a/packages/post-kit-editor/src/canvas/helpers/EditorBlockWrapper.tsx b/packages/post-kit-editor/src/canvas/helpers/EditorBlockWrapper.tsx new file mode 100644 index 0000000..8666cbf --- /dev/null +++ b/packages/post-kit-editor/src/canvas/helpers/EditorBlockWrapper.tsx @@ -0,0 +1,47 @@ +import React, { type CSSProperties, useState } from 'react'; + +import { useCurrentBlockId } from '../EditorBlock'; +import { useSelectedBlockId, useSetSelectedBlockId } from '../editor-context'; + +type EditorBlockWrapperProps = { + children: JSX.Element; +}; + +export default function EditorBlockWrapper({ children }: EditorBlockWrapperProps): JSX.Element { + const selectedBlockId = useSelectedBlockId(); + const setSelectedBlockId = useSetSelectedBlockId(); + 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)'; + } + + return ( +
{ + setMouseInside(true); + event.stopPropagation(); + }} + onMouseLeave={() => { + setMouseInside(false); + }} + onClick={(event) => { + setSelectedBlockId(blockId); + event.stopPropagation(); + event.preventDefault(); + }} + > + {children} +
+ ); +} diff --git a/packages/post-kit-editor/src/canvas/helpers/EditorChildrenIds.tsx b/packages/post-kit-editor/src/canvas/helpers/EditorChildrenIds.tsx new file mode 100644 index 0000000..eff2bdf --- /dev/null +++ b/packages/post-kit-editor/src/canvas/helpers/EditorChildrenIds.tsx @@ -0,0 +1,62 @@ +import React, { Fragment } from 'react'; + +import { EditorBlock } from '../EditorBlock'; +import type { TEditorBlock } from '../editor-core'; + +import AddBlockButton from './AddBlockMenu'; + +export type EditorChildrenChange = { + blockId: string; + block: TEditorBlock; + childrenIds: string[]; +}; + +function generateId(): string { + return `block-${Date.now()}`; +} + +export type EditorChildrenIdsProps = { + childrenIds: string[] | null | undefined; + onChange: (val: EditorChildrenChange) => void; +}; + +export default function EditorChildrenIds({ + childrenIds, + onChange, +}: EditorChildrenIdsProps): JSX.Element { + const appendBlock = (block: TEditorBlock) => { + const blockId = generateId(); + onChange({ + blockId, + block, + childrenIds: [...(childrenIds || []), blockId], + }); + }; + + const insertBlock = (block: TEditorBlock, index: number) => { + const blockId = generateId(); + const nextChildrenIds = [...(childrenIds || [])]; + nextChildrenIds.splice(index, 0, blockId); + onChange({ + blockId, + block, + childrenIds: nextChildrenIds, + }); + }; + + if (!childrenIds || childrenIds.length === 0) { + return ; + } + + return ( + <> + {childrenIds.map((childId, index) => ( + + insertBlock(block, index)} /> + + + ))} + + + ); +} diff --git a/packages/post-kit-editor/src/canvas/helpers/block-templates.ts b/packages/post-kit-editor/src/canvas/helpers/block-templates.ts new file mode 100644 index 0000000..21dc688 --- /dev/null +++ b/packages/post-kit-editor/src/canvas/helpers/block-templates.ts @@ -0,0 +1,90 @@ +import type { TEditorBlock } from '../editor-core'; + +type BlockTemplate = { + label: string; + block: () => TEditorBlock; +}; + +export const BLOCK_TEMPLATES: BlockTemplate[] = [ + { + label: 'Heading', + block: () => ({ + type: 'Heading', + data: { + props: { text: 'Hello friend' }, + style: { + padding: { top: 16, bottom: 16, left: 24, right: 24 }, + }, + }, + }), + }, + { + label: 'Text', + block: () => ({ + type: 'Text', + data: { + props: { text: 'My new text block' }, + style: { + padding: { top: 16, bottom: 16, left: 24, right: 24 }, + fontWeight: 'normal', + }, + }, + }), + }, + { + label: 'Button', + block: () => ({ + type: 'Button', + data: { + props: { + text: 'Button', + url: 'https://example.com', + }, + style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } }, + }, + }), + }, + { + label: 'Image', + block: () => ({ + type: 'Image', + data: { + props: { + url: 'https://placehold.co/600x400', + alt: 'Sample image', + contentAlignment: 'middle', + linkHref: null, + }, + style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } }, + }, + }), + }, + { + label: 'Divider', + block: () => ({ + type: 'Divider', + data: { + style: { padding: { top: 16, right: 0, bottom: 16, left: 0 } }, + props: { + lineColor: '#CCCCCC', + }, + }, + }), + }, + { + label: 'Spacer', + block: () => ({ + type: 'Spacer', + data: {}, + }), + }, + { + label: 'Container', + block: () => ({ + type: 'Container', + data: { + style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } }, + }, + }), + }, +]; diff --git a/packages/post-kit-editor/src/email-template-editor.tsx b/packages/post-kit-editor/src/email-template-editor.tsx index ef9d473..59c89e0 100644 --- a/packages/post-kit-editor/src/email-template-editor.tsx +++ b/packages/post-kit-editor/src/email-template-editor.tsx @@ -1,7 +1,9 @@ // `tsx` (the test runner's loader) compiles JSX with the classic runtime, so // React must be in scope even though `tsc` is configured for `react-jsx`. -import React from 'react'; +import React, { useCallback, useState } from 'react'; +import { EmailBuilderCanvas } from './canvas/EmailBuilderCanvas'; +import type { EmailBuilderDocument } from './types'; import type { TemplateSourceFiles, TemplateVariable } from './types'; /** @@ -23,14 +25,31 @@ export interface EmailTemplateEditorProps { } /** - * Placeholder editor surface. + * Email template editor with an EmailBuilder.js canvas. * - * Renders the root container only — the canvas, metadata form, preview pane and - * validation surfaces arrive in later issues. Persistence is consumer-supplied - * via `onSave`. + * Holds the working `TemplateSourceFiles` in local state, seeded from the + * `template` prop. Canvas edits update `templateJson`; metadata and preview + * data are passed through unchanged until dedicated editing surfaces land in + * later issues. Persistence is consumer-supplied via `onSave`. */ -export function EmailTemplateEditor({ className }: EmailTemplateEditorProps): JSX.Element { +export function EmailTemplateEditor({ + template, + className, +}: EmailTemplateEditorProps): JSX.Element { + const [workingFiles, setWorkingFiles] = useState(template); + + const handleDocumentChange = useCallback((document: EmailBuilderDocument) => { + setWorkingFiles((current) => ({ + ...current, + templateJson: document, + })); + }, []); + const rootClassName = [`${EDITOR_CLASS_PREFIX}root`, className].filter(Boolean).join(' '); - return
; + return ( +
+ +
+ ); } diff --git a/packages/post-kit-editor/src/index.spec.tsx b/packages/post-kit-editor/src/index.spec.tsx index e2b7a01..0711ace 100644 --- a/packages/post-kit-editor/src/index.spec.tsx +++ b/packages/post-kit-editor/src/index.spec.tsx @@ -5,9 +5,34 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { EmailTemplateEditor, EDITOR_CLASS_PREFIX } from './index'; import type { EmailTemplateEditorProps } from './index'; import type { TemplateSourceFiles } from './types'; +import { EmailBuilderCanvas } from './canvas/EmailBuilderCanvas'; +import type { EmailBuilderDocument } from './types'; const template: TemplateSourceFiles = { - templateJson: { root: { type: 'EmailLayout', data: {} } }, + templateJson: { + root: { + type: 'EmailLayout', + data: { + backdropColor: '#F8F8F8', + canvasColor: '#FFFFFF', + textColor: '#242424', + fontFamily: 'MODERN_SANS', + childrenIds: ['block-text'], + }, + }, + 'block-text': { + type: 'Text', + data: { + style: { + fontWeight: 'normal', + padding: { top: 16, bottom: 16, right: 24, left: 24 }, + }, + props: { + text: 'Hello {{name}}', + }, + }, + }, + }, metadata: { key: 'marketing.contact-us', name: 'Contact Us', @@ -41,8 +66,24 @@ describe('EmailTemplateEditor', () => { assert.match(html, /class="[^"]*tenant-theme/); }); - it('does not render editor chrome yet', () => { + it('renders the EmailBuilder canvas inside the root container', () => { const html = renderToStaticMarkup(); - assert.doesNotMatch(html, /