├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── ci.yaml │ └── github-pages.yaml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── images ├── builder.png ├── designer.png ├── mobile.png ├── output.png └── template.png ├── jest.config.ts ├── package-lock.json ├── package.json ├── packages ├── block-avatar │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── __snapshots__ │ │ │ └── index.spec.tsx.snap │ │ ├── index.spec.tsx │ │ └── index.tsx │ ├── tsconfig.build.json │ └── tsconfig.json ├── block-button │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── __snapshots__ │ │ │ └── index.spec.tsx.snap │ │ ├── index.spec.tsx │ │ └── index.tsx │ ├── tsconfig.build.json │ └── tsconfig.json ├── block-columns-container │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── __snapshots__ │ │ │ └── index.spec.tsx.snap │ │ ├── index.spec.tsx │ │ └── index.tsx │ ├── tsconfig.build.json │ └── tsconfig.json ├── block-container │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── __snapshots__ │ │ │ └── index.spec.tsx.snap │ │ ├── index.spec.tsx │ │ └── index.tsx │ ├── tsconfig.build.json │ └── tsconfig.json ├── block-divider │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── __snapshots__ │ │ │ └── index.spec.tsx.snap │ │ ├── index.spec.tsx │ │ └── index.tsx │ ├── tsconfig.build.json │ └── tsconfig.json ├── block-heading │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── __snapshots__ │ │ │ └── index.spec.tsx.snap │ │ ├── index.spec.tsx │ │ └── index.tsx │ ├── tsconfig.build.json │ └── tsconfig.json ├── block-html │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── __snapshots__ │ │ │ └── index.spec.tsx.snap │ │ ├── index.spec.tsx │ │ └── index.tsx │ ├── tsconfig.build.json │ └── tsconfig.json ├── block-image │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── __snapshots__ │ │ │ └── index.spec.tsx.snap │ │ ├── index.spec.tsx │ │ └── index.tsx │ ├── tsconfig.build.json │ └── tsconfig.json ├── block-spacer │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── __snapshots__ │ │ │ └── index.spec.tsx.snap │ │ ├── index.spec.tsx │ │ └── index.tsx │ ├── tsconfig.build.json │ └── tsconfig.json ├── block-text │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── EmailMarkdown.tsx │ │ ├── __snapshots__ │ │ │ └── index.spec.tsx.snap │ │ ├── index.spec.tsx │ │ └── index.tsx │ ├── tsconfig.build.json │ └── tsconfig.json ├── document-core │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── builders │ │ │ ├── buildBlockComponent.tsx │ │ │ ├── buildBlockConfigurationDictionary.ts │ │ │ └── buildBlockConfigurationSchema.ts │ │ ├── index.ts │ │ └── utils.ts │ ├── tests │ │ └── builder │ │ │ ├── __snapshots__ │ │ │ └── buildBlockComponent.spec.tsx.snap │ │ │ ├── buildBlockComponent.spec.tsx │ │ │ └── buildBlockConfigurationSchema.spec.tsx │ ├── tsconfig.build.json │ └── tsconfig.json ├── editor-sample │ ├── LICENSE │ ├── README.md │ ├── index.html │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── App │ │ │ ├── InspectorDrawer │ │ │ │ ├── ConfigurationPanel │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── input-panels │ │ │ │ │ │ ├── AvatarSidebarPanel.tsx │ │ │ │ │ │ ├── ButtonSidebarPanel.tsx │ │ │ │ │ │ ├── ColumnsContainerSidebarPanel.tsx │ │ │ │ │ │ ├── ContainerSidebarPanel.tsx │ │ │ │ │ │ ├── DividerSidebarPanel.tsx │ │ │ │ │ │ ├── EmailLayoutSidebarPanel.tsx │ │ │ │ │ │ ├── HeadingSidebarPanel.tsx │ │ │ │ │ │ ├── HtmlSidebarPanel.tsx │ │ │ │ │ │ ├── ImageSidebarPanel.tsx │ │ │ │ │ │ ├── SpacerSidebarPanel.tsx │ │ │ │ │ │ ├── TextSidebarPanel.tsx │ │ │ │ │ │ └── helpers │ │ │ │ │ │ ├── BaseSidebarPanel.tsx │ │ │ │ │ │ ├── inputs │ │ │ │ │ │ ├── BooleanInput.tsx │ │ │ │ │ │ ├── ColorInput │ │ │ │ │ │ │ ├── BaseColorInput.tsx │ │ │ │ │ │ │ ├── Picker.tsx │ │ │ │ │ │ │ ├── Swatch.tsx │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ ├── ColumnWidthsInput.tsx │ │ │ │ │ │ ├── FontFamily.tsx │ │ │ │ │ │ ├── FontSizeInput.tsx │ │ │ │ │ │ ├── FontWeightInput.tsx │ │ │ │ │ │ ├── PaddingInput.tsx │ │ │ │ │ │ ├── RadioGroupInput.tsx │ │ │ │ │ │ ├── SliderInput.tsx │ │ │ │ │ │ ├── TextAlignInput.tsx │ │ │ │ │ │ ├── TextDimensionInput.tsx │ │ │ │ │ │ ├── TextInput.tsx │ │ │ │ │ │ └── raw │ │ │ │ │ │ │ └── RawSliderInput.tsx │ │ │ │ │ │ └── style-inputs │ │ │ │ │ │ ├── MultiStylePropertyPanel.tsx │ │ │ │ │ │ └── SingleStylePropertyPanel.tsx │ │ │ │ ├── StylesPanel.tsx │ │ │ │ ├── ToggleInspectorPanelButton.tsx │ │ │ │ └── index.tsx │ │ │ ├── SamplesDrawer │ │ │ │ ├── SidebarButton.tsx │ │ │ │ ├── ToggleSamplesPanelButton.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── waypoint.svg │ │ │ ├── TemplatePanel │ │ │ │ ├── DownloadJson │ │ │ │ │ └── index.tsx │ │ │ │ ├── HtmlPanel.tsx │ │ │ │ ├── ImportJson │ │ │ │ │ ├── ImportJsonDialog.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── validateJsonStringValue.ts │ │ │ │ ├── JsonPanel.tsx │ │ │ │ ├── MainTabsGroup.tsx │ │ │ │ ├── ShareButton.tsx │ │ │ │ ├── helper │ │ │ │ │ ├── HighlightedCodePanel.tsx │ │ │ │ │ └── highlighters.tsx │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── documents │ │ │ ├── blocks │ │ │ │ ├── ColumnsContainer │ │ │ │ │ ├── ColumnsContainerEditor.tsx │ │ │ │ │ └── ColumnsContainerPropsSchema.ts │ │ │ │ ├── Container │ │ │ │ │ ├── ContainerEditor.tsx │ │ │ │ │ └── ContainerPropsSchema.tsx │ │ │ │ ├── EmailLayout │ │ │ │ │ ├── EmailLayoutEditor.tsx │ │ │ │ │ └── EmailLayoutPropsSchema.tsx │ │ │ │ └── helpers │ │ │ │ │ ├── EditorChildrenIds │ │ │ │ │ ├── AddBlockMenu │ │ │ │ │ │ ├── BlockButton.tsx │ │ │ │ │ │ ├── BlocksMenu.tsx │ │ │ │ │ │ ├── DividerButton.tsx │ │ │ │ │ │ ├── PlaceholderButton.tsx │ │ │ │ │ │ ├── buttons.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── TStyle.ts │ │ │ │ │ ├── block-wrappers │ │ │ │ │ ├── EditorBlockWrapper.tsx │ │ │ │ │ ├── ReaderBlockWrapper.tsx │ │ │ │ │ └── TuneMenu.tsx │ │ │ │ │ ├── fontFamily.ts │ │ │ │ │ └── zod.ts │ │ │ └── editor │ │ │ │ ├── EditorBlock.tsx │ │ │ │ ├── EditorContext.tsx │ │ │ │ └── core.tsx │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ └── favicon.ico │ │ ├── getConfiguration │ │ │ ├── index.tsx │ │ │ └── sample │ │ │ │ ├── empty-email-message.ts │ │ │ │ ├── one-time-passcode.ts │ │ │ │ ├── order-ecommerce.ts │ │ │ │ ├── post-metrics-report.ts │ │ │ │ ├── reservation-reminder.ts │ │ │ │ ├── reset-password.ts │ │ │ │ ├── respond-to-message.ts │ │ │ │ ├── subscription-receipt.ts │ │ │ │ └── welcome.ts │ │ ├── main.tsx │ │ ├── theme.ts │ │ └── vite-env.d.ts │ ├── tsconfig.json │ └── vite.config.ts └── email-builder │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── Reader │ │ └── core.tsx │ ├── blocks │ │ ├── ColumnsContainer │ │ │ ├── ColumnsContainerPropsSchema.ts │ │ │ └── ColumnsContainerReader.tsx │ │ ├── Container │ │ │ ├── ContainerPropsSchema.tsx │ │ │ └── ContainerReader.tsx │ │ └── EmailLayout │ │ │ ├── EmailLayoutPropsSchema.tsx │ │ │ └── EmailLayoutReader.tsx │ ├── index.ts │ └── renderers │ │ ├── renderToStaticMarkup.spec.tsx │ │ └── renderToStaticMarkup.tsx │ ├── tsconfig.build.json │ └── tsconfig.json └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 2015, 7 | "project": "./tsconfig.json", 8 | "sourceType": "module" 9 | }, 10 | "env": { 11 | "browser": true, 12 | "node": true 13 | }, 14 | "plugins": ["@typescript-eslint", "simple-import-sort"], 15 | "rules": { 16 | "@typescript-eslint/no-empty-interface": ["off"], 17 | "@typescript-eslint/no-explicit-any": ["error"], 18 | "@typescript-eslint/no-unused-vars": ["error"], 19 | "indent": ["off"], 20 | "no-empty": ["error", { "allowEmptyCatch": true }], 21 | "no-extra-boolean-cast": ["error", { "enforceForLogicalOperands": false }], 22 | "no-implicit-coercion": "error", 23 | "no-duplicate-imports": "error", 24 | "quotes": ["error", "single", { "avoidEscape": false, "allowTemplateLiterals": true }], 25 | "semi": ["error", "always"], 26 | "simple-import-sort/imports": [ 27 | "error", 28 | { 29 | "groups": [["^\\u0000"], ["^\\w"], ["^@"], ["^"], ["^\\.\\."], ["^\\."]] 30 | } 31 | ] 32 | }, 33 | "overrides": [ 34 | { 35 | "files": ["*.spec.ts", "*.spec.tsx"], 36 | "rules": { 37 | "@typescript-eslint/no-explicit-any": ["off"] 38 | }, 39 | "env": { 40 | "jest": true 41 | } 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI - Code styles, unit tests 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: 7 | - main 8 | concurrency: 9 | group: ${{ github.head_ref }}-codestyles 10 | cancel-in-progress: true 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 5 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 20 20 | cache: 'npm' 21 | cache-dependency-path: '**/package-lock.json' 22 | - run: | 23 | npm ci 24 | (cd ./packages/block-avatar;pwd;npm ci) 25 | (cd ./packages/block-button;pwd;npm ci) 26 | (cd ./packages/block-divider;pwd;npm ci) 27 | (cd ./packages/block-heading;pwd;npm ci) 28 | (cd ./packages/block-html;pwd;npm ci) 29 | (cd ./packages/block-image;pwd;npm ci) 30 | (cd ./packages/block-spacer;pwd;npm ci) 31 | (cd ./packages/block-text;pwd;npm ci) 32 | (cd ./packages/document-core;pwd;npm ci) 33 | (cd ./packages/editor-sample;pwd;npm ci) 34 | (cd ./packages/email-builder;pwd;npm ci) 35 | - run: npx eslint . 36 | - run: npx prettier . --check 37 | - run: npm test 38 | - run: ./node_modules/.bin/tsc --noEmit 39 | -------------------------------------------------------------------------------- /.github/workflows/github-pages.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy static content to Pages 2 | on: 3 | push: 4 | branches: ['main'] 5 | workflow_dispatch: 6 | permissions: 7 | contents: read 8 | pages: write 9 | id-token: write 10 | concurrency: 11 | group: 'pages' 12 | cancel-in-progress: true 13 | jobs: 14 | deploy: 15 | environment: 16 | name: github-pages 17 | url: ${{ steps.deployment.outputs.page_url }} 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | - name: Set up Node 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: 20 26 | cache: 'npm' 27 | - name: Install dependencies and build 28 | working-directory: './packages/editor-sample' 29 | run: | 30 | npm ci 31 | npm run build 32 | - name: Setup Pages 33 | uses: actions/configure-pages@v3 34 | - name: Upload artifact 35 | uses: actions/upload-pages-artifact@v2 36 | with: 37 | path: './packages/editor-sample/dist' 38 | - name: Deploy to GitHub Pages 39 | id: deployment 40 | uses: actions/deploy-pages@v2 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # misc 5 | .esbuild 6 | .DS_Store 7 | .env 8 | .envrc 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | dist 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "printWidth": 120, 5 | "proseWrap": "preserve", 6 | "semi": true, 7 | "singleQuote": true, 8 | "tabWidth": 2, 9 | "trailingComma": "es5", 10 | "useTabs": false 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Waypoint (Metaccountant, Inc.) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /images/builder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usewaypoint/email-builder-js/1ded24dca92f90488c27f9ef536d37a2d6692ce5/images/builder.png -------------------------------------------------------------------------------- /images/designer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usewaypoint/email-builder-js/1ded24dca92f90488c27f9ef536d37a2d6692ce5/images/designer.png -------------------------------------------------------------------------------- /images/mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usewaypoint/email-builder-js/1ded24dca92f90488c27f9ef536d37a2d6692ce5/images/mobile.png -------------------------------------------------------------------------------- /images/output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usewaypoint/email-builder-js/1ded24dca92f90488c27f9ef536d37a2d6692ce5/images/output.png -------------------------------------------------------------------------------- /images/template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usewaypoint/email-builder-js/1ded24dca92f90488c27f9ef536d37a2d6692ce5/images/template.png -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'jsdom', 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@usewaypoint-monorepo", 3 | "version": "0.0.6", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "target": "ES2022", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "build": "npx tsc", 12 | "test": "npx jest" 13 | }, 14 | "author": "carlos@usewaypoint.com", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@jest/globals": "^29.7.0", 18 | "@testing-library/react": "^14.2.1", 19 | "@types/jest": "^29.5.12", 20 | "@typescript-eslint/eslint-plugin": "^6.21.0", 21 | "eslint": "^8.56.0", 22 | "eslint-plugin-react-hooks": "^4.6.0", 23 | "eslint-plugin-simple-import-sort": "^11.0.0", 24 | "jest": "^29.7.0", 25 | "jest-environment-jsdom": "^29.7.0", 26 | "prettier": "^3.2.5", 27 | "react-test-renderer": "^18.2.0", 28 | "ts-jest": "^29.1.2", 29 | "ts-node": "^10.9.2", 30 | "tsup": "^8.0.2", 31 | "typescript": "^5.3.3", 32 | "zod": "^3.22.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/block-avatar/.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .envrc 3 | .eslintignore 4 | .eslintrc.json 5 | .prettierrc 6 | jest.config.ts 7 | src 8 | tsconfig.json -------------------------------------------------------------------------------- /packages/block-avatar/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Waypoint (Metaccountant, Inc.) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/block-avatar/README.md: -------------------------------------------------------------------------------- 1 | # @usewaypoint/block-avatar 2 | 3 | Avatar component for use with the EmailBuilder package. 4 | -------------------------------------------------------------------------------- /packages/block-avatar/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@usewaypoint/block-avatar", 3 | "version": "0.0.3", 4 | "description": "@usewaypoint/document compatible Avatar component", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.mjs", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "require": "./dist/index.js", 11 | "import": "./dist/index.mjs", 12 | "types": "./dist/index.d.ts" 13 | } 14 | }, 15 | "files": [ 16 | "dist" 17 | ], 18 | "scripts": { 19 | "test": "echo \"Error: no test specified\" && exit 1" 20 | }, 21 | "author": "carlos@usewaypoint.com", 22 | "license": "MIT", 23 | "peerDependencies": { 24 | "react": "^16 || ^17 || ^18", 25 | "zod": "^1 || ^2 || ^3" 26 | }, 27 | "devDependencies": { 28 | "@testing-library/react": "^14.2.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/block-avatar/src/__snapshots__/index.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`block-avatar Avatar renders with default values 1`] = ` 4 | 5 |
6 | 13 |
14 |
15 | `; 16 | -------------------------------------------------------------------------------- /packages/block-avatar/src/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import { Avatar } from '.'; 6 | 7 | describe('block-avatar', () => { 8 | describe('Avatar', () => { 9 | it('renders with default values', () => { 10 | expect(render().asFragment()).toMatchSnapshot(); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/block-avatar/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from 'react'; 2 | import { z } from 'zod'; 3 | 4 | const PADDING_SCHEMA = z 5 | .object({ 6 | top: z.number(), 7 | bottom: z.number(), 8 | right: z.number(), 9 | left: z.number(), 10 | }) 11 | .optional() 12 | .nullable(); 13 | 14 | const getPadding = (padding: z.infer) => 15 | padding ? `${padding.top}px ${padding.right}px ${padding.bottom}px ${padding.left}px` : undefined; 16 | 17 | export const AvatarPropsSchema = z.object({ 18 | style: z 19 | .object({ 20 | textAlign: z.enum(['left', 'center', 'right']).optional().nullable(), 21 | padding: PADDING_SCHEMA, 22 | }) 23 | .optional() 24 | .nullable(), 25 | props: z 26 | .object({ 27 | size: z.number().gt(0).optional().nullable(), 28 | shape: z.enum(['circle', 'square', 'rounded']).optional().nullable(), 29 | imageUrl: z.string().optional().nullable(), 30 | alt: z.string().optional().nullable(), 31 | }) 32 | .optional() 33 | .nullable(), 34 | }); 35 | 36 | export type AvatarProps = z.infer; 37 | 38 | function getBorderRadius(shape: 'circle' | 'square' | 'rounded', size: number): number | undefined { 39 | switch (shape) { 40 | case 'rounded': 41 | return size * 0.125; 42 | case 'circle': 43 | return size; 44 | case 'square': 45 | default: 46 | return undefined; 47 | } 48 | } 49 | 50 | export const AvatarPropsDefaults = { 51 | size: 64, 52 | imageUrl: '', 53 | alt: '', 54 | shape: 'square', 55 | } as const; 56 | 57 | export function Avatar({ style, props }: AvatarProps) { 58 | const size = props?.size ?? AvatarPropsDefaults.size; 59 | const imageUrl = props?.imageUrl ?? AvatarPropsDefaults.imageUrl; 60 | const alt = props?.alt ?? AvatarPropsDefaults.alt; 61 | const shape = props?.shape ?? AvatarPropsDefaults.shape; 62 | 63 | const sectionStyle: CSSProperties = { 64 | textAlign: style?.textAlign ?? undefined, 65 | padding: getPadding(style?.padding), 66 | }; 67 | return ( 68 |
69 | {alt} 88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /packages/block-avatar/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx", "jest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/block-avatar/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2015", 5 | "module": "esnext", 6 | "outDir": "dist" 7 | }, 8 | "exclude": ["dist"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/block-button/.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .envrc 3 | .eslintignore 4 | .eslintrc.json 5 | .prettierrc 6 | jest.config.ts 7 | src 8 | tsconfig.json -------------------------------------------------------------------------------- /packages/block-button/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Waypoint (Metaccountant, Inc.) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/block-button/README.md: -------------------------------------------------------------------------------- 1 | # @usewaypoint/block-button 2 | 3 | Button component for use with the EmailBuilder package. 4 | -------------------------------------------------------------------------------- /packages/block-button/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@usewaypoint/block-button", 3 | "version": "0.0.3", 4 | "description": "@usewaypoint/document compatible Button component", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.mjs", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "require": "./dist/index.js", 11 | "import": "./dist/index.mjs", 12 | "types": "./dist/index.d.ts" 13 | } 14 | }, 15 | "files": [ 16 | "dist" 17 | ], 18 | "scripts": { 19 | "test": "echo \"Error: no test specified\" && exit 1" 20 | }, 21 | "author": "carlos@usewaypoint.com", 22 | "license": "MIT", 23 | "peerDependencies": { 24 | "react": "^16 || ^17 || ^18", 25 | "zod": "^1 || ^2 || ^3" 26 | }, 27 | "devDependencies": { 28 | "@testing-library/react": "^14.2.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/block-button/src/__snapshots__/index.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`block-button renders with default values 1`] = ` 4 | 5 |
6 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 | `; 22 | -------------------------------------------------------------------------------- /packages/block-button/src/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import { Button } from '.'; 6 | 7 | describe('block-button', () => { 8 | it('renders with default values', () => { 9 | expect(render( 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /packages/editor-sample/src/App/SamplesDrawer/ToggleSamplesPanelButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { FirstPageOutlined, MenuOutlined } from '@mui/icons-material'; 4 | import { IconButton } from '@mui/material'; 5 | 6 | import { toggleSamplesDrawerOpen, useSamplesDrawerOpen } from '../../documents/editor/EditorContext'; 7 | 8 | function useIcon() { 9 | const samplesDrawerOpen = useSamplesDrawerOpen(); 10 | if (samplesDrawerOpen) { 11 | return ; 12 | } 13 | return ; 14 | } 15 | 16 | export default function ToggleSamplesPanelButton() { 17 | const icon = useIcon(); 18 | return {icon}; 19 | } 20 | -------------------------------------------------------------------------------- /packages/editor-sample/src/App/SamplesDrawer/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Box, Button, Divider, Drawer, Link, Stack, Typography } from '@mui/material'; 4 | 5 | import { useSamplesDrawerOpen } from '../../documents/editor/EditorContext'; 6 | 7 | import SidebarButton from './SidebarButton'; 8 | import logo from './waypoint.svg'; 9 | 10 | export const SAMPLES_DRAWER_WIDTH = 240; 11 | 12 | export default function SamplesDrawer() { 13 | const samplesDrawerOpen = useSamplesDrawerOpen(); 14 | 15 | return ( 16 | 24 | 25 | 26 | 27 | EmailBuilder.js 28 | 29 | 30 | 31 | Empty 32 | Welcome email 33 | One-time passcode (OTP) 34 | Reset password 35 | E-commerce receipt 36 | Subscription receipt 37 | Reservation reminder 38 | Post metrics 39 | Respond to inquiry 40 | 41 | 42 | 43 | 44 | 45 | 48 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | Looking to send emails? 60 | 61 | 62 | Waypoint is an end-to-end email API with a 'pro' version of this template builder with dynamic 63 | variables, loops, conditionals, drag and drop, layouts, and more. 64 | 65 | 66 | 75 | 76 | 77 | 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /packages/editor-sample/src/App/TemplatePanel/DownloadJson/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import { FileDownloadOutlined } from '@mui/icons-material'; 4 | import { IconButton, Tooltip } from '@mui/material'; 5 | 6 | import { useDocument } from '../../../documents/editor/EditorContext'; 7 | 8 | export default function DownloadJson() { 9 | const doc = useDocument(); 10 | const href = useMemo(() => { 11 | return `data:text/plain,${encodeURIComponent(JSON.stringify(doc, null, ' '))}`; 12 | }, [doc]); 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /packages/editor-sample/src/App/TemplatePanel/HtmlPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import { renderToStaticMarkup } from '@usewaypoint/email-builder'; 4 | 5 | import { useDocument } from '../../documents/editor/EditorContext'; 6 | 7 | import HighlightedCodePanel from './helper/HighlightedCodePanel'; 8 | 9 | export default function HtmlPanel() { 10 | const document = useDocument(); 11 | const code = useMemo(() => renderToStaticMarkup(document, { rootBlockId: 'root' }), [document]); 12 | return ; 13 | } 14 | -------------------------------------------------------------------------------- /packages/editor-sample/src/App/TemplatePanel/ImportJson/ImportJsonDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { 4 | Alert, 5 | Button, 6 | Dialog, 7 | DialogActions, 8 | DialogContent, 9 | DialogTitle, 10 | Link, 11 | TextField, 12 | Typography, 13 | } from '@mui/material'; 14 | 15 | import { resetDocument } from '../../../documents/editor/EditorContext'; 16 | 17 | import validateJsonStringValue from './validateJsonStringValue'; 18 | 19 | type ImportJsonDialogProps = { 20 | onClose: () => void; 21 | }; 22 | export default function ImportJsonDialog({ onClose }: ImportJsonDialogProps) { 23 | const [value, setValue] = useState(''); 24 | const [error, setError] = useState(null); 25 | 26 | const handleChange: React.ChangeEventHandler = (ev) => { 27 | const v = ev.currentTarget.value; 28 | setValue(v); 29 | const { error } = validateJsonStringValue(v); 30 | setError(error ?? null); 31 | }; 32 | 33 | let errorAlert = null; 34 | if (error) { 35 | errorAlert = {error}; 36 | } 37 | 38 | return ( 39 | 40 | Import JSON 41 |
{ 43 | ev.preventDefault(); 44 | const { error, data } = validateJsonStringValue(value); 45 | setError(error ?? null); 46 | if (!data) { 47 | return; 48 | } 49 | resetDocument(data); 50 | onClose(); 51 | }} 52 | > 53 | 54 | 55 | Copy and paste an EmailBuilder.js JSON ( 56 | 61 | example 62 | 63 | ). 64 | 65 | {errorAlert} 66 | 77 | 78 | 79 | 82 | 85 | 86 |
87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /packages/editor-sample/src/App/TemplatePanel/ImportJson/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { FileUploadOutlined } from '@mui/icons-material'; 4 | import { IconButton, Tooltip } from '@mui/material'; 5 | 6 | import ImportJsonDialog from './ImportJsonDialog'; 7 | 8 | export default function ImportJson() { 9 | const [open, setOpen] = useState(false); 10 | 11 | let dialog = null; 12 | if (open) { 13 | dialog = setOpen(false)} />; 14 | } 15 | 16 | return ( 17 | <> 18 | 19 | setOpen(true)}> 20 | 21 | 22 | 23 | {dialog} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /packages/editor-sample/src/App/TemplatePanel/ImportJson/validateJsonStringValue.ts: -------------------------------------------------------------------------------- 1 | import { EditorConfigurationSchema, TEditorConfiguration } from '../../../documents/editor/core'; 2 | 3 | type TResult = { error: string; data?: undefined } | { data: TEditorConfiguration; error?: undefined }; 4 | 5 | export default function validateTextAreaValue(value: string): TResult { 6 | let jsonObject = undefined; 7 | try { 8 | jsonObject = JSON.parse(value); 9 | } catch { 10 | return { error: 'Invalid json' }; 11 | } 12 | 13 | const parseResult = EditorConfigurationSchema.safeParse(jsonObject); 14 | if (!parseResult.success) { 15 | return { error: 'Invalid JSON schema' }; 16 | } 17 | 18 | if (!parseResult.data.root) { 19 | return { error: 'Missing "root" node' }; 20 | } 21 | 22 | return { data: parseResult.data }; 23 | } 24 | -------------------------------------------------------------------------------- /packages/editor-sample/src/App/TemplatePanel/JsonPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import { useDocument } from '../../documents/editor/EditorContext'; 4 | 5 | import HighlightedCodePanel from './helper/HighlightedCodePanel'; 6 | 7 | export default function JsonPanel() { 8 | const document = useDocument(); 9 | const code = useMemo(() => JSON.stringify(document, null, ' '), [document]); 10 | return ; 11 | } 12 | -------------------------------------------------------------------------------- /packages/editor-sample/src/App/TemplatePanel/MainTabsGroup.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { CodeOutlined, DataObjectOutlined, EditOutlined, PreviewOutlined } from '@mui/icons-material'; 4 | import { Tab, Tabs, Tooltip } from '@mui/material'; 5 | 6 | import { setSelectedMainTab, useSelectedMainTab } from '../../documents/editor/EditorContext'; 7 | 8 | export default function MainTabsGroup() { 9 | const selectedMainTab = useSelectedMainTab(); 10 | const handleChange = (_: unknown, v: unknown) => { 11 | switch (v) { 12 | case 'json': 13 | case 'preview': 14 | case 'editor': 15 | case 'html': 16 | setSelectedMainTab(v); 17 | return; 18 | default: 19 | setSelectedMainTab('editor'); 20 | } 21 | }; 22 | 23 | return ( 24 | 25 | 29 | 30 | 31 | } 32 | /> 33 | 37 | 38 | 39 | } 40 | /> 41 | 45 | 46 | 47 | } 48 | /> 49 | 53 | 54 | 55 | } 56 | /> 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /packages/editor-sample/src/App/TemplatePanel/ShareButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { IosShareOutlined } from '@mui/icons-material'; 4 | import { IconButton, Snackbar, Tooltip } from '@mui/material'; 5 | 6 | import { useDocument } from '../../documents/editor/EditorContext'; 7 | 8 | export default function ShareButton() { 9 | const document = useDocument(); 10 | const [message, setMessage] = useState(null); 11 | 12 | const onClick = async () => { 13 | const c = encodeURIComponent(JSON.stringify(document)); 14 | location.hash = `#code/${btoa(c)}`; 15 | setMessage('The URL was updated. Copy it to share your current template.'); 16 | }; 17 | 18 | const onClose = () => { 19 | setMessage(null); 20 | }; 21 | 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 28 | 29 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /packages/editor-sample/src/App/TemplatePanel/helper/HighlightedCodePanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | import { html, json } from './highlighters'; 4 | 5 | type TextEditorPanelProps = { 6 | type: 'json' | 'html' | 'javascript'; 7 | value: string; 8 | }; 9 | export default function HighlightedCodePanel({ type, value }: TextEditorPanelProps) { 10 | const [code, setCode] = useState(null); 11 | 12 | useEffect(() => { 13 | switch (type) { 14 | case 'html': 15 | html(value).then(setCode); 16 | return; 17 | case 'json': 18 | json(value).then(setCode); 19 | return; 20 | } 21 | }, [setCode, value, type]); 22 | 23 | if (code === null) { 24 | return null; 25 | } 26 | 27 | return ( 28 |
 {
32 |         const s = window.getSelection();
33 |         if (s === null) {
34 |           return;
35 |         }
36 |         s.selectAllChildren(ev.currentTarget);
37 |       }}
38 |     />
39 |   );
40 | }
41 | 


--------------------------------------------------------------------------------
/packages/editor-sample/src/App/TemplatePanel/helper/highlighters.tsx:
--------------------------------------------------------------------------------
 1 | import hljs from 'highlight.js';
 2 | import jsonHighlighter from 'highlight.js/lib/languages/json';
 3 | import xmlHighlighter from 'highlight.js/lib/languages/xml';
 4 | import prettierPluginBabel from 'prettier/plugins/babel';
 5 | import prettierPluginEstree from 'prettier/plugins/estree';
 6 | import prettierPluginHtml from 'prettier/plugins/html';
 7 | import { format } from 'prettier/standalone';
 8 | 
 9 | hljs.registerLanguage('json', jsonHighlighter);
10 | hljs.registerLanguage('html', xmlHighlighter);
11 | 
12 | export async function html(value: string): Promise {
13 |   const prettyValue = await format(value, {
14 |     parser: 'html',
15 |     plugins: [prettierPluginHtml],
16 |   });
17 |   return hljs.highlight(prettyValue, { language: 'html' }).value;
18 | }
19 | 
20 | export async function json(value: string): Promise {
21 |   const prettyValue = await format(value, {
22 |     parser: 'json',
23 |     printWidth: 0,
24 |     trailingComma: 'all',
25 |     plugins: [prettierPluginBabel, prettierPluginEstree],
26 |   });
27 |   return hljs.highlight(prettyValue, { language: 'javascript' }).value;
28 | }
29 | 


--------------------------------------------------------------------------------
/packages/editor-sample/src/App/index.tsx:
--------------------------------------------------------------------------------
 1 | import React from 'react';
 2 | 
 3 | import { Stack, useTheme } from '@mui/material';
 4 | 
 5 | import { useInspectorDrawerOpen, useSamplesDrawerOpen } from '../documents/editor/EditorContext';
 6 | 
 7 | import InspectorDrawer, { INSPECTOR_DRAWER_WIDTH } from './InspectorDrawer';
 8 | import SamplesDrawer, { SAMPLES_DRAWER_WIDTH } from './SamplesDrawer';
 9 | import TemplatePanel from './TemplatePanel';
10 | 
11 | function useDrawerTransition(cssProperty: 'margin-left' | 'margin-right', open: boolean) {
12 |   const { transitions } = useTheme();
13 |   return transitions.create(cssProperty, {
14 |     easing: !open ? transitions.easing.sharp : transitions.easing.easeOut,
15 |     duration: !open ? transitions.duration.leavingScreen : transitions.duration.enteringScreen,
16 |   });
17 | }
18 | 
19 | export default function App() {
20 |   const inspectorDrawerOpen = useInspectorDrawerOpen();
21 |   const samplesDrawerOpen = useSamplesDrawerOpen();
22 | 
23 |   const marginLeftTransition = useDrawerTransition('margin-left', samplesDrawerOpen);
24 |   const marginRightTransition = useDrawerTransition('margin-right', inspectorDrawerOpen);
25 | 
26 |   return (
27 |     <>
28 |       
29 |       
30 | 
31 |       
38 |         
39 |       
40 |     
41 |   );
42 | }
43 | 


--------------------------------------------------------------------------------
/packages/editor-sample/src/documents/blocks/ColumnsContainer/ColumnsContainerEditor.tsx:
--------------------------------------------------------------------------------
 1 | import React from 'react';
 2 | 
 3 | import { ColumnsContainer as BaseColumnsContainer } from '@usewaypoint/block-columns-container';
 4 | 
 5 | import { useCurrentBlockId } from '../../editor/EditorBlock';
 6 | import { setDocument, setSelectedBlockId } from '../../editor/EditorContext';
 7 | import EditorChildrenIds, { EditorChildrenChange } from '../helpers/EditorChildrenIds';
 8 | 
 9 | import ColumnsContainerPropsSchema, { ColumnsContainerProps } from './ColumnsContainerPropsSchema';
10 | 
11 | const EMPTY_COLUMNS = [{ childrenIds: [] }, { childrenIds: [] }, { childrenIds: [] }];
12 | 
13 | export default function ColumnsContainerEditor({ style, props }: ColumnsContainerProps) {
14 |   const currentBlockId = useCurrentBlockId();
15 | 
16 |   const { columns, ...restProps } = props ?? {};
17 |   const columnsValue = columns ?? EMPTY_COLUMNS;
18 | 
19 |   const updateColumn = (columnIndex: 0 | 1 | 2, { block, blockId, childrenIds }: EditorChildrenChange) => {
20 |     const nColumns = [...columnsValue];
21 |     nColumns[columnIndex] = { childrenIds };
22 |     setDocument({
23 |       [blockId]: block,
24 |       [currentBlockId]: {
25 |         type: 'ColumnsContainer',
26 |         data: ColumnsContainerPropsSchema.parse({
27 |           style,
28 |           props: {
29 |             ...restProps,
30 |             columns: nColumns,
31 |           },
32 |         }),
33 |       },
34 |     });
35 |     setSelectedBlockId(blockId);
36 |   };
37 | 
38 |   return (
39 |      updateColumn(0, change)} />,
44 |          updateColumn(1, change)} />,
45 |          updateColumn(2, change)} />,
46 |       ]}
47 |     />
48 |   );
49 | }
50 | 


--------------------------------------------------------------------------------
/packages/editor-sample/src/documents/blocks/ColumnsContainer/ColumnsContainerPropsSchema.ts:
--------------------------------------------------------------------------------
 1 | import { z } from 'zod';
 2 | 
 3 | import { ColumnsContainerPropsSchema as BaseColumnsContainerPropsSchema } from '@usewaypoint/block-columns-container';
 4 | 
 5 | const BasePropsShape = BaseColumnsContainerPropsSchema.shape.props.unwrap().unwrap().shape;
 6 | 
 7 | const ColumnsContainerPropsSchema = z.object({
 8 |   style: BaseColumnsContainerPropsSchema.shape.style,
 9 |   props: z
10 |     .object({
11 |       ...BasePropsShape,
12 |       columns: z.tuple([
13 |         z.object({ childrenIds: z.array(z.string()) }),
14 |         z.object({ childrenIds: z.array(z.string()) }),
15 |         z.object({ childrenIds: z.array(z.string()) }),
16 |       ]),
17 |     })
18 |     .optional()
19 |     .nullable(),
20 | });
21 | 
22 | export type ColumnsContainerProps = z.infer;
23 | export default ColumnsContainerPropsSchema;
24 | 


--------------------------------------------------------------------------------
/packages/editor-sample/src/documents/blocks/Container/ContainerEditor.tsx:
--------------------------------------------------------------------------------
 1 | import React from 'react';
 2 | 
 3 | import { Container as BaseContainer } from '@usewaypoint/block-container';
 4 | 
 5 | import { useCurrentBlockId } from '../../editor/EditorBlock';
 6 | import { setDocument, setSelectedBlockId, useDocument } from '../../editor/EditorContext';
 7 | import EditorChildrenIds from '../helpers/EditorChildrenIds';
 8 | 
 9 | import { ContainerProps } from './ContainerPropsSchema';
10 | 
11 | export default function ContainerEditor({ style, props }: ContainerProps) {
12 |   const childrenIds = props?.childrenIds ?? [];
13 | 
14 |   const document = useDocument();
15 |   const currentBlockId = useCurrentBlockId();
16 | 
17 |   return (
18 |     
19 |        {
22 |           setDocument({
23 |             [blockId]: block,
24 |             [currentBlockId]: {
25 |               type: 'Container',
26 |               data: {
27 |                 ...document[currentBlockId].data,
28 |                 props: { childrenIds: childrenIds },
29 |               },
30 |             },
31 |           });
32 |           setSelectedBlockId(blockId);
33 |         }}
34 |       />
35 |     
36 |   );
37 | }
38 | 


--------------------------------------------------------------------------------
/packages/editor-sample/src/documents/blocks/Container/ContainerPropsSchema.tsx:
--------------------------------------------------------------------------------
 1 | import { z } from 'zod';
 2 | 
 3 | import { ContainerPropsSchema as BaseContainerPropsSchema } from '@usewaypoint/block-container';
 4 | 
 5 | const ContainerPropsSchema = z.object({
 6 |   style: BaseContainerPropsSchema.shape.style,
 7 |   props: z
 8 |     .object({
 9 |       childrenIds: z.array(z.string()).optional().nullable(),
10 |     })
11 |     .optional()
12 |     .nullable(),
13 | });
14 | 
15 | export default ContainerPropsSchema;
16 | 
17 | export type ContainerProps = z.infer;
18 | 


--------------------------------------------------------------------------------
/packages/editor-sample/src/documents/blocks/EmailLayout/EmailLayoutPropsSchema.tsx:
--------------------------------------------------------------------------------
 1 | import { z } from 'zod';
 2 | 
 3 | const COLOR_SCHEMA = z
 4 |   .string()
 5 |   .regex(/^#[0-9a-fA-F]{6}$/)
 6 |   .nullable()
 7 |   .optional();
 8 | 
 9 | const FONT_FAMILY_SCHEMA = z
10 |   .enum([
11 |     'MODERN_SANS',
12 |     'BOOK_SANS',
13 |     'ORGANIC_SANS',
14 |     'GEOMETRIC_SANS',
15 |     'HEAVY_SANS',
16 |     'ROUNDED_SANS',
17 |     'MODERN_SERIF',
18 |     'BOOK_SERIF',
19 |     'MONOSPACE',
20 |   ])
21 |   .nullable()
22 |   .optional();
23 | 
24 | const EmailLayoutPropsSchema = z.object({
25 |   backdropColor: COLOR_SCHEMA,
26 |   borderColor: COLOR_SCHEMA,
27 |   borderRadius: z.number().optional().nullable(),
28 |   canvasColor: COLOR_SCHEMA,
29 |   textColor: COLOR_SCHEMA,
30 |   fontFamily: FONT_FAMILY_SCHEMA,
31 |   childrenIds: z.array(z.string()).optional().nullable(),
32 | });
33 | 
34 | export default EmailLayoutPropsSchema;
35 | 
36 | export type EmailLayoutProps = z.infer;
37 | 


--------------------------------------------------------------------------------
/packages/editor-sample/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/BlockButton.tsx:
--------------------------------------------------------------------------------
 1 | import React from 'react';
 2 | 
 3 | import { Box, Button, SxProps, Typography } from '@mui/material';
 4 | 
 5 | type BlockMenuButtonProps = {
 6 |   label: string;
 7 |   icon: React.ReactNode;
 8 |   onClick: () => void;
 9 | };
10 | 
11 | const BUTTON_SX: SxProps = { p: 1.5, display: 'flex', flexDirection: 'column' };
12 | const ICON_SX: SxProps = {
13 |   mb: 0.75,
14 |   width: '100%',
15 |   bgcolor: 'cadet.200',
16 |   display: 'flex',
17 |   justifyContent: 'center',
18 |   p: 1,
19 |   border: '1px solid',
20 |   borderColor: 'cadet.300',
21 | };
22 | 
23 | export default function BlockTypeButton({ label, icon, onClick }: BlockMenuButtonProps) {
24 |   return (
25 |     
35 |   );
36 | }
37 | 


--------------------------------------------------------------------------------
/packages/editor-sample/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/BlocksMenu.tsx:
--------------------------------------------------------------------------------
 1 | import React from 'react';
 2 | 
 3 | import { Box, Menu } from '@mui/material';
 4 | 
 5 | import { TEditorBlock } from '../../../../editor/core';
 6 | 
 7 | import BlockButton from './BlockButton';
 8 | import { BUTTONS } from './buttons';
 9 | 
10 | type BlocksMenuProps = {
11 |   anchorEl: HTMLElement | null;
12 |   setAnchorEl: (v: HTMLElement | null) => void;
13 |   onSelect: (block: TEditorBlock) => void;
14 | };
15 | export default function BlocksMenu({ anchorEl, setAnchorEl, onSelect }: BlocksMenuProps) {
16 |   const onClose = () => {
17 |     setAnchorEl(null);
18 |   };
19 | 
20 |   const onClick = (block: TEditorBlock) => {
21 |     onSelect(block);
22 |     setAnchorEl(null);
23 |   };
24 | 
25 |   if (anchorEl === null) {
26 |     return null;
27 |   }
28 | 
29 |   return (
30 |     
37 |       
38 |         {BUTTONS.map((k, i) => (
39 |            onClick(k.block())} />
40 |         ))}
41 |       
42 |     
43 |   );
44 | }
45 | 


--------------------------------------------------------------------------------
/packages/editor-sample/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/DividerButton.tsx:
--------------------------------------------------------------------------------
 1 | import React, { useEffect, useState } from 'react';
 2 | 
 3 | import { AddOutlined } from '@mui/icons-material';
 4 | import { Fade, IconButton } from '@mui/material';
 5 | 
 6 | type Props = {
 7 |   buttonElement: HTMLElement | null;
 8 |   onClick: () => void;
 9 | };
10 | export default function DividerButton({ buttonElement, onClick }: Props) {
11 |   const [visible, setVisible] = useState(false);
12 | 
13 |   useEffect(() => {
14 |     function listener({ clientX, clientY }: MouseEvent) {
15 |       if (!buttonElement) {
16 |         return;
17 |       }
18 |       const rect = buttonElement.getBoundingClientRect();
19 |       const rectY = rect.y;
20 |       const bottomX = rect.x;
21 |       const topX = bottomX + rect.width;
22 | 
23 |       if (Math.abs(clientY - rectY) < 20) {
24 |         if (bottomX < clientX && clientX < topX) {
25 |           setVisible(true);
26 |           return;
27 |         }
28 |       }
29 |       setVisible(false);
30 |     }
31 |     window.addEventListener('mousemove', listener);
32 |     return () => {
33 |       window.removeEventListener('mousemove', listener);
34 |     };
35 |   }, [buttonElement, setVisible]);
36 | 
37 |   return (
38 |     
39 |        {
56 |           ev.stopPropagation();
57 |           onClick();
58 |         }}
59 |       >
60 |         
61 |       
62 |     
63 |   );
64 | }
65 | 


--------------------------------------------------------------------------------
/packages/editor-sample/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/PlaceholderButton.tsx:
--------------------------------------------------------------------------------
 1 | import React from 'react';
 2 | 
 3 | import { AddOutlined } from '@mui/icons-material';
 4 | import { ButtonBase } from '@mui/material';
 5 | 
 6 | type Props = {
 7 |   onClick: () => void;
 8 | };
 9 | export default function PlaceholderButton({ onClick }: Props) {
10 |   return (
11 |      {
13 |         ev.stopPropagation();
14 |         onClick();
15 |       }}
16 |       sx={{
17 |         display: 'flex',
18 |         alignContent: 'center',
19 |         justifyContent: 'center',
20 |         height: 48,
21 |         width: '100%',
22 |         bgcolor: 'rgba(0,0,0, 0.05)',
23 |       }}
24 |     >
25 |       
34 |     
35 |   );
36 | }
37 | 


--------------------------------------------------------------------------------
/packages/editor-sample/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/index.tsx:
--------------------------------------------------------------------------------
 1 | import React, { useState } from 'react';
 2 | 
 3 | import { TEditorBlock } from '../../../../editor/core';
 4 | 
 5 | import BlocksMenu from './BlocksMenu';
 6 | import DividerButton from './DividerButton';
 7 | import PlaceholderButton from './PlaceholderButton';
 8 | 
 9 | type Props = {
10 |   placeholder?: boolean;
11 |   onSelect: (block: TEditorBlock) => void;
12 | };
13 | export default function AddBlockButton({ onSelect, placeholder }: Props) {
14 |   const [menuAnchorEl, setMenuAnchorEl] = useState(null);
15 |   const [buttonElement, setButtonElement] = useState(null);
16 | 
17 |   const handleButtonClick = () => {
18 |     setMenuAnchorEl(buttonElement);
19 |   };
20 | 
21 |   const renderButton = () => {
22 |     if (placeholder) {
23 |       return ;
24 |     } else {
25 |       return ;
26 |     }
27 |   };
28 | 
29 |   return (
30 |     <>
31 |       
32 | {renderButton()} 33 |
34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /packages/editor-sample/src/documents/blocks/helpers/EditorChildrenIds/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | 3 | import { TEditorBlock } from '../../../editor/core'; 4 | import EditorBlock from '../../../editor/EditorBlock'; 5 | 6 | import AddBlockButton from './AddBlockMenu'; 7 | 8 | export type EditorChildrenChange = { 9 | blockId: string; 10 | block: TEditorBlock; 11 | childrenIds: string[]; 12 | }; 13 | 14 | function generateId() { 15 | return `block-${Date.now()}`; 16 | } 17 | 18 | export type EditorChildrenIdsProps = { 19 | childrenIds: string[] | null | undefined; 20 | onChange: (val: EditorChildrenChange) => void; 21 | }; 22 | export default function EditorChildrenIds({ childrenIds, onChange }: EditorChildrenIdsProps) { 23 | const appendBlock = (block: TEditorBlock) => { 24 | const blockId = generateId(); 25 | return onChange({ 26 | blockId, 27 | block, 28 | childrenIds: [...(childrenIds || []), blockId], 29 | }); 30 | }; 31 | 32 | const insertBlock = (block: TEditorBlock, index: number) => { 33 | const blockId = generateId(); 34 | const newChildrenIds = [...(childrenIds || [])]; 35 | newChildrenIds.splice(index, 0, blockId); 36 | return onChange({ 37 | blockId, 38 | block, 39 | childrenIds: newChildrenIds, 40 | }); 41 | }; 42 | 43 | if (!childrenIds || childrenIds.length === 0) { 44 | return ; 45 | } 46 | 47 | return ( 48 | <> 49 | {childrenIds.map((childId, i) => ( 50 | 51 | insertBlock(block, i)} /> 52 | 53 | 54 | ))} 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /packages/editor-sample/src/documents/blocks/helpers/TStyle.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | export type TStyle = { 4 | backgroundColor?: any; 5 | borderColor?: any; 6 | borderRadius?: any; 7 | color?: any; 8 | fontFamily?: any; 9 | fontSize?: any; 10 | fontWeight?: any; 11 | padding?: any; 12 | textAlign?: any; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/editor-sample/src/documents/blocks/helpers/block-wrappers/EditorBlockWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, useState } from 'react'; 2 | 3 | import { Box } from '@mui/material'; 4 | 5 | import { useCurrentBlockId } from '../../../editor/EditorBlock'; 6 | import { setSelectedBlockId, useSelectedBlockId } from '../../../editor/EditorContext'; 7 | 8 | import TuneMenu from './TuneMenu'; 9 | 10 | type TEditorBlockWrapperProps = { 11 | children: JSX.Element; 12 | }; 13 | 14 | export default function EditorBlockWrapper({ children }: TEditorBlockWrapperProps) { 15 | const selectedBlockId = useSelectedBlockId(); 16 | const [mouseInside, setMouseInside] = useState(false); 17 | const blockId = useCurrentBlockId(); 18 | 19 | let outline: CSSProperties['outline']; 20 | if (selectedBlockId === blockId) { 21 | outline = '2px solid rgba(0,121,204, 1)'; 22 | } else if (mouseInside) { 23 | outline = '2px solid rgba(0,121,204, 0.3)'; 24 | } 25 | 26 | const renderMenu = () => { 27 | if (selectedBlockId !== blockId) { 28 | return null; 29 | } 30 | return ; 31 | }; 32 | 33 | return ( 34 | { 42 | setMouseInside(true); 43 | ev.stopPropagation(); 44 | }} 45 | onMouseLeave={() => { 46 | setMouseInside(false); 47 | }} 48 | onClick={(ev) => { 49 | setSelectedBlockId(blockId); 50 | ev.stopPropagation(); 51 | ev.preventDefault(); 52 | }} 53 | > 54 | {renderMenu()} 55 | {children} 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /packages/editor-sample/src/documents/blocks/helpers/block-wrappers/ReaderBlockWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from 'react'; 2 | 3 | import { TStyle } from '../TStyle'; 4 | 5 | type TReaderBlockWrapperProps = { 6 | style: TStyle; 7 | children: JSX.Element; 8 | }; 9 | 10 | export default function ReaderBlockWrapper({ style, children }: TReaderBlockWrapperProps) { 11 | const { padding, borderColor, ...restStyle } = style; 12 | const cssStyle: CSSProperties = { 13 | ...restStyle, 14 | }; 15 | 16 | if (padding) { 17 | const { top, bottom, left, right } = padding; 18 | cssStyle.padding = `${top}px ${right}px ${bottom}px ${left}px`; 19 | } 20 | 21 | if (borderColor) { 22 | cssStyle.border = `1px solid ${borderColor}`; 23 | } 24 | 25 | return
{children}
; 26 | } 27 | -------------------------------------------------------------------------------- /packages/editor-sample/src/documents/blocks/helpers/fontFamily.ts: -------------------------------------------------------------------------------- 1 | export const FONT_FAMILIES = [ 2 | { 3 | key: 'MODERN_SANS', 4 | label: 'Modern sans', 5 | value: '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif', 6 | }, 7 | { 8 | key: 'BOOK_SANS', 9 | label: 'Book sans', 10 | value: 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif', 11 | }, 12 | { 13 | key: 'ORGANIC_SANS', 14 | label: 'Organic sans', 15 | value: 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif', 16 | }, 17 | { 18 | key: 'GEOMETRIC_SANS', 19 | label: 'Geometric sans', 20 | value: 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif', 21 | }, 22 | { 23 | key: 'HEAVY_SANS', 24 | label: 'Heavy sans', 25 | value: 26 | 'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif', 27 | }, 28 | { 29 | key: 'ROUNDED_SANS', 30 | label: 'Rounded sans', 31 | value: 32 | 'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif', 33 | }, 34 | { 35 | key: 'MODERN_SERIF', 36 | label: 'Modern serif', 37 | value: 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif', 38 | }, 39 | { 40 | key: 'BOOK_SERIF', 41 | label: 'Book serif', 42 | value: '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif', 43 | }, 44 | { 45 | key: 'MONOSPACE', 46 | label: 'Monospace', 47 | value: '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace', 48 | }, 49 | ]; 50 | 51 | export const FONT_FAMILY_NAMES = [ 52 | 'MODERN_SANS', 53 | 'BOOK_SANS', 54 | 'ORGANIC_SANS', 55 | 'GEOMETRIC_SANS', 56 | 'HEAVY_SANS', 57 | 'ROUNDED_SANS', 58 | 'MODERN_SERIF', 59 | 'BOOK_SERIF', 60 | 'MONOSPACE', 61 | ] as const; 62 | -------------------------------------------------------------------------------- /packages/editor-sample/src/documents/blocks/helpers/zod.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { FONT_FAMILY_NAMES } from './fontFamily'; 4 | 5 | export function zColor() { 6 | return z.string().regex(/^#[0-9a-fA-F]{6}$/); 7 | } 8 | 9 | export function zFontFamily() { 10 | return z.enum(FONT_FAMILY_NAMES); 11 | } 12 | 13 | export function zFontWeight() { 14 | return z.enum(['bold', 'normal']); 15 | } 16 | 17 | export function zTextAlign() { 18 | return z.enum(['left', 'center', 'right']); 19 | } 20 | 21 | export function zPadding() { 22 | return z.object({ 23 | top: z.number(), 24 | bottom: z.number(), 25 | right: z.number(), 26 | left: z.number(), 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /packages/editor-sample/src/documents/editor/EditorBlock.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react'; 2 | 3 | import { EditorBlock as CoreEditorBlock } from './core'; 4 | import { useDocument } from './EditorContext'; 5 | 6 | const EditorBlockContext = createContext(null); 7 | export const useCurrentBlockId = () => useContext(EditorBlockContext)!; 8 | 9 | type EditorBlockProps = { 10 | id: string; 11 | }; 12 | 13 | /** 14 | * 15 | * @param id - Block id 16 | * @returns EditorBlock component that loads data from the EditorDocumentContext 17 | */ 18 | export default function EditorBlock({ id }: EditorBlockProps) { 19 | const document = useDocument(); 20 | const block = document[id]; 21 | if (!block) { 22 | throw new Error('Could not find block'); 23 | } 24 | return ( 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /packages/editor-sample/src/documents/editor/EditorContext.tsx: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | import getConfiguration from '../../getConfiguration'; 4 | 5 | import { TEditorConfiguration } from './core'; 6 | 7 | type TValue = { 8 | document: TEditorConfiguration; 9 | 10 | selectedBlockId: string | null; 11 | selectedSidebarTab: 'block-configuration' | 'styles'; 12 | selectedMainTab: 'editor' | 'preview' | 'json' | 'html'; 13 | selectedScreenSize: 'desktop' | 'mobile'; 14 | 15 | inspectorDrawerOpen: boolean; 16 | samplesDrawerOpen: boolean; 17 | }; 18 | 19 | const editorStateStore = create(() => ({ 20 | document: getConfiguration(window.location.hash), 21 | selectedBlockId: null, 22 | selectedSidebarTab: 'styles', 23 | selectedMainTab: 'editor', 24 | selectedScreenSize: 'desktop', 25 | 26 | inspectorDrawerOpen: true, 27 | samplesDrawerOpen: true, 28 | })); 29 | 30 | export function useDocument() { 31 | return editorStateStore((s) => s.document); 32 | } 33 | 34 | export function useSelectedBlockId() { 35 | return editorStateStore((s) => s.selectedBlockId); 36 | } 37 | 38 | export function useSelectedScreenSize() { 39 | return editorStateStore((s) => s.selectedScreenSize); 40 | } 41 | 42 | export function useSelectedMainTab() { 43 | return editorStateStore((s) => s.selectedMainTab); 44 | } 45 | 46 | export function setSelectedMainTab(selectedMainTab: TValue['selectedMainTab']) { 47 | return editorStateStore.setState({ selectedMainTab }); 48 | } 49 | 50 | export function useSelectedSidebarTab() { 51 | return editorStateStore((s) => s.selectedSidebarTab); 52 | } 53 | 54 | export function useInspectorDrawerOpen() { 55 | return editorStateStore((s) => s.inspectorDrawerOpen); 56 | } 57 | 58 | export function useSamplesDrawerOpen() { 59 | return editorStateStore((s) => s.samplesDrawerOpen); 60 | } 61 | 62 | export function setSelectedBlockId(selectedBlockId: TValue['selectedBlockId']) { 63 | const selectedSidebarTab = selectedBlockId === null ? 'styles' : 'block-configuration'; 64 | const options: Partial = {}; 65 | if (selectedBlockId !== null) { 66 | options.inspectorDrawerOpen = true; 67 | } 68 | return editorStateStore.setState({ 69 | selectedBlockId, 70 | selectedSidebarTab, 71 | ...options, 72 | }); 73 | } 74 | 75 | export function setSidebarTab(selectedSidebarTab: TValue['selectedSidebarTab']) { 76 | return editorStateStore.setState({ selectedSidebarTab }); 77 | } 78 | 79 | export function resetDocument(document: TValue['document']) { 80 | return editorStateStore.setState({ 81 | document, 82 | selectedSidebarTab: 'styles', 83 | selectedBlockId: null, 84 | }); 85 | } 86 | 87 | export function setDocument(document: TValue['document']) { 88 | const originalDocument = editorStateStore.getState().document; 89 | return editorStateStore.setState({ 90 | document: { 91 | ...originalDocument, 92 | ...document, 93 | }, 94 | }); 95 | } 96 | 97 | export function toggleInspectorDrawerOpen() { 98 | const inspectorDrawerOpen = !editorStateStore.getState().inspectorDrawerOpen; 99 | return editorStateStore.setState({ inspectorDrawerOpen }); 100 | } 101 | 102 | export function toggleSamplesDrawerOpen() { 103 | const samplesDrawerOpen = !editorStateStore.getState().samplesDrawerOpen; 104 | return editorStateStore.setState({ samplesDrawerOpen }); 105 | } 106 | 107 | export function setSelectedScreenSize(selectedScreenSize: TValue['selectedScreenSize']) { 108 | return editorStateStore.setState({ selectedScreenSize }); 109 | } 110 | -------------------------------------------------------------------------------- /packages/editor-sample/src/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usewaypoint/email-builder-js/1ded24dca92f90488c27f9ef536d37a2d6692ce5/packages/editor-sample/src/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /packages/editor-sample/src/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usewaypoint/email-builder-js/1ded24dca92f90488c27f9ef536d37a2d6692ce5/packages/editor-sample/src/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /packages/editor-sample/src/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usewaypoint/email-builder-js/1ded24dca92f90488c27f9ef536d37a2d6692ce5/packages/editor-sample/src/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/editor-sample/src/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usewaypoint/email-builder-js/1ded24dca92f90488c27f9ef536d37a2d6692ce5/packages/editor-sample/src/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /packages/editor-sample/src/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usewaypoint/email-builder-js/1ded24dca92f90488c27f9ef536d37a2d6692ce5/packages/editor-sample/src/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /packages/editor-sample/src/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usewaypoint/email-builder-js/1ded24dca92f90488c27f9ef536d37a2d6692ce5/packages/editor-sample/src/favicon/favicon.ico -------------------------------------------------------------------------------- /packages/editor-sample/src/getConfiguration/index.tsx: -------------------------------------------------------------------------------- 1 | import EMPTY_EMAIL_MESSAGE from './sample/empty-email-message'; 2 | import ONE_TIME_PASSCODE from './sample/one-time-passcode'; 3 | import ORDER_ECOMMERCE from './sample/order-ecommerce'; 4 | import POST_METRICS_REPORT from './sample/post-metrics-report'; 5 | import RESERVATION_REMINDER from './sample/reservation-reminder'; 6 | import RESET_PASSWORD from './sample/reset-password'; 7 | import RESPOND_TO_MESSAGE from './sample/respond-to-message'; 8 | import SUBSCRIPTION_RECEIPT from './sample/subscription-receipt'; 9 | import WELCOME from './sample/welcome'; 10 | 11 | export default function getConfiguration(template: string) { 12 | if (template.startsWith('#sample/')) { 13 | const sampleName = template.replace('#sample/', ''); 14 | switch (sampleName) { 15 | case 'welcome': 16 | return WELCOME; 17 | case 'one-time-password': 18 | return ONE_TIME_PASSCODE; 19 | case 'order-ecomerce': 20 | return ORDER_ECOMMERCE; 21 | case 'post-metrics-report': 22 | return POST_METRICS_REPORT; 23 | case 'reservation-reminder': 24 | return RESERVATION_REMINDER; 25 | case 'reset-password': 26 | return RESET_PASSWORD; 27 | case 'respond-to-message': 28 | return RESPOND_TO_MESSAGE; 29 | case 'subscription-receipt': 30 | return SUBSCRIPTION_RECEIPT; 31 | } 32 | } 33 | 34 | if (template.startsWith('#code/')) { 35 | const encodedString = template.replace('#code/', ''); 36 | const configurationString = decodeURIComponent(atob(encodedString)); 37 | try { 38 | return JSON.parse(configurationString); 39 | } catch { 40 | console.error(`Couldn't load configuration from hash.`); 41 | } 42 | } 43 | 44 | return EMPTY_EMAIL_MESSAGE; 45 | } 46 | -------------------------------------------------------------------------------- /packages/editor-sample/src/getConfiguration/sample/empty-email-message.ts: -------------------------------------------------------------------------------- 1 | import { TEditorConfiguration } from '../../documents/editor/core'; 2 | 3 | const EMPTY_EMAIL_MESSAGE: TEditorConfiguration = { 4 | root: { 5 | type: 'EmailLayout', 6 | data: { 7 | backdropColor: '#F5F5F5', 8 | canvasColor: '#FFFFFF', 9 | textColor: '#262626', 10 | fontFamily: 'MODERN_SANS', 11 | childrenIds: [], 12 | }, 13 | }, 14 | }; 15 | 16 | export default EMPTY_EMAIL_MESSAGE; 17 | -------------------------------------------------------------------------------- /packages/editor-sample/src/getConfiguration/sample/one-time-passcode.ts: -------------------------------------------------------------------------------- 1 | import { TEditorConfiguration } from '../../documents/editor/core'; 2 | 3 | const ONE_TIME_PASSCODE: TEditorConfiguration = { 4 | root: { 5 | type: 'EmailLayout', 6 | data: { 7 | backdropColor: '#000000', 8 | canvasColor: '#000000', 9 | textColor: '#FFFFFF', 10 | fontFamily: 'BOOK_SERIF', 11 | childrenIds: [ 12 | 'block_ChPX66qUhF46uynDE8AY11', 13 | 'block_CkNrtQgkqPt2YWLv1hr5eJ', 14 | 'block_BFLBa3q5y8kax9KngyXP65', 15 | 'block_4T7sDFb4rqbSyWjLGJKmov', 16 | 'block_Rvc8ZfTjfhXjpphHquJKvP', 17 | ], 18 | }, 19 | }, 20 | block_ChPX66qUhF46uynDE8AY11: { 21 | type: 'Image', 22 | data: { 23 | style: { 24 | backgroundColor: null, 25 | padding: { 26 | top: 24, 27 | bottom: 24, 28 | left: 24, 29 | right: 24, 30 | }, 31 | textAlign: 'center', 32 | }, 33 | props: { 34 | height: 24, 35 | url: 'https://d1iiu589g39o6c.cloudfront.net/live/platforms/platform_A9wwKSL6EV6orh6f/images/wptemplateimage_jc7ZfPvdHJ6rtH1W/&.png', 36 | contentAlignment: 'middle', 37 | }, 38 | }, 39 | }, 40 | block_CkNrtQgkqPt2YWLv1hr5eJ: { 41 | type: 'Text', 42 | data: { 43 | style: { 44 | color: '#ffffff', 45 | backgroundColor: null, 46 | fontSize: 16, 47 | fontFamily: null, 48 | fontWeight: 'normal', 49 | textAlign: 'center', 50 | padding: { 51 | top: 16, 52 | bottom: 16, 53 | left: 24, 54 | right: 24, 55 | }, 56 | }, 57 | props: { 58 | text: 'Here is your one-time passcode:', 59 | }, 60 | }, 61 | }, 62 | block_BFLBa3q5y8kax9KngyXP65: { 63 | type: 'Heading', 64 | data: { 65 | style: { 66 | color: null, 67 | backgroundColor: null, 68 | fontFamily: 'MONOSPACE', 69 | fontWeight: 'bold', 70 | textAlign: 'center', 71 | padding: { 72 | top: 16, 73 | bottom: 16, 74 | left: 24, 75 | right: 24, 76 | }, 77 | }, 78 | props: { 79 | level: 'h1', 80 | text: '0123456', 81 | }, 82 | }, 83 | }, 84 | block_4T7sDFb4rqbSyWjLGJKmov: { 85 | type: 'Text', 86 | data: { 87 | style: { 88 | color: '#868686', 89 | backgroundColor: null, 90 | fontSize: 16, 91 | fontFamily: null, 92 | fontWeight: 'normal', 93 | textAlign: 'center', 94 | padding: { 95 | top: 16, 96 | bottom: 16, 97 | left: 24, 98 | right: 24, 99 | }, 100 | }, 101 | props: { 102 | text: 'This code will expire in 30 minutes.', 103 | }, 104 | }, 105 | }, 106 | block_Rvc8ZfTjfhXjpphHquJKvP: { 107 | type: 'Text', 108 | data: { 109 | style: { 110 | color: '#868686', 111 | backgroundColor: null, 112 | fontSize: 14, 113 | fontFamily: null, 114 | fontWeight: 'normal', 115 | textAlign: 'center', 116 | padding: { 117 | top: 16, 118 | bottom: 16, 119 | left: 24, 120 | right: 24, 121 | }, 122 | }, 123 | props: { 124 | text: 'Problems? Just reply to this email.', 125 | }, 126 | }, 127 | }, 128 | }; 129 | 130 | export default ONE_TIME_PASSCODE; 131 | -------------------------------------------------------------------------------- /packages/editor-sample/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | 4 | import { CssBaseline, ThemeProvider } from '@mui/material'; 5 | 6 | import App from './App'; 7 | import theme from './theme'; 8 | 9 | ReactDOM.createRoot(document.getElementById('root')!).render( 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /packages/editor-sample/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/editor-sample/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2015", 5 | "module": "esnext", 6 | "outDir": "dist" 7 | }, 8 | "exclude": ["dist"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/editor-sample/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | import react from '@vitejs/plugin-react-swc'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | base: '/email-builder-js/', 8 | }); 9 | -------------------------------------------------------------------------------- /packages/email-builder/.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .envrc 3 | .eslintignore 4 | .eslintrc.json 5 | .prettierrc 6 | jest.config.ts 7 | src 8 | tests 9 | tsconfig.json -------------------------------------------------------------------------------- /packages/email-builder/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Waypoint (Metaccountant, Inc.) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/email-builder/README.md: -------------------------------------------------------------------------------- 1 | # usewaypoint/email-builder 2 | -------------------------------------------------------------------------------- /packages/email-builder/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@usewaypoint/email-builder", 3 | "version": "0.0.8", 4 | "description": "React component to render email messages", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.mjs", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "require": "./dist/index.js", 11 | "import": "./dist/index.mjs", 12 | "types": "./dist/index.d.ts" 13 | } 14 | }, 15 | "files": [ 16 | "dist" 17 | ], 18 | "scripts": { 19 | "build": "npx tsc" 20 | }, 21 | "author": "carlos@usewaypoint.com", 22 | "license": "MIT", 23 | "peerDependencies": { 24 | "react": "^16 || ^17 || ^18", 25 | "react-dom": "^16 || ^17 || ^18", 26 | "zod": "^1 || ^2 || ^3" 27 | }, 28 | "dependencies": { 29 | "@usewaypoint/block-avatar": "^0.0.3", 30 | "@usewaypoint/block-button": "^0.0.3", 31 | "@usewaypoint/block-columns-container": "^0.0.3", 32 | "@usewaypoint/block-container": "^0.0.2", 33 | "@usewaypoint/block-divider": "^0.0.4", 34 | "@usewaypoint/block-heading": "^0.0.3", 35 | "@usewaypoint/block-html": "^0.0.3", 36 | "@usewaypoint/block-image": "^0.0.5", 37 | "@usewaypoint/block-spacer": "^0.0.3", 38 | "@usewaypoint/block-text": "^0.0.6", 39 | "@usewaypoint/document-core": "^0.0.6" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/email-builder/src/blocks/ColumnsContainer/ColumnsContainerPropsSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { ColumnsContainerPropsSchema as BaseColumnsContainerPropsSchema } from '@usewaypoint/block-columns-container'; 4 | 5 | const BasePropsShape = BaseColumnsContainerPropsSchema.shape.props.unwrap().unwrap().shape; 6 | 7 | const ColumnsContainerPropsSchema = z.object({ 8 | style: BaseColumnsContainerPropsSchema.shape.style, 9 | props: z 10 | .object({ 11 | ...BasePropsShape, 12 | columns: z.tuple([ 13 | z.object({ childrenIds: z.array(z.string()) }), 14 | z.object({ childrenIds: z.array(z.string()) }), 15 | z.object({ childrenIds: z.array(z.string()) }), 16 | ]), 17 | }) 18 | .optional() 19 | .nullable(), 20 | }); 21 | 22 | export default ColumnsContainerPropsSchema; 23 | export type ColumnsContainerProps = z.infer; 24 | -------------------------------------------------------------------------------- /packages/email-builder/src/blocks/ColumnsContainer/ColumnsContainerReader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ColumnsContainer as BaseColumnsContainer } from '@usewaypoint/block-columns-container'; 4 | 5 | import { ReaderBlock } from '../../Reader/core'; 6 | 7 | import { ColumnsContainerProps } from './ColumnsContainerPropsSchema'; 8 | 9 | export default function ColumnsContainerReader({ style, props }: ColumnsContainerProps) { 10 | const { columns, ...restProps } = props ?? {}; 11 | let cols = undefined; 12 | if (columns) { 13 | cols = columns.map((col) => col.childrenIds.map((childId) => )); 14 | } 15 | 16 | return ; 17 | } 18 | -------------------------------------------------------------------------------- /packages/email-builder/src/blocks/Container/ContainerPropsSchema.tsx: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { ContainerPropsSchema as BaseContainerPropsSchema } from '@usewaypoint/block-container'; 4 | 5 | export const ContainerPropsSchema = z.object({ 6 | style: BaseContainerPropsSchema.shape.style, 7 | props: z 8 | .object({ 9 | childrenIds: z.array(z.string()).optional().nullable(), 10 | }) 11 | .optional() 12 | .nullable(), 13 | }); 14 | 15 | export type ContainerProps = z.infer; 16 | -------------------------------------------------------------------------------- /packages/email-builder/src/blocks/Container/ContainerReader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Container as BaseContainer } from '@usewaypoint/block-container'; 4 | 5 | import { ReaderBlock } from '../../Reader/core'; 6 | 7 | import { ContainerProps } from './ContainerPropsSchema'; 8 | 9 | export default function ContainerReader({ style, props }: ContainerProps) { 10 | const childrenIds = props?.childrenIds ?? []; 11 | return ( 12 | 13 | {childrenIds.map((childId) => ( 14 | 15 | ))} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/email-builder/src/blocks/EmailLayout/EmailLayoutPropsSchema.tsx: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const COLOR_SCHEMA = z 4 | .string() 5 | .regex(/^#[0-9a-fA-F]{6}$/) 6 | .nullable() 7 | .optional(); 8 | 9 | const FONT_FAMILY_SCHEMA = z 10 | .enum([ 11 | 'MODERN_SANS', 12 | 'BOOK_SANS', 13 | 'ORGANIC_SANS', 14 | 'GEOMETRIC_SANS', 15 | 'HEAVY_SANS', 16 | 'ROUNDED_SANS', 17 | 'MODERN_SERIF', 18 | 'BOOK_SERIF', 19 | 'MONOSPACE', 20 | ]) 21 | .nullable() 22 | .optional(); 23 | 24 | export const EmailLayoutPropsSchema = z.object({ 25 | backdropColor: COLOR_SCHEMA, 26 | borderColor: COLOR_SCHEMA, 27 | borderRadius: z.number().optional().nullable(), 28 | canvasColor: COLOR_SCHEMA, 29 | textColor: COLOR_SCHEMA, 30 | fontFamily: FONT_FAMILY_SCHEMA, 31 | childrenIds: z.array(z.string()).optional().nullable(), 32 | }); 33 | 34 | export type EmailLayoutProps = z.infer; 35 | -------------------------------------------------------------------------------- /packages/email-builder/src/blocks/EmailLayout/EmailLayoutReader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ReaderBlock } from '../../Reader/core'; 4 | 5 | import { EmailLayoutProps } from './EmailLayoutPropsSchema'; 6 | 7 | function getFontFamily(fontFamily: EmailLayoutProps['fontFamily']) { 8 | const f = fontFamily ?? 'MODERN_SANS'; 9 | switch (f) { 10 | case 'MODERN_SANS': 11 | return '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif'; 12 | case 'BOOK_SANS': 13 | return 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif'; 14 | case 'ORGANIC_SANS': 15 | return 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif'; 16 | case 'GEOMETRIC_SANS': 17 | return 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif'; 18 | case 'HEAVY_SANS': 19 | return 'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif'; 20 | case 'ROUNDED_SANS': 21 | return 'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif'; 22 | case 'MODERN_SERIF': 23 | return 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif'; 24 | case 'BOOK_SERIF': 25 | return '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif'; 26 | case 'MONOSPACE': 27 | return '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace'; 28 | } 29 | } 30 | 31 | function getBorder({ borderColor }: EmailLayoutProps) { 32 | if (!borderColor) { 33 | return undefined; 34 | } 35 | return `1px solid ${borderColor}`; 36 | } 37 | 38 | export default function EmailLayoutReader(props: EmailLayoutProps) { 39 | const childrenIds = props.childrenIds ?? []; 40 | return ( 41 |
56 | 71 | 72 | 73 | 78 | 79 | 80 |
74 | {childrenIds.map((childId) => ( 75 | 76 | ))} 77 |
81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /packages/email-builder/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as renderToStaticMarkup } from './renderers/renderToStaticMarkup'; 2 | 3 | export { 4 | ReaderBlockSchema, 5 | TReaderBlock, 6 | // 7 | ReaderDocumentSchema, 8 | TReaderDocument, 9 | // 10 | ReaderBlock, 11 | TReaderBlockProps, 12 | // 13 | TReaderProps, 14 | default as Reader, 15 | } from './Reader/core'; 16 | -------------------------------------------------------------------------------- /packages/email-builder/src/renderers/renderToStaticMarkup.spec.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import renderToStaticMarkup from './renderToStaticMarkup'; 6 | 7 | describe('renderToStaticMarkup', () => { 8 | it('renders into a string', () => { 9 | const result = renderToStaticMarkup( 10 | { 11 | root: { 12 | type: 'Container', 13 | data: { 14 | props: { 15 | childrenIds: [], 16 | }, 17 | }, 18 | }, 19 | }, 20 | { rootBlockId: 'root' } 21 | ); 22 | expect(result).toEqual('
'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/email-builder/src/renderers/renderToStaticMarkup.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { renderToStaticMarkup as baseRenderToStaticMarkup } from 'react-dom/server'; 3 | 4 | import Reader, { TReaderDocument } from '../Reader/core'; 5 | 6 | type TOptions = { 7 | rootBlockId: string; 8 | }; 9 | export default function renderToStaticMarkup(document: TReaderDocument, { rootBlockId }: TOptions) { 10 | return ( 11 | '' + 12 | baseRenderToStaticMarkup( 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /packages/email-builder/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["tests/**/*.spec.ts", "tests/**/*.spec.tsx", "jest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/email-builder/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2015", 5 | "module": "esnext", 6 | "outDir": "dist" 7 | }, 8 | "exclude": ["dist"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "esnext", 5 | "lib": [], 6 | "moduleResolution": "node", 7 | "jsx": "react", 8 | "strict": true, 9 | "sourceMap": true, 10 | "allowJs": true, 11 | "esModuleInterop": true, 12 | 13 | "skipLibCheck": true, 14 | "declarationMap": true, 15 | "declaration": true, 16 | "noUnusedLocals": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "allowSyntheticDefaultImports": true 20 | }, 21 | "exclude": ["dist"] 22 | } 23 | --------------------------------------------------------------------------------