├── example ├── cms │ ├── .npmrc │ ├── nodemon.json │ ├── src │ │ ├── blocks │ │ │ ├── TestBlock2.ts │ │ │ ├── TestBlock1.ts │ │ │ └── ComplexBlock.ts │ │ ├── collections │ │ │ ├── Users.ts │ │ │ ├── Tags.ts │ │ │ ├── Categories.ts │ │ │ ├── Media.ts │ │ │ └── Posts.ts │ │ ├── server.ts │ │ ├── payload.config.ts │ │ ├── globals │ │ │ └── kitchenSink.ts │ │ └── types │ │ │ └── payload-types.ts │ ├── tsconfig.json │ ├── package.json │ └── .gitignore └── website │ ├── .gitignore │ ├── tsconfig.json │ ├── public │ └── index.html │ ├── esbuild.mjs │ ├── package.json │ ├── src │ ├── payload-types.ts │ └── index.ts │ └── yarn.lock ├── .gitignore ├── src ├── types │ ├── previewMode.ts │ ├── previewUrl.ts │ ├── globalWithFallbackConfig.ts │ └── collectionWithFallbackConfig.ts ├── components │ ├── preview │ │ ├── index.tsx │ │ ├── hooks │ │ │ ├── useFields.ts │ │ │ ├── useOnFieldChanges.ts │ │ │ ├── useOnPreviewMessage.ts │ │ │ └── usePreview.ts │ │ ├── iframe │ │ │ ├── useResizeObserver.ts │ │ │ └── index.tsx │ │ └── popup │ │ │ └── index.tsx │ ├── icons │ │ ├── SideBySide │ │ │ └── index.tsx │ │ └── NewWindow │ │ │ └── index.tsx │ ├── visualEditorView │ │ ├── usePersistentState.ts │ │ └── index.tsx │ └── dropdown │ │ └── index.tsx ├── index.ts ├── styles.scss └── utils │ └── generateDocument.ts ├── visual-editor-screenshot.png ├── Dockerfile ├── tsconfig.json ├── docker-compose.yml ├── LICENSE ├── package.json ├── CHANGELOG.md └── README.md /example/cms/.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /example/website/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.js.map 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /src/types/previewMode.ts: -------------------------------------------------------------------------------- 1 | export type PreviewMode = "iframe" | "popup" | "none"; 2 | -------------------------------------------------------------------------------- /example/cms/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ext": "ts", 3 | "exec": "ts-node src/server.ts" 4 | } 5 | -------------------------------------------------------------------------------- /src/components/preview/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./iframe"; 2 | export * from "./popup"; 3 | -------------------------------------------------------------------------------- /visual-editor-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pemedia/payload-visual-editor/HEAD/visual-editor-screenshot.png -------------------------------------------------------------------------------- /src/types/previewUrl.ts: -------------------------------------------------------------------------------- 1 | export interface PreviewUrlParams { 2 | locale: string; 3 | } 4 | 5 | export type PreviewUrlFn = (params: PreviewUrlParams) => string; 6 | -------------------------------------------------------------------------------- /src/types/globalWithFallbackConfig.ts: -------------------------------------------------------------------------------- 1 | import { GlobalConfig } from "payload/types"; 2 | 3 | export type GlobalWithFallbackConfig = GlobalConfig & { custom: { fallback: T; } }; 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | WORKDIR /usr/src/app/ 4 | 5 | # RUN apk add tini 6 | RUN apk add git 7 | 8 | # ENTRYPOINT ["/sbin/tini", "--"] 9 | 10 | CMD ["sleep", "infinity"] 11 | -------------------------------------------------------------------------------- /src/types/collectionWithFallbackConfig.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from "payload/types"; 2 | 3 | export type CollectionWithFallbackConfig = CollectionConfig & { custom: { fallback: T; }; }; 4 | -------------------------------------------------------------------------------- /example/website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "outDir": "./dist", 5 | "module": "esnext", 6 | "strict": true, 7 | }, 8 | "include": [ 9 | "src/**/*" 10 | ], 11 | } 12 | -------------------------------------------------------------------------------- /example/cms/src/blocks/TestBlock2.ts: -------------------------------------------------------------------------------- 1 | import { Block } from "payload/types"; 2 | 3 | export const TestBlock2: Block = { 4 | slug: "testBlock2", 5 | fields: [ 6 | { name: "number1", type: "number" }, 7 | { name: "number2", type: "number" }, 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /example/cms/src/blocks/TestBlock1.ts: -------------------------------------------------------------------------------- 1 | import { Block } from "payload/types"; 2 | 3 | export const TestBlock1: Block = { 4 | slug: "testBlock1", 5 | fields: [ 6 | { name: "text1", type: "text", required: true }, 7 | { name: "text2", type: "text", required: true }, 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /example/cms/src/collections/Users.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from "payload/types"; 2 | 3 | export const Users: CollectionConfig = { 4 | slug: "users", 5 | auth: true, 6 | admin: { 7 | useAsTitle: "email", 8 | }, 9 | access: { 10 | read: () => true, 11 | }, 12 | fields: [], 13 | }; 14 | -------------------------------------------------------------------------------- /example/website/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |

Payload Live Preview Test

12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "outDir": "./dist", 5 | "module": "commonjs", 6 | "sourceMap": true, 7 | "jsx": "react", 8 | "esModuleInterop": true, 9 | "declaration": true, 10 | "declarationDir": "./dist", 11 | "strict": true, 12 | "skipLibCheck": true 13 | }, 14 | "include": [ 15 | "src/**/*" 16 | ], 17 | "exclude": [ 18 | "node_modules", 19 | "dist" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/components/icons/SideBySide/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const SideBySide = () => ( 4 | 5 | 6 | 7 | ); 8 | 9 | export default SideBySide; 10 | -------------------------------------------------------------------------------- /src/components/preview/hooks/useFields.ts: -------------------------------------------------------------------------------- 1 | import { useAllFormFields } from "payload/components/forms"; 2 | import { useEffect, useRef } from "react"; 3 | 4 | export const useFields = () => { 5 | const [fields] = useAllFormFields(); 6 | const ref = useRef(fields); 7 | 8 | useEffect(() => { 9 | ref.current = fields; 10 | }, [fields]); 11 | 12 | return ref; 13 | }; 14 | 15 | // TODO: use useEffectEvent when api is stable (https://react.dev/reference/react/experimental_useEffectEvent) 16 | -------------------------------------------------------------------------------- /example/website/esbuild.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import { logger } from "esbuild-plugin-collection"; 3 | 4 | const ctx = await esbuild.context({ 5 | entryPoints: ["dist/index.js"], 6 | outfile: "public/bundle.js", 7 | bundle: true, 8 | sourcemap: true, 9 | plugins: [ 10 | logger(), 11 | ], 12 | }); 13 | 14 | await ctx.watch(); 15 | 16 | let { host, port } = await ctx.serve({ 17 | port: 8080, 18 | servedir: "./public", 19 | }) 20 | 21 | console.log(`Serve app on ${host}:${port}`); 22 | -------------------------------------------------------------------------------- /src/components/preview/iframe/useResizeObserver.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from "react"; 2 | 3 | export const useResizeObserver = (targetRef: RefObject, cb: ResizeObserverCallback) => { 4 | useEffect(() => { 5 | const resizeObserver = new ResizeObserver(cb); 6 | 7 | setTimeout(() => { 8 | resizeObserver.observe(targetRef.current!); 9 | }, 0); 10 | 11 | return () => { 12 | resizeObserver.disconnect(); 13 | }; 14 | }, []); 15 | }; 16 | -------------------------------------------------------------------------------- /example/cms/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "allowJs": true, 5 | "strict": false, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "outDir": "./dist", 9 | "rootDir": "./src", 10 | "jsx": "react", 11 | "moduleResolution": "node" 12 | }, 13 | "include": [ 14 | "../../src" 15 | ], 16 | "exclude": [ 17 | "node_modules", 18 | "dist", 19 | "build", 20 | ], 21 | "ts-node": { 22 | "transpileOnly": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/icons/NewWindow/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const NewWindow = () => ( 4 | 5 | 6 | 7 | ); 8 | 9 | export default NewWindow; 10 | -------------------------------------------------------------------------------- /example/website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "build-watch": "tsc -w", 8 | "bundle-and-serve": "node esbuild.mjs", 9 | "dev": "concurrently --kill-others yarn:build-watch yarn:bundle-and-serve" 10 | }, 11 | "devDependencies": { 12 | "concurrently": "^8.2.2", 13 | "esbuild": "^0.19.5", 14 | "esbuild-plugin-collection": "dkirchhof/esbuild-plugin-collection", 15 | "typescript": "^5.2.2" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/preview/hooks/useOnFieldChanges.ts: -------------------------------------------------------------------------------- 1 | import { Fields } from "payload/types"; 2 | import { useEffect, useRef } from "react"; 3 | 4 | export const useOnFieldChanges = (fields: Fields, callback: () => any) => { 5 | const debounce = useRef(false); 6 | 7 | useEffect(() => { 8 | if (debounce.current) { 9 | return; 10 | } 11 | 12 | debounce.current = true; 13 | 14 | setTimeout(() => { 15 | callback(); 16 | 17 | debounce.current = false; 18 | }, 100); 19 | }, [fields]); 20 | }; 21 | -------------------------------------------------------------------------------- /example/cms/src/server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import payload from "payload"; 3 | 4 | require("dotenv").config(); 5 | const app = express(); 6 | 7 | // Redirect root to Admin panel 8 | app.get("/", (_, res) => { 9 | res.redirect("/admin"); 10 | }); 11 | 12 | // Initialize Payload 13 | payload.init({ 14 | secret: process.env.PAYLOAD_SECRET || 'SECRET', 15 | express: app, 16 | onInit: () => { 17 | payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`) 18 | }, 19 | }) 20 | 21 | // Add your own express routes here 22 | 23 | app.listen(3000); 24 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | node: 5 | build: . 6 | init: true 7 | ports: 8 | - "3000:3000" 9 | - "8080:8080" 10 | volumes: 11 | - ./:/usr/src/app 12 | depends_on: 13 | - mongo 14 | environment: 15 | MONGODB_URI: mongodb://mongo:27017/payload 16 | PAYLOAD_SECRET: SECRET 17 | 18 | mongo: 19 | image: mongo:latest 20 | ports: 21 | - "27017:27017" 22 | command: 23 | - --storageEngine=wiredTiger 24 | volumes: 25 | - data:/data/db 26 | logging: 27 | driver: none 28 | 29 | volumes: 30 | data: 31 | -------------------------------------------------------------------------------- /example/cms/src/collections/Tags.ts: -------------------------------------------------------------------------------- 1 | import { CollectionWithFallbackConfig } from "../../../../src"; 2 | import { Tag } from "../types/payload-types"; 3 | 4 | export const Tags: CollectionWithFallbackConfig = { 5 | slug: "tags", 6 | admin: { 7 | useAsTitle: "name", 8 | }, 9 | fields: [ 10 | { 11 | name: "name", 12 | type: "text", 13 | required: true, 14 | }, 15 | ], 16 | custom: { 17 | fallback: { 18 | id: "", 19 | name: "Fallback Tag", 20 | createdAt: "", 21 | updatedAt: "", 22 | }, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/preview/hooks/useOnPreviewMessage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | type Message = "ready" 4 | 5 | export const useOnPreviewMessage = (previewUrl: string, callback: (message: Message) => any) => { 6 | useEffect(() => { 7 | const listener = (e: MessageEvent) => { 8 | const url = new URL(previewUrl); 9 | 10 | if (e.origin === url.origin) { 11 | callback(e.data); 12 | } 13 | }; 14 | 15 | window.addEventListener("message", listener); 16 | 17 | return () => { 18 | window.removeEventListener("message", listener); 19 | }; 20 | }, []); 21 | }; 22 | -------------------------------------------------------------------------------- /example/cms/src/collections/Categories.ts: -------------------------------------------------------------------------------- 1 | import { CollectionWithFallbackConfig } from "../../../../src"; 2 | import { Category } from "../types/payload-types"; 3 | 4 | export const Categories: CollectionWithFallbackConfig = { 5 | slug: "categories", 6 | admin: { 7 | useAsTitle: "name", 8 | }, 9 | fields: [ 10 | { 11 | name: "name", 12 | type: "text", 13 | required: true, 14 | }, 15 | ], 16 | custom: { 17 | fallback: { 18 | id: "", 19 | name: "Fallback Category", 20 | createdAt: "", 21 | updatedAt: "", 22 | }, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /example/cms/src/blocks/ComplexBlock.ts: -------------------------------------------------------------------------------- 1 | import { Block } from "payload/types"; 2 | import { Media } from "../collections/Media"; 3 | 4 | export const ComplexBlock: Block = { 5 | slug: "complexBlock", 6 | fields: [ 7 | { 8 | name: "textPosition", 9 | type: "select", 10 | required: true, 11 | options: [ 12 | { label: "left", value: "left" }, 13 | { label: "right", value: "right" }, 14 | ], 15 | }, 16 | { 17 | name: "text", 18 | type: "richText", 19 | required: true, 20 | }, 21 | { 22 | name: "medium", 23 | type: "upload", 24 | relationTo: Media.slug, 25 | required: true, 26 | }, 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/visualEditorView/usePersistentState.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | 3 | const getItem = (key: string) => { 4 | const item = localStorage.getItem(key); 5 | 6 | if (!item) { 7 | return null; 8 | } 9 | 10 | try { 11 | return JSON.parse(item); 12 | } catch (e) { 13 | return null; 14 | } 15 | }; 16 | 17 | const setItem = (key: string, value: any) => { 18 | localStorage.setItem(key, JSON.stringify(value)); 19 | }; 20 | 21 | export const usePersistentState = (key: string, defaultValue: T) => { 22 | const [state, setState_] = useState(getItem(key) || defaultValue); 23 | 24 | const setState = (value: T) => { 25 | setItem(key, value); 26 | setState_(value); 27 | }; 28 | 29 | return [state, setState] as readonly [T, (value: T) => void]; 30 | }; 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 pemedia GmbH 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 | -------------------------------------------------------------------------------- /src/components/preview/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { PreviewMode } from "../../../types/previewMode"; 3 | import { PreviewUrlFn } from "../../../types/previewUrl"; 4 | import { usePreview } from "../hooks/usePreview"; 5 | 6 | interface Props { 7 | previewUrlFn: PreviewUrlFn; 8 | 9 | setPreviewMode: (mode: PreviewMode) => void; 10 | } 11 | 12 | export const PopupPreview = (props: Props) => { 13 | const previewWindow = useRef(null); 14 | 15 | const previewUrl = usePreview(props.previewUrlFn, previewWindow); 16 | 17 | useEffect(() => { 18 | previewWindow.current = open(previewUrl, "preview", "popup"); 19 | 20 | let timer: any; 21 | 22 | if (previewWindow.current) { 23 | timer = setInterval(() => { 24 | if (previewWindow.current!.closed) { 25 | clearInterval(timer); 26 | 27 | props.setPreviewMode("none"); 28 | 29 | previewWindow.current = null; 30 | } 31 | }, 1000); 32 | } 33 | 34 | return () => { 35 | if (timer) { 36 | clearInterval(timer); 37 | } 38 | 39 | if (previewWindow.current) { 40 | previewWindow.current.close(); 41 | } 42 | }; 43 | }, []); 44 | 45 | return null; 46 | }; 47 | -------------------------------------------------------------------------------- /example/cms/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-starter-typescript", 3 | "description": "Blank template - no collections", 4 | "version": "1.0.0", 5 | "main": "dist/server.js", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon", 9 | "build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build", 10 | "build:server": "tsc", 11 | "build": "yarn copyfiles && yarn build:payload && yarn build:server", 12 | "serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js", 13 | "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/", 14 | "generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts ../../node_modules/.bin/payload generate:types", 15 | "generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema" 16 | }, 17 | "dependencies": { 18 | "@payloadcms/bundler-webpack": "^1.0.5", 19 | "@payloadcms/db-mongodb": "^1.0.5", 20 | "@payloadcms/richtext-lexical": "^0.1.16", 21 | "@payloadcms/richtext-slate": "^1.0.0", 22 | "dotenv": "^16.3.1", 23 | "express": "^4.17.1" 24 | }, 25 | "devDependencies": { 26 | "@types/express": "^4.17.20", 27 | "copyfiles": "^2.4.1", 28 | "cross-env": "^7.0.3", 29 | "nodemon": "^3.0.1", 30 | "ts-node": "^10.9.1", 31 | "typescript": "^5.2.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/dropdown/index.tsx: -------------------------------------------------------------------------------- 1 | import Chevron from "payload/dist/admin/components/icons/Chevron"; 2 | import React, { useState } from "react"; 3 | 4 | interface Props { 5 | triggerText: string; 6 | items: { label: string; action: () => void; }[]; 7 | className?: string; 8 | } 9 | 10 | export const Dropdown = (props: Props) => { 11 | const [expanded, setExpanded] = useState(false); 12 | 13 | return ( 14 |
15 | {expanded &&
setExpanded(false)} />} 16 | 17 | 21 | 22 |
23 |
    24 | {props.items.map((item, index) => ( 25 |
  • 26 | 32 |
  • 33 | ))} 34 |
35 |
36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /example/cms/src/collections/Media.ts: -------------------------------------------------------------------------------- 1 | import { CollectionWithFallbackConfig } from "../../../../src"; 2 | import { Medium } from "../types/payload-types"; 3 | 4 | export const Media: CollectionWithFallbackConfig = { 5 | slug: "media", 6 | typescript: { 7 | interface: "Medium", 8 | }, 9 | fields: [], 10 | upload: { 11 | staticDir: "media", 12 | // adminThumbnail: "thumbnail", 13 | // imageSizes: [ 14 | // { 15 | // height: 200, 16 | // width: 200, 17 | // crop: "center", 18 | // name: "thumbnail", 19 | // formatOptions: { 20 | // format: "webp", 21 | // }, 22 | // }, 23 | // { 24 | // width: 540, 25 | // height: 960, 26 | // crop: "center", 27 | // name: "mobile", 28 | // formatOptions: { 29 | // format: "webp", 30 | // }, 31 | // }, 32 | // { 33 | // width: 1707, 34 | // height: 960, 35 | // crop: "center", 36 | // name: "desktop", 37 | // formatOptions: { 38 | // format: "webp", 39 | // }, 40 | // }, 41 | // ], 42 | }, 43 | custom: { 44 | fallback: { 45 | id: "", 46 | createdAt: "", 47 | updatedAt: "", 48 | mimeType: "image/jpeg", 49 | url: "https://fastly.picsum.photos/id/1038/200/200.jpg?hmac=H5HUzcu1mnVoapNKQB4L0bitWDrUhwiYuke8QItf9ng", 50 | filename: "lorem picsum", 51 | }, 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-visual-editor", 3 | "version": "2.0.6", 4 | "description": "Payload CMS plugin which provides a visual live editor directly in the Admin UI.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "repository": "git@github.com:pemedia/payload-visual-editor.git", 8 | "author": "info@pemedia.de", 9 | "license": "MIT", 10 | "scripts": { 11 | "build": "mkdir -p dist && cp src/styles.scss dist/styles.scss && yarn tsc", 12 | "build-watch": "mkdir -p dist && cp src/styles.scss dist/styles.scss && yarn tsc -w", 13 | "docker:shell": "docker exec -i -t payload-visual-editor-node-1 /bin/sh", 14 | "docker:mongo": "docker exec -i -t payload-visual-editor-mongo-1 mongosh", 15 | "docker:plugin:yarn": "docker exec -t payload-visual-editor-node-1 /bin/sh -c 'yarn'", 16 | "docker:plugin:build": "docker exec -t payload-visual-editor-node-1 /bin/sh -c 'mkdir -p dist && cp src/styles.scss dist/styles.scss && yarn tsc'", 17 | "docker:plugin:build-watch": "docker exec -t payload-visual-editor-node-1 /bin/sh -c 'mkdir -p dist && cp src/styles.scss dist/styles.scss && yarn tsc -w'", 18 | "docker:example:cms:yarn": "docker exec -t payload-visual-editor-node-1 /bin/sh -c 'cd example/cms && yarn'", 19 | "docker:example:cms:dev": "docker exec -t payload-visual-editor-node-1 /bin/sh -c 'cd example/cms && yarn dev'", 20 | "docker:example:cms:generate-types": "docker exec -t payload-visual-editor-node-1 /bin/sh -c 'cd example/cms && yarn generate:types && cp src/types/payload-types.ts ../website/src/payload-types.ts'", 21 | "docker:example:website:yarn": "docker exec -t payload-visual-editor-node-1 /bin/sh -c 'cd example/website && yarn'", 22 | "docker:example:website:dev": "docker exec -t payload-visual-editor-node-1 /bin/sh -c 'cd example/website && yarn dev'", 23 | "prepublishOnly": "yarn build" 24 | }, 25 | "files": [ 26 | "dist", 27 | "LICENSE.txt" 28 | ], 29 | "keywords": [ 30 | "payload", 31 | "payload-plugin", 32 | "cms", 33 | "plugin", 34 | "typescript", 35 | "react" 36 | ], 37 | "peerDependencies": { 38 | "payload": "^2.0.13", 39 | "react": "^18.2.0", 40 | "react-i18next": "^11.18.0" 41 | }, 42 | "devDependencies": { 43 | "@types/react": "^18.2.34", 44 | "payload": "^2.0.14", 45 | "react": "^18.2.0", 46 | "typescript": "^5.2.2" 47 | }, 48 | "dependencies": { 49 | "ts-pattern": "^5.0.5" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.0.6 2 | 3 | ### Bug Fixes 4 | - Fixed handling of `undefined` non required upload fields 5 | 6 | ## 2.0.5 7 | 8 | ### Bug Fixes 9 | - Fixed handling of `undefined` richText fields 10 | 11 | ## 2.0.4 12 | 13 | ### Bug Fixes 14 | - Fixed bundling styles with vite 15 | 16 | ## 2.0.3 17 | 18 | ### Bug Fixes 19 | - Fixed parsing presentational fields in block. 20 | 21 | ## 2.0.2 22 | 23 | ## 2.0.1 24 | 25 | ## 2.0.0 26 | 27 | ### Features 28 | - Plugin is now available for Payload 2.x.x 29 | - The preview can now be opened in a separate window and in an iframe. 30 | 31 | ### Breaking Changes 32 | - Changed `options.showPreview` to `options.defaultPreviewMode`. 33 | - To fetch the current document state on first render, you have to send a ready event to the cms (s. [Frontend Integration in React / Next.js](https://github.com/pemedia/payload-visual-editor#frontend-integration-in-react--nextjs)). 34 | 35 | ## 0.1.4 36 | 37 | ### Bug Fixes 38 | - Fixed parsing block fields while values are undefined (#20) 39 | 40 | ## 0.1.3 41 | 42 | ### Bug Fixes 43 | - Passing API path set in config to fetch relations (#18) 44 | 45 | ## 0.1.2 46 | 47 | ### Bug Fixes 48 | - Added missing react-i18next as peer dependency (#17) 49 | 50 | ## 0.1.1 51 | 52 | ### Features 53 | - Configuration option to hide preview by default (#11) 54 | - New logic to generate the preview data, now handling all Payload field types 55 | 56 | ### Bug Fixes 57 | - Fixed layout for collections and globals using versioning and drafts (#14) 58 | - Showing versions and timestamps in sidebar, when versioning is enabled 59 | - Correct API url and link for globals in the sidebar area (#13) 60 | 61 | ## 0.1.0 62 | 63 | ### Features 64 | - 'previewUrl' localization: Added the option to create a custom locale based previewUrl. 65 | - fields from the `{admin: {position:'sidebar'}}` will be placed in an extra tab called "More", if tabs are beeing used in a collection or global. 66 | - Little styling adjustments to make the preview frame visually more present. 67 | 68 | ### Breaking Changes 69 | - The `previewUrl` option is now a string returning function instead of a string. 70 | 71 | ## 0.0.5 72 | 73 | ### Features 74 | - fields from the `{admin: {position:'sidebar'}}` are now useable. (in the main fields area) 75 | 76 | ## 0.0.4 77 | 78 | ### Bug Fixes 79 | - Get all fields recursively to allow nested presentational fields. 80 | 81 | ## 0.0.3 82 | 83 | ### Bug Fixes 84 | - Flatten all presenational only fields. 85 | -------------------------------------------------------------------------------- /example/cms/src/payload.config.ts: -------------------------------------------------------------------------------- 1 | import { webpackBundler } from "@payloadcms/bundler-webpack"; 2 | import { mongooseAdapter } from "@payloadcms/db-mongodb"; 3 | import { lexicalEditor } from "@payloadcms/richtext-lexical"; 4 | import { slateEditor } from '@payloadcms/richtext-slate'; 5 | import path from "path"; 6 | import { buildConfig } from "payload/config"; 7 | import { visualEditor } from "../../../src"; 8 | import "../../../src/styles.scss"; 9 | import { Categories } from "./collections/Categories"; 10 | import { Media } from "./collections/Media"; 11 | import { Posts } from "./collections/Posts"; 12 | import { Tags } from "./collections/Tags"; 13 | import { Users } from "./collections/Users"; 14 | import { KitchenSink } from "./globals/kitchenSink"; 15 | 16 | export default buildConfig({ 17 | serverURL: "http://localhost:3000", 18 | admin: { 19 | user: Users.slug, 20 | bundler: webpackBundler(), 21 | webpack: config => ({ 22 | ...config, 23 | resolve: { 24 | ...config.resolve, 25 | alias: { 26 | ...config.resolve?.alias, 27 | "react": path.join(__dirname, "../../../node_modules/react"), 28 | "react-dom": path.join(__dirname, "../../../node_modules/react-dom"), 29 | "payload": path.join(__dirname, "../../../node_modules/payload"), 30 | }, 31 | }, 32 | }), 33 | }, 34 | collections: [ 35 | Users, 36 | Posts, 37 | Tags, 38 | Categories, 39 | Media, 40 | ], 41 | globals: [ 42 | KitchenSink, 43 | ], 44 | localization: { 45 | locales: [ 46 | 'en', 47 | 'de', 48 | ], 49 | defaultLocale: 'de', 50 | fallback: true, 51 | }, 52 | db: mongooseAdapter({ 53 | url: process.env.MONGODB_URI!, 54 | }), 55 | editor: slateEditor({}), 56 | typescript: { 57 | outputFile: path.resolve(__dirname, "types/payload-types.ts"), 58 | }, 59 | graphQL: { 60 | schemaOutputFile: path.resolve(__dirname, "generated-schema.graphql"), 61 | }, 62 | plugins: [ 63 | visualEditor({ 64 | previewUrl: () => `http://localhost:8080/`, 65 | previewWidthInPercentage: 50, 66 | collections: { 67 | [Posts.slug]: {}, 68 | }, 69 | globals: { 70 | [KitchenSink.slug]: {}, 71 | }, 72 | }), 73 | ], 74 | }); 75 | -------------------------------------------------------------------------------- /src/components/preview/hooks/usePreview.ts: -------------------------------------------------------------------------------- 1 | import { useConfig, useDocumentInfo, useLocale } from "payload/components/utilities"; 2 | import { ContextType } from "payload/dist/admin/components/utilities/DocumentInfo/types"; 3 | import { Fields } from "payload/types"; 4 | import { RefObject } from "react"; 5 | import { match } from "ts-pattern"; 6 | import { PreviewUrlFn } from "../../../types/previewUrl"; 7 | import { GenDocConfig, generateDocument } from "../../../utils/generateDocument"; 8 | import { useFields } from "./useFields"; 9 | import { useOnFieldChanges } from "./useOnFieldChanges"; 10 | import { useOnPreviewMessage } from "./useOnPreviewMessage"; 11 | 12 | const getFieldConfigs = (documentInfo: ContextType) => { 13 | return documentInfo.collection?.fields ?? documentInfo.global?.fields ?? []; 14 | }; 15 | 16 | const updatePreview = async (genDocConfig: GenDocConfig, fields: Fields, windowRef: RefObject) => { 17 | try { 18 | const doc = await generateDocument(genDocConfig, fields); 19 | 20 | if (windowRef.current instanceof HTMLIFrameElement) { 21 | windowRef.current.contentWindow?.postMessage({ cmsLivePreviewData: doc }, "*"); 22 | } else { 23 | windowRef.current?.postMessage({ cmsLivePreviewData: doc }, "*"); 24 | } 25 | } catch (e) { 26 | console.error(e); 27 | } 28 | }; 29 | 30 | export const usePreview = (previewUrlFn: PreviewUrlFn, windowRef: RefObject) => { 31 | const payloadConfig = useConfig(); 32 | const documentInfo = useDocumentInfo(); 33 | const fieldConfigs = getFieldConfigs(documentInfo); 34 | const fields = useFields(); 35 | 36 | const genDocConfig: GenDocConfig = { 37 | collections: payloadConfig.collections, 38 | globals: payloadConfig.globals, 39 | fieldConfigs, 40 | serverUrl: payloadConfig.serverURL, 41 | apiPath: payloadConfig.routes.api, 42 | }; 43 | 44 | const locale = useLocale(); 45 | 46 | const previewUrl = payloadConfig.localization 47 | ? previewUrlFn({ locale: locale.code }) 48 | : previewUrlFn({ locale: "" }); 49 | 50 | useOnPreviewMessage(previewUrl, message => { 51 | match(message) 52 | .with("ready", () => updatePreview(genDocConfig, fields.current, windowRef)) 53 | .exhaustive(); 54 | }); 55 | 56 | useOnFieldChanges(fields.current, () => { 57 | updatePreview(genDocConfig, fields.current, windowRef); 58 | }); 59 | 60 | return previewUrl; 61 | } 62 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "payload/config"; 2 | import { CollectionConfig, GlobalConfig } from "payload/types"; 3 | import { createVisualEditorView } from "./components/visualEditorView"; 4 | import { PreviewMode } from "./types/previewMode"; 5 | import { PreviewUrlFn } from "./types/previewUrl"; 6 | 7 | export * from "./types/collectionWithFallbackConfig"; 8 | export * from "./types/globalWithFallbackConfig"; 9 | 10 | type CollectionOrGlobalConfig = CollectionConfig | GlobalConfig; 11 | 12 | interface PluginCollectionOrGlobalConfig { 13 | previewUrl?: PreviewUrlFn; 14 | } 15 | 16 | export interface PluginConfig { 17 | previewUrl: PreviewUrlFn; 18 | defaultPreviewMode?: PreviewMode; 19 | previewWidthInPercentage?: number; 20 | collections?: Record; 21 | globals?: Record; 22 | } 23 | 24 | const extendCogConfigs = ( 25 | previewUrl: PreviewUrlFn, 26 | defaultPreviewMode?: PreviewMode, 27 | previewWidthInPercentage?: number, 28 | cogConfigs?: T[], 29 | pluginCogConfigs?: Record, 30 | ) => cogConfigs?.map(cogConfig => { 31 | const pluginCogConfig = pluginCogConfigs?.[cogConfig.slug]; 32 | 33 | if (pluginCogConfig) { 34 | cogConfig.admin = { 35 | ...cogConfig.admin, 36 | components: { 37 | ...cogConfig.admin?.components, 38 | views: { 39 | ...cogConfig.admin?.components?.views, 40 | Edit: { 41 | ...cogConfig.admin?.components?.views?.Edit, 42 | Default: createVisualEditorView({ 43 | previewUrl: pluginCogConfig.previewUrl ?? previewUrl, 44 | defaultPreviewMode: defaultPreviewMode ?? "iframe", 45 | previewWidthInPercentage: previewWidthInPercentage ?? 50, 46 | }), 47 | } as any, 48 | }, 49 | }, 50 | }; 51 | } 52 | 53 | return cogConfig 54 | }) ?? []; 55 | 56 | export const visualEditor = (pluginConfig: PluginConfig) => (config: Config): Config => ({ 57 | ...config, 58 | collections: extendCogConfigs( 59 | pluginConfig.previewUrl, 60 | pluginConfig.defaultPreviewMode, 61 | pluginConfig.previewWidthInPercentage, 62 | config.collections, 63 | pluginConfig.collections, 64 | ), 65 | globals: extendCogConfigs( 66 | pluginConfig.previewUrl, 67 | pluginConfig.defaultPreviewMode, 68 | pluginConfig.previewWidthInPercentage, 69 | config.globals, 70 | pluginConfig.globals, 71 | ), 72 | }); 73 | -------------------------------------------------------------------------------- /example/cms/src/collections/Posts.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from "payload/types"; 2 | import { Categories } from "./Categories"; 3 | import { Tags } from "./Tags"; 4 | import { slateEditor } from "@payloadcms/richtext-slate"; 5 | 6 | export const Posts: CollectionConfig = { 7 | slug: "posts", 8 | admin: { 9 | useAsTitle: "title", 10 | }, 11 | versions: { 12 | drafts: true, 13 | }, 14 | fields: [ 15 | { 16 | type: "tabs", 17 | tabs: [ 18 | { 19 | label: "General", 20 | fields: [ 21 | { 22 | name: "title", 23 | type: "text", 24 | required: true, 25 | }, 26 | { 27 | name: "subtitle", 28 | type: "text", 29 | required: true, 30 | }, 31 | { 32 | name: "category", 33 | type: "relationship", 34 | relationTo: Categories.slug, 35 | hasMany: false, 36 | }, 37 | { 38 | name: "tagsAndCategories", 39 | type: "relationship", 40 | relationTo: [Tags.slug, Categories.slug], 41 | hasMany: true, 42 | }, 43 | { 44 | name: 'status', 45 | type: 'select', 46 | options: [ 47 | { 48 | value: 'draft', 49 | label: 'Draft', 50 | }, 51 | { 52 | value: 'published', 53 | label: 'Published', 54 | }, 55 | ], 56 | defaultValue: 'draft', 57 | }, 58 | { 59 | name: "checkParagraph", 60 | type: "checkbox", 61 | defaultValue: false, 62 | }, 63 | { 64 | name: 'paragraph', 65 | type: 'richText', 66 | required: true, 67 | editor: slateEditor({ 68 | admin: { 69 | elements: ['h2', 'h3'], 70 | leaves: ["italic", "underline", "bold"], 71 | } 72 | }), 73 | admin: { 74 | condition: (data, siblingData, { user }) => siblingData.checkParagraph, 75 | }, 76 | }, 77 | ], 78 | }, 79 | ], 80 | }, 81 | { 82 | name: "description", 83 | type: "text", 84 | admin: { 85 | position: "sidebar", 86 | }, 87 | }, 88 | ], 89 | }; 90 | -------------------------------------------------------------------------------- /example/cms/.gitignore: -------------------------------------------------------------------------------- 1 | ### Node ### 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # Snowpack dependency directory (https://snowpack.dev/) 47 | web_modules/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional stylelint cache 59 | .stylelintcache 60 | 61 | # Microbundle cache 62 | .rpt2_cache/ 63 | .rts2_cache_cjs/ 64 | .rts2_cache_es/ 65 | .rts2_cache_umd/ 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variable files 77 | .env 78 | .env.development.local 79 | .env.test.local 80 | .env.production.local 81 | .env.local 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | .parcel-cache 86 | 87 | # Next.js build output 88 | .next 89 | out 90 | 91 | # Nuxt.js build / generate output 92 | .nuxt 93 | dist 94 | 95 | # Gatsby files 96 | .cache/ 97 | # Comment in the public line in if your project uses Gatsby and not Next.js 98 | # https://nextjs.org/blog/next-9-1#public-directory-support 99 | # public 100 | 101 | # vuepress build output 102 | .vuepress/dist 103 | 104 | # vuepress v2.x temp and cache directory 105 | .temp 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | ### Node Patch ### 133 | # Serverless Webpack directories 134 | .webpack/ 135 | 136 | # Optional stylelint cache 137 | 138 | # SvelteKit build / generate output 139 | .svelte-kit 140 | 141 | ### VisualStudioCode ### 142 | .vscode/* 143 | !.vscode/settings.json 144 | !.vscode/tasks.json 145 | !.vscode/launch.json 146 | !.vscode/extensions.json 147 | !.vscode/*.code-snippets 148 | 149 | # Local History for Visual Studio Code 150 | .history/ 151 | 152 | # Built Visual Studio Code Extensions 153 | *.vsix 154 | 155 | ### VisualStudioCode Patch ### 156 | # Ignore all local history of files 157 | .history 158 | .ionide 159 | 160 | # Support for Project snippet scope 161 | .vscode/*.code-snippets 162 | 163 | # Ignore code-workspaces 164 | *.code-workspace 165 | 166 | # End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode 167 | -------------------------------------------------------------------------------- /example/cms/src/globals/kitchenSink.ts: -------------------------------------------------------------------------------- 1 | import { GlobalConfig } from "payload/types"; 2 | import { ComplexBlock } from "../blocks/ComplexBlock"; 3 | import { TestBlock1 } from "../blocks/TestBlock1"; 4 | import { TestBlock2 } from "../blocks/TestBlock2"; 5 | import { Categories } from "../collections/Categories"; 6 | import { Media } from "../collections/Media"; 7 | import { Tags } from "../collections/Tags"; 8 | 9 | export const KitchenSink: GlobalConfig = { 10 | slug: "kitchenSink", 11 | fields: [ 12 | { 13 | name: "array", 14 | type: "array", 15 | required: true, 16 | fields: [ 17 | { name: "text", type: "text" }, 18 | { name: "number", type: "number" }, 19 | ], 20 | }, 21 | { 22 | name: "blocks", 23 | type: "blocks", 24 | required: true, 25 | blocks: [TestBlock1, TestBlock2, ComplexBlock], 26 | }, 27 | { 28 | name: "checkbox", 29 | type: "checkbox", 30 | required: true, 31 | }, 32 | { 33 | name: "code", 34 | type: "code", 35 | required: true, 36 | }, 37 | { 38 | name: "date", 39 | type: "date", 40 | required: true, 41 | }, 42 | { 43 | name: "email", 44 | type: "email", 45 | required: true, 46 | }, 47 | { 48 | name: "group", 49 | type: "group", 50 | fields: [ 51 | { name: "text", type: "text" }, 52 | { name: "number", type: "number" }, 53 | ], 54 | }, 55 | { 56 | name: "json", 57 | type: "json", 58 | required: true, 59 | }, 60 | { 61 | name: "number", 62 | type: "number", 63 | required: true, 64 | }, 65 | { 66 | name: "point", 67 | type: "point", 68 | required: true, 69 | }, 70 | { 71 | name: "radio", 72 | type: "radio", 73 | required: true, 74 | options: [ 75 | { label: "Radio 1", value: "radio1" }, 76 | { label: "Radio 2", value: "radio2" }, 77 | ], 78 | }, 79 | { 80 | name: "relationship1", 81 | type: "relationship", 82 | required: true, 83 | relationTo: Tags.slug, 84 | }, 85 | { 86 | name: "relationship2", 87 | type: "relationship", 88 | required: true, 89 | relationTo: Tags.slug, 90 | hasMany: true, 91 | }, 92 | { 93 | name: "relationship3", 94 | type: "relationship", 95 | required: true, 96 | relationTo: [Categories.slug, Tags.slug], 97 | }, 98 | { 99 | name: "relationship4", 100 | type: "relationship", 101 | required: true, 102 | relationTo: [Categories.slug, Tags.slug], 103 | hasMany: true, 104 | }, 105 | { 106 | name: "richText", 107 | type: "richText", 108 | required: true, 109 | }, 110 | { 111 | name: "select1", 112 | type: "select", 113 | required: true, 114 | options: [ 115 | { label: "Select 1", value: "select1" }, 116 | { label: "Select 2", value: "select2" }, 117 | ], 118 | }, 119 | { 120 | name: "select2", 121 | type: "select", 122 | required: true, 123 | hasMany: true, 124 | options: [ 125 | { label: "Select 1", value: "select1" }, 126 | { label: "Select 2", value: "select2" }, 127 | ], 128 | }, 129 | // { 130 | // type: "tabs", 131 | // tabs: [ 132 | // { 133 | // name: "tab1", 134 | // fields: [ 135 | // { name: "text1", type: "text" }, 136 | // ], 137 | // }, 138 | // { 139 | // name: "tab2", 140 | // fields: [ 141 | // { name: "text1", type: "text" }, 142 | // ], 143 | // }, 144 | // ], 145 | // }, 146 | { 147 | name: "text", 148 | type: "text", 149 | required: true, 150 | }, 151 | { 152 | name: "textarea", 153 | type: "textarea", 154 | required: true, 155 | }, 156 | { 157 | name: "upload", 158 | type: "upload", 159 | required: true, 160 | relationTo: Media.slug, 161 | }, 162 | ], 163 | }; 164 | -------------------------------------------------------------------------------- /example/website/src/payload-types.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * This file was automatically generated by Payload. 5 | * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, 6 | * and re-run `payload generate:types` to regenerate this file. 7 | */ 8 | 9 | export interface Config { 10 | collections: { 11 | users: User; 12 | posts: Post; 13 | tags: Tag; 14 | categories: Category; 15 | media: Medium; 16 | 'payload-preferences': PayloadPreference; 17 | 'payload-migrations': PayloadMigration; 18 | }; 19 | globals: { 20 | kitchenSink: KitchenSink; 21 | }; 22 | } 23 | /** 24 | * This interface was referenced by `Config`'s JSON-Schema 25 | * via the `definition` "users". 26 | */ 27 | export interface User { 28 | id: string; 29 | updatedAt: string; 30 | createdAt: string; 31 | email: string; 32 | resetPasswordToken?: string | null; 33 | resetPasswordExpiration?: string | null; 34 | salt?: string | null; 35 | hash?: string | null; 36 | loginAttempts?: number | null; 37 | lockUntil?: string | null; 38 | password: string | null; 39 | } 40 | /** 41 | * This interface was referenced by `Config`'s JSON-Schema 42 | * via the `definition` "posts". 43 | */ 44 | export interface Post { 45 | id: string; 46 | title: string; 47 | subtitle: string; 48 | category?: (string | null) | Category; 49 | tagsAndCategories?: 50 | | ( 51 | | { 52 | relationTo: 'tags'; 53 | value: string | Tag; 54 | } 55 | | { 56 | relationTo: 'categories'; 57 | value: string | Category; 58 | } 59 | )[] 60 | | null; 61 | status?: ('draft' | 'published') | null; 62 | checkParagraph?: boolean | null; 63 | paragraph?: 64 | | { 65 | [k: string]: unknown; 66 | }[] 67 | | null; 68 | description?: string | null; 69 | updatedAt: string; 70 | createdAt: string; 71 | _status?: ('draft' | 'published') | null; 72 | } 73 | /** 74 | * This interface was referenced by `Config`'s JSON-Schema 75 | * via the `definition` "categories". 76 | */ 77 | export interface Category { 78 | id: string; 79 | name: string; 80 | updatedAt: string; 81 | createdAt: string; 82 | } 83 | /** 84 | * This interface was referenced by `Config`'s JSON-Schema 85 | * via the `definition` "tags". 86 | */ 87 | export interface Tag { 88 | id: string; 89 | name: string; 90 | updatedAt: string; 91 | createdAt: string; 92 | } 93 | /** 94 | * This interface was referenced by `Config`'s JSON-Schema 95 | * via the `definition` "media". 96 | */ 97 | export interface Medium { 98 | id: string; 99 | updatedAt: string; 100 | createdAt: string; 101 | url?: string | null; 102 | filename?: string | null; 103 | mimeType?: string | null; 104 | filesize?: number | null; 105 | width?: number | null; 106 | height?: number | null; 107 | } 108 | /** 109 | * This interface was referenced by `Config`'s JSON-Schema 110 | * via the `definition` "payload-preferences". 111 | */ 112 | export interface PayloadPreference { 113 | id: string; 114 | user: { 115 | relationTo: 'users'; 116 | value: string | User; 117 | }; 118 | key?: string | null; 119 | value?: 120 | | { 121 | [k: string]: unknown; 122 | } 123 | | unknown[] 124 | | string 125 | | number 126 | | boolean 127 | | null; 128 | updatedAt: string; 129 | createdAt: string; 130 | } 131 | /** 132 | * This interface was referenced by `Config`'s JSON-Schema 133 | * via the `definition` "payload-migrations". 134 | */ 135 | export interface PayloadMigration { 136 | id: string; 137 | name?: string | null; 138 | batch?: number | null; 139 | updatedAt: string; 140 | createdAt: string; 141 | } 142 | /** 143 | * This interface was referenced by `Config`'s JSON-Schema 144 | * via the `definition` "kitchenSink". 145 | */ 146 | export interface KitchenSink { 147 | id: string; 148 | array: { 149 | text?: string | null; 150 | number?: number | null; 151 | id?: string | null; 152 | }[]; 153 | blocks: ( 154 | | { 155 | text1: string; 156 | text2: string; 157 | id?: string | null; 158 | blockName?: string | null; 159 | blockType: 'testBlock1'; 160 | } 161 | | { 162 | number1?: number | null; 163 | number2?: number | null; 164 | id?: string | null; 165 | blockName?: string | null; 166 | blockType: 'testBlock2'; 167 | } 168 | | { 169 | textPosition: 'left' | 'right'; 170 | text: { 171 | [k: string]: unknown; 172 | }[]; 173 | medium: string | Medium; 174 | id?: string | null; 175 | blockName?: string | null; 176 | blockType: 'complexBlock'; 177 | } 178 | )[]; 179 | checkbox: boolean; 180 | code: string; 181 | date: string; 182 | email: string; 183 | group?: { 184 | text?: string | null; 185 | number?: number | null; 186 | }; 187 | json: 188 | | { 189 | [k: string]: unknown; 190 | } 191 | | unknown[] 192 | | string 193 | | number 194 | | boolean 195 | | null; 196 | number: number; 197 | /** 198 | * @minItems 2 199 | * @maxItems 2 200 | */ 201 | point: [number, number]; 202 | radio: 'radio1' | 'radio2'; 203 | relationship1: string | Tag; 204 | relationship2: (string | Tag)[]; 205 | relationship3: 206 | | { 207 | relationTo: 'categories'; 208 | value: string | Category; 209 | } 210 | | { 211 | relationTo: 'tags'; 212 | value: string | Tag; 213 | }; 214 | relationship4: ( 215 | | { 216 | relationTo: 'categories'; 217 | value: string | Category; 218 | } 219 | | { 220 | relationTo: 'tags'; 221 | value: string | Tag; 222 | } 223 | )[]; 224 | richText: { 225 | [k: string]: unknown; 226 | }[]; 227 | select1: 'select1' | 'select2'; 228 | select2: ('select1' | 'select2')[]; 229 | text: string; 230 | textarea: string; 231 | upload: string | Medium; 232 | updatedAt?: string | null; 233 | createdAt?: string | null; 234 | } 235 | 236 | 237 | declare module 'payload' { 238 | export interface GeneratedTypes extends Config { } 239 | } -------------------------------------------------------------------------------- /example/cms/src/types/payload-types.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * This file was automatically generated by Payload. 5 | * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, 6 | * and re-run `payload generate:types` to regenerate this file. 7 | */ 8 | 9 | export interface Config { 10 | collections: { 11 | users: User; 12 | posts: Post; 13 | tags: Tag; 14 | categories: Category; 15 | media: Medium; 16 | 'payload-preferences': PayloadPreference; 17 | 'payload-migrations': PayloadMigration; 18 | }; 19 | globals: { 20 | kitchenSink: KitchenSink; 21 | }; 22 | } 23 | /** 24 | * This interface was referenced by `Config`'s JSON-Schema 25 | * via the `definition` "users". 26 | */ 27 | export interface User { 28 | id: string; 29 | updatedAt: string; 30 | createdAt: string; 31 | email: string; 32 | resetPasswordToken?: string | null; 33 | resetPasswordExpiration?: string | null; 34 | salt?: string | null; 35 | hash?: string | null; 36 | loginAttempts?: number | null; 37 | lockUntil?: string | null; 38 | password: string | null; 39 | } 40 | /** 41 | * This interface was referenced by `Config`'s JSON-Schema 42 | * via the `definition` "posts". 43 | */ 44 | export interface Post { 45 | id: string; 46 | title: string; 47 | subtitle: string; 48 | category?: (string | null) | Category; 49 | tagsAndCategories?: 50 | | ( 51 | | { 52 | relationTo: 'tags'; 53 | value: string | Tag; 54 | } 55 | | { 56 | relationTo: 'categories'; 57 | value: string | Category; 58 | } 59 | )[] 60 | | null; 61 | status?: ('draft' | 'published') | null; 62 | checkParagraph?: boolean | null; 63 | paragraph?: 64 | | { 65 | [k: string]: unknown; 66 | }[] 67 | | null; 68 | description?: string | null; 69 | updatedAt: string; 70 | createdAt: string; 71 | _status?: ('draft' | 'published') | null; 72 | } 73 | /** 74 | * This interface was referenced by `Config`'s JSON-Schema 75 | * via the `definition` "categories". 76 | */ 77 | export interface Category { 78 | id: string; 79 | name: string; 80 | updatedAt: string; 81 | createdAt: string; 82 | } 83 | /** 84 | * This interface was referenced by `Config`'s JSON-Schema 85 | * via the `definition` "tags". 86 | */ 87 | export interface Tag { 88 | id: string; 89 | name: string; 90 | updatedAt: string; 91 | createdAt: string; 92 | } 93 | /** 94 | * This interface was referenced by `Config`'s JSON-Schema 95 | * via the `definition` "media". 96 | */ 97 | export interface Medium { 98 | id: string; 99 | updatedAt: string; 100 | createdAt: string; 101 | url?: string | null; 102 | filename?: string | null; 103 | mimeType?: string | null; 104 | filesize?: number | null; 105 | width?: number | null; 106 | height?: number | null; 107 | } 108 | /** 109 | * This interface was referenced by `Config`'s JSON-Schema 110 | * via the `definition` "payload-preferences". 111 | */ 112 | export interface PayloadPreference { 113 | id: string; 114 | user: { 115 | relationTo: 'users'; 116 | value: string | User; 117 | }; 118 | key?: string | null; 119 | value?: 120 | | { 121 | [k: string]: unknown; 122 | } 123 | | unknown[] 124 | | string 125 | | number 126 | | boolean 127 | | null; 128 | updatedAt: string; 129 | createdAt: string; 130 | } 131 | /** 132 | * This interface was referenced by `Config`'s JSON-Schema 133 | * via the `definition` "payload-migrations". 134 | */ 135 | export interface PayloadMigration { 136 | id: string; 137 | name?: string | null; 138 | batch?: number | null; 139 | updatedAt: string; 140 | createdAt: string; 141 | } 142 | /** 143 | * This interface was referenced by `Config`'s JSON-Schema 144 | * via the `definition` "kitchenSink". 145 | */ 146 | export interface KitchenSink { 147 | id: string; 148 | array: { 149 | text?: string | null; 150 | number?: number | null; 151 | id?: string | null; 152 | }[]; 153 | blocks: ( 154 | | { 155 | text1: string; 156 | text2: string; 157 | id?: string | null; 158 | blockName?: string | null; 159 | blockType: 'testBlock1'; 160 | } 161 | | { 162 | number1?: number | null; 163 | number2?: number | null; 164 | id?: string | null; 165 | blockName?: string | null; 166 | blockType: 'testBlock2'; 167 | } 168 | | { 169 | textPosition: 'left' | 'right'; 170 | text: { 171 | [k: string]: unknown; 172 | }[]; 173 | medium: string | Medium; 174 | id?: string | null; 175 | blockName?: string | null; 176 | blockType: 'complexBlock'; 177 | } 178 | )[]; 179 | checkbox: boolean; 180 | code: string; 181 | date: string; 182 | email: string; 183 | group?: { 184 | text?: string | null; 185 | number?: number | null; 186 | }; 187 | json: 188 | | { 189 | [k: string]: unknown; 190 | } 191 | | unknown[] 192 | | string 193 | | number 194 | | boolean 195 | | null; 196 | number: number; 197 | /** 198 | * @minItems 2 199 | * @maxItems 2 200 | */ 201 | point: [number, number]; 202 | radio: 'radio1' | 'radio2'; 203 | relationship1: string | Tag; 204 | relationship2: (string | Tag)[]; 205 | relationship3: 206 | | { 207 | relationTo: 'categories'; 208 | value: string | Category; 209 | } 210 | | { 211 | relationTo: 'tags'; 212 | value: string | Tag; 213 | }; 214 | relationship4: ( 215 | | { 216 | relationTo: 'categories'; 217 | value: string | Category; 218 | } 219 | | { 220 | relationTo: 'tags'; 221 | value: string | Tag; 222 | } 223 | )[]; 224 | richText: { 225 | [k: string]: unknown; 226 | }[]; 227 | select1: 'select1' | 'select2'; 228 | select2: ('select1' | 'select2')[]; 229 | text: string; 230 | textarea: string; 231 | upload: string | Medium; 232 | updatedAt?: string | null; 233 | createdAt?: string | null; 234 | } 235 | 236 | 237 | declare module 'payload' { 238 | export interface GeneratedTypes extends Config {} 239 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Payload Visual Editor Plugin 2 | > **Note** 3 | > This plugin provides a visual live preview, including a nice UI, for **[Payload](https://github.com/payloadcms/payload)** 4 | > 5 | > Version 0.x.x is compatible with Payload 1.x.x 6 | > Version 2.x.x is compatible with Payload 2.x.x 7 | 8 | ## Core features: 9 | 10 | - Adds a visual editor component to your collections and globals: 11 | - Creates the visual editor UI in the Admin UIs edit view 12 | - Handles the live data exchange with your frontend 13 | 14 | ![image](https://github.com/pemedia/payload-visual-live-preview/blob/main/visual-editor-screenshot.png?raw=true) 15 | 16 | ## Installation 17 | 18 | ```bash 19 | yarn add payload-visual-editor 20 | # OR 21 | npm i payload-visual-editor 22 | ``` 23 | 24 | ## Basic Usage 25 | 26 | In the `plugins` array of your [Payload config](https://payloadcms.com/docs/configuration/overview), call the plugin with [options](#options): 27 | 28 | ```js 29 | // import plugin 30 | import { visualEditor } from "payload-visual-editor"; 31 | 32 | // import styles 33 | import "payload-visual-editor/dist/styles.scss"; 34 | 35 | const config = buildConfig({ 36 | collections: [...], 37 | plugins: [ 38 | visualEditor({ 39 | previewUrl: () => `http://localhost:3001/pages/preview`, 40 | previewWidthInPercentage: 60, 41 | collections: { 42 | [COLLECTION_SLUG]: { 43 | previewUrl: () => `...` // optional individual preview url for each collection 44 | }, 45 | }, 46 | globals: { 47 | [GLOBAL_SLUG]: { 48 | previewUrl: () => `...` // optional individual preview url for each global 49 | }, 50 | }, 51 | }), 52 | ], 53 | }); 54 | ``` 55 | 56 | ### Options 57 | 58 | - `previewUrl` : `({ locale: string; }) => string | mandatory` 59 | 60 | A function returning a string of the URL to your frontend preview route (e.g. `https://localhost:3001/pages/preview`). The `locale` property can be used if needed for [preview localization](#Localization). 61 | 62 | - `defaultPreviewMode` : `"iframe" | "popup" | "none"` 63 | 64 | Preferred preview mode while opening an edit page the first time. After toggling, the state will be saved in localStore. Default: "iframe" 65 | 66 | - `previewWidthInPercentage` : `number` 67 | 68 | Width of the iframe preview in percentage. Default: 50 69 | 70 | - `collections` / `globals` : `Record string; }>` 71 | 72 | An object with configs for all collections / globals which should enable the live preview. Use the collection / global slug as the key. If you don't want to override the previewUrl, just pass an empty object. 73 | 74 | ### Localization 75 | 76 | If you are using Localization with multiple locales, it can be very handy, to be able to adjust the preview URL based on the selected/current locale. You can pass `locale` to the `previewUrl` function in your payload config an place it, where your frontend needs it to be: 77 | 78 | ```js 79 | const config = buildConfig({ 80 | collections: [...], 81 | plugins: [ 82 | visualEditor({ 83 | previewUrl: params => `https://localhost:3001/${params.locale}/pages/preview` 84 | ... 85 | }), 86 | ], 87 | }); 88 | ``` 89 | 90 | ### Relation Fallbacks 91 | 92 | When adding blocks or editing relationship / upload fields, you will often encounter the issue that the data is incomplete. 93 | For instance, because no relation has been selected yet. 94 | However, when such fields are marked as required and there is no check for undefined values in the frontend, 95 | it can lead to unexpected errors in the rendering process. 96 | To address this problem, fallbacks can be set up for the collections / globals. 97 | In cases where a field is required but no value has been selected, the fallback of the respective collection will be returned. 98 | 99 | ```js 100 | import { CollectionWithFallbackConfig } from "payload-visual-editor"; 101 | 102 | export const Tags: CollectionWithFallbackConfig = { 103 | slug: "tags", 104 | fields: [ 105 | { 106 | name: "name", 107 | type: "text", 108 | required: true, 109 | }, 110 | ], 111 | custom: { 112 | fallback: { 113 | id: "", 114 | name: "Fallback Tag", 115 | createdAt: "", 116 | updatedAt: "", 117 | }, 118 | }, 119 | }; 120 | ``` 121 | 122 | ## Frontend Integration in React / Next.js 123 | 124 | In the next.js route which will handle your life preview use this code snippet to get the live post data of your collection directly from payload. In this case it"s a collection with he name `page`. 125 | 126 | ```js 127 | const [page, setPage] = useState(null); 128 | 129 | useEffect(() => { 130 | const listener = (event: MessageEvent) => { 131 | if (event.data.cmsLivePreviewData) { 132 | setPage(event.data.cmsLivePreviewData); 133 | } 134 | }; 135 | 136 | window.addEventListener("message", listener, false); 137 | 138 | return () => { 139 | window.removeEventListener("message", listener); 140 | }; 141 | }, []); 142 | ``` 143 | 144 | You can now pass this to your render function and you can use all your payload collection data in there. For example like this: 145 | 146 | ```js 147 | return ( 148 |
149 |
150 |

{page.title}

151 |
152 |
153 | 154 |
155 |
156 | ); 157 | ``` 158 | 159 | Since the document will only be send to the frontend after a field has been changed the preview page wouldn"t show any data on first render. 160 | To inform the cms to send the current document state to the frontend, send a `ready` message to the parent window, as soon as the DOM / react app is ready: 161 | 162 | ```js 163 | // react 164 | useEffect(() => { 165 | (opener ?? parent).postMessage("ready", "*"); 166 | }, []); 167 | 168 | // vanilla js 169 | window.addEventListener("DOMContentLoaded", () => { 170 | (opener ?? parent).postMessage("ready", "*"); 171 | }); 172 | ``` 173 | 174 | ## Development 175 | 176 | This repo includes a demo project with payload as the backend and a simple website written in plain TypeScript. 177 | To start the demo, follow these steps: 178 | 179 | 1. Start docker and wait until the containers are up: 180 | 181 | ```sh 182 | docker-compose up 183 | ``` 184 | 185 | 2. Open another terminal and install root dependencies: 186 | 187 | ```sh 188 | yarn docker:plugin:yarn 189 | ``` 190 | 191 | 3. Install dependencies of the payload example: 192 | 193 | ```sh 194 | yarn docker:example:cms:yarn 195 | ``` 196 | 197 | 4. Run the payload dev server: 198 | 199 | ```sh 200 | yarn docker:example:cms:dev 201 | ``` 202 | 203 | 5. Open another terminal and install dependencies of the frontend example: 204 | 205 | ```sh 206 | yarn docker:example:website:yarn 207 | ``` 208 | 209 | 6. Start the dev server for the frontend: 210 | 211 | ```sh 212 | yarn docker:example:website:dev 213 | ``` 214 | 215 | - After changing collections, fields, etc., you can use `yarn docker:example:cms:generate-types` to create an updated interface file. 216 | - To connect with the node container, run `yarn docker:shell`. 217 | - To connect with the database container, run `yarn docker:mongo`. 218 | -------------------------------------------------------------------------------- /src/components/visualEditorView/index.tsx: -------------------------------------------------------------------------------- 1 | import { Meta } from "payload/components/utilities"; 2 | import { FieldTypes } from "payload/config"; 3 | import { DocumentControls } from "payload/dist/admin/components/elements/DocumentControls"; 4 | import { DocumentFields } from "payload/dist/admin/components/elements/DocumentFields"; 5 | import { LeaveWithoutSaving } from "payload/dist/admin/components/modals/LeaveWithoutSaving"; 6 | import { SetStepNav } from "payload/dist/admin/components/views/collections/Edit/SetStepNav"; 7 | import { CollectionEditViewProps, GlobalEditViewProps } from "payload/dist/admin/components/views/types"; 8 | import { getTranslation } from "payload/dist/utilities/getTranslation"; 9 | import React, { Fragment } from "react"; 10 | import { useTranslation } from "react-i18next"; 11 | import { match } from "ts-pattern"; 12 | import { PreviewMode } from "../../types/previewMode"; 13 | import { PreviewUrlFn } from "../../types/previewUrl"; 14 | import NewWindow from "../icons/NewWindow"; 15 | import SideBySide from "../icons/SideBySide"; 16 | import { IFramePreview, PopupPreview } from "../preview"; 17 | import { usePersistentState } from "./usePersistentState"; 18 | 19 | type Options = { 20 | previewUrl: PreviewUrlFn; 21 | defaultPreviewMode: PreviewMode; 22 | previewWidthInPercentage: number; 23 | } 24 | 25 | type Props = (CollectionEditViewProps | GlobalEditViewProps) & { fieldTypes: FieldTypes }; 26 | 27 | const getCollectionOrGlobalProps = (props: Props) => { 28 | if ("collection" in props) { 29 | return ({ 30 | apiURL: props.apiURL, 31 | fieldTypes: props.fieldTypes, 32 | data: props.data, 33 | permissions: props.permissions!, 34 | 35 | collection: props.collection, 36 | disableActions: props.disableActions, 37 | disableLeaveWithoutSaving: props.disableLeaveWithoutSaving, 38 | hasSavePermission: props.hasSavePermission!, 39 | isEditing: props.isEditing!, 40 | id: props.id, 41 | fields: props.collection?.fields, 42 | }); 43 | } 44 | 45 | return ({ 46 | apiURL: props.apiURL, 47 | fieldTypes: props.fieldTypes, 48 | data: props.data, 49 | permissions: props.permissions!, 50 | 51 | global: props.global, 52 | fields: props.global.fields, 53 | label: props.global.label, 54 | description: props.global.admin?.description, 55 | hasSavePermission: props.permissions?.update?.permission!, 56 | }); 57 | }; 58 | 59 | export const createVisualEditorView = (options: Options) => (props_: Props) => { 60 | const props = getCollectionOrGlobalProps(props_); 61 | 62 | const [previewMode, setPreviewMode] = usePersistentState("visualEditorPreviewState", options.defaultPreviewMode); 63 | 64 | const { i18n, t } = useTranslation("general"); 65 | 66 | const containerStyle = { 67 | "--preview-width": `${options.previewWidthInPercentage}%`, 68 | } as any; 69 | 70 | return ( 71 | 72 | {props.collection && ( 73 | 78 | )} 79 | 80 | {props.global && ( 81 | 86 | )} 87 | 88 | {((props.collection && !(props.collection.versions?.drafts && props.collection.versions?.drafts?.autosave)) || 89 | (props.global && !(props.global.versions?.drafts && props.global.versions?.drafts?.autosave))) && 90 | !props.disableLeaveWithoutSaving && } 91 | 92 | {props.collection && ( 93 | 99 | )} 100 | 101 | {props.global && ( 102 | 106 | )} 107 | 108 | 119 | 120 |
121 | 129 | 130 | {match(previewMode) 131 | .with("iframe", () => ( 132 |
133 | 137 |
138 | )) 139 | .with("popup", () => ( 140 | 144 | )) 145 | .with("none", () => ( 146 |
147 | 154 | 161 |
162 | )) 163 | .exhaustive() 164 | } 165 |
166 |
167 | ); 168 | }; 169 | -------------------------------------------------------------------------------- /example/website/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Post, Tag, Category, Medium, KitchenSink } from "./payload-types"; 2 | 3 | new EventSource("/esbuild").addEventListener("change", () => location.reload()) 4 | 5 | const isCategory = (doc: any): doc is Category => { 6 | return doc.name !== undefined; 7 | }; 8 | 9 | const isTag = (doc: any): doc is Tag => { 10 | return doc.name !== undefined; 11 | }; 12 | 13 | const isPost = (doc: any): doc is Post => { 14 | return doc.title !== undefined; 15 | }; 16 | 17 | const isKitchenSink = (doc: any): doc is KitchenSink => { 18 | return doc.code !== undefined; 19 | }; 20 | 21 | window.addEventListener("message", event => { 22 | const data: Post | KitchenSink | undefined = event.data.cmsLivePreviewData; 23 | console.log(data); 24 | 25 | clearElements(); 26 | if (isPost(data)) return postPreview(data); 27 | else if (isKitchenSink(data)) return kitchenSinkPreview(data); 28 | }); 29 | 30 | window.addEventListener("DOMContentLoaded", () => { 31 | setTimeout(() => { 32 | (opener ?? parent).postMessage("ready", "*"); 33 | }, 100); 34 | }); 35 | 36 | const postPreview = (data: Post) => { 37 | 38 | addElem(`

${data.title}

`); 39 | addElem(`

${data.subtitle}

`); 40 | 41 | if (data.tagsAndCategories !== undefined) { 42 | 43 | const tagList = data.tagsAndCategories.map(tagOrCategory => { 44 | if (tagOrCategory.relationTo === "tags" && isTag(tagOrCategory.value)) { 45 | return `
  • Tag: ${tagOrCategory.value.name}
  • `; 46 | } 47 | 48 | if (tagOrCategory.relationTo === "categories" && isCategory(tagOrCategory.value)) { 49 | return `
  • Category: ${tagOrCategory.value.name}
  • `; 50 | } 51 | 52 | return null; 53 | 54 | }).filter(Boolean).join("\n"); 55 | 56 | addElem(`
      ${tagList}
    `); 57 | } 58 | 59 | if (data.category !== undefined && isCategory(data.category)) { 60 | addElem(`
    ${data.category.name}
    `); 61 | } 62 | 63 | if (data.checkParagraph) { 64 | addElem(`

    Rich text:

    `); 65 | addElem(`${JSON.stringify([data.paragraph])}`); 66 | addElem(`
    `); 67 | } 68 | 69 | } 70 | 71 | 72 | const kitchenSinkPreview = (data: KitchenSink) => { 73 | 74 | // array 75 | const array = data.array.map(item => { 76 | return `
  • Number: ${item.text} – Number: ${item.number}
  • `; 77 | }).filter(Boolean).join("\n"); 78 | addElem(`

    Array:

    `); 79 | addElem(`
      ${array}
    `); 80 | 81 | // blocks 82 | const blocks = data.blocks.map(item => { 83 | if (item.blockType == 'testBlock1') return `
  • BlockType1: ${item.text1} - ${item.text2}
  • `; 84 | else if (item.blockType == 'testBlock2') return `
  • BlockType2: ${item.number1} - ${item.number2}
  • `; 85 | }).filter(Boolean).join("\n"); 86 | addElem(`

    Blocks:

    `); 87 | addElem(`
      ${blocks}
    `); 88 | addElem(`
    `); 89 | 90 | // checkbox 91 | addElem(`

    Checkbox:

    `); 92 | addElem(`
    `); 93 | addElem(`
    `); 94 | 95 | // code 96 | addElem(`

    Code:

    `); 97 | addElem(`${data.code}`); 98 | addElem(`
    `); 99 | 100 | // date 101 | addElem(`

    Date:

    `); 102 | addElem(`
    ${data.date}
    `); 103 | addElem(`
    `); 104 | 105 | // email 106 | addElem(`

    Email:

    `); 107 | addElem(`
    ${data.email}
    `); 108 | addElem(`
    `); 109 | 110 | // group 111 | addElem(`

    Group:

    `); 112 | addElem(`
    ${data.group?.number}
    `); 113 | addElem(`
    ${data.group?.text}
    `); 114 | addElem(`
    `); 115 | 116 | // json 117 | addElem(`

    JSON:

    `); 118 | addElem(`${JSON.stringify(data.json)}`); 119 | addElem(`
    `); 120 | 121 | // number 122 | addElem(`

    Number:

    `); 123 | addElem(`
    ${data.number}
    `); 124 | addElem(`
    `); 125 | 126 | // point 127 | addElem(`

    Point:

    `); 128 | const points = data.point.map((item, index) => { 129 | return `
  • [${index}] = ${item}
  • `; 130 | }).filter(Boolean).join("\n"); 131 | addElem(`
      ${points}
    `); 132 | addElem(`
    `); 133 | 134 | // radio 135 | addElem(`

    Radio:

    `); 136 | addElem(`
    ${data.radio}
    `); 137 | addElem(`
    `); 138 | 139 | // relationship (single) 140 | addElem(`

    Relationship 1 (single):

    `); 141 | addElem(`
    • ${(data.relationship1 as Tag).name}
    `); 142 | addElem(`
    `); 143 | 144 | // relationship (multi) 145 | addElem(`

    Relationship 2 (multi):

    `); 146 | const relationships2 = (data.relationship2 as Tag[]).map((item, index) => { 147 | return `
  • [${index}] = ${item.name}
  • `; 148 | }).filter(Boolean).join("\n"); 149 | addElem(`
      ${relationships2}
    `); 150 | addElem(`
    `); 151 | 152 | // relationship (array) 153 | addElem(`

    Relationship 3 (array):

    `); 154 | addElem(`
    • ${data.relationship3.relationTo}: ${(data.relationship3.value as Tag | Category).name}
    `); 155 | addElem(`
    `); 156 | 157 | // relationship (array multi) 158 | addElem(`

    Relationship 4 (array multi):

    `); 159 | const relationships4 = data.relationship4.map((item, index) => { 160 | const value = item.value as Tag | Category; 161 | 162 | return `
  • ${item.relationTo}: ${value.name}
  • `; 163 | }).filter(Boolean).join("\n"); 164 | addElem(`
      ${relationships4}
    `); 165 | addElem(`
    `); 166 | 167 | // rich text 168 | addElem(`

    Rich text:

    `); 169 | addElem(`${JSON.stringify([data.richText])}`); 170 | addElem(`
    `); 171 | 172 | // select 1 173 | addElem(`

    Select 1 (single):

    `); 174 | addElem(`
    • ${data.select1}
    `); 175 | addElem(`
    `); 176 | 177 | // select 2 178 | addElem(`

    Select 2 (multi):

    `); 179 | const selects = data.select2.map((item, index) => { 180 | return `
  • [${index}] = ${item}
  • `; 181 | }).filter(Boolean).join("\n"); 182 | addElem(`
      ${selects}
    `); 183 | addElem(`
    `); 184 | 185 | // text 186 | addElem(`

    Text:

    `); 187 | addElem(`
    ${data.text}
    `); 188 | addElem(`
    `); 189 | 190 | // textarea 191 | addElem(`

    Textarea:

    `); 192 | addElem(`
    ${data.textarea.replace(/(?:\r\n|\r|\n)/g, "
    ")}
    `); 193 | addElem(`
    `); 194 | 195 | // upload 196 | addElem(`

    Upload:

    `); 197 | const upload = data.upload as Medium; 198 | 199 | let mediaElem: string | null = null; 200 | 201 | if (upload.mimeType?.includes("image/")) { 202 | mediaElem = ``; 203 | } else if (upload.mimeType?.includes("video/")) { 204 | mediaElem = ``; 205 | } 206 | 207 | addElem(` 208 |
    209 |
    ${upload.filename}
    210 | ${(mediaElem) ? `
    ${mediaElem}
    ` : ''} 211 |
    212 | `); 213 | addElem(`
    `); 214 | } 215 | 216 | const addElem = (data: string) => { 217 | const container = document.getElementById("preview"); 218 | const template = document.createElement('template'); 219 | data = data.trim(); 220 | template.innerHTML = data; 221 | const htmlNode = template.content.firstChild; 222 | if (container && htmlNode) container.appendChild(htmlNode); 223 | } 224 | 225 | const clearElements = () => { 226 | const container = document.getElementById("preview"); 227 | if (container) container.innerHTML = ""; 228 | } 229 | -------------------------------------------------------------------------------- /src/components/preview/iframe/index.tsx: -------------------------------------------------------------------------------- 1 | import CloseMenu from "payload/dist/admin/components/icons/CloseMenu"; 2 | import React, { CSSProperties, useEffect, useRef, useState } from "react"; 3 | import { PreviewMode } from "../../../types/previewMode"; 4 | import { PreviewUrlFn } from "../../../types/previewUrl"; 5 | import NewWindow from "../../icons/NewWindow"; 6 | import { usePreview } from "../hooks/usePreview"; 7 | import { useResizeObserver } from "./useResizeObserver"; 8 | import { Dropdown } from "../../dropdown"; 9 | 10 | interface Props { 11 | previewUrlFn: PreviewUrlFn; 12 | setPreviewMode: (mode: PreviewMode) => void; 13 | } 14 | 15 | interface Size { 16 | width: number; 17 | height: number; 18 | } 19 | 20 | interface ScreenSize extends Size { 21 | slug: string; 22 | label: string; 23 | } 24 | 25 | const SCREEN_SIZES: ScreenSize[] = [ 26 | { 27 | slug: "responsive", 28 | label: "Responsive", 29 | width: 0, 30 | height: 0, 31 | }, 32 | { 33 | slug: "desktop", 34 | label: "Desktop", 35 | width: 1440, 36 | height: 900, 37 | }, 38 | { 39 | slug: "tablet", 40 | label: "Tablet", 41 | width: 1024, 42 | height: 768, 43 | }, 44 | { 45 | slug: "smartphone", 46 | label: "Smartphone", 47 | width: 375, 48 | height: 700, 49 | }, 50 | ]; 51 | 52 | const calculateScale = (availableSize: Size, preferredSize: Size, preferredScale = Infinity) => { 53 | // const scales = [1, 0.75, 0.5]; 54 | 55 | // const fittingScale = scales.find(scale => { 56 | // if (preferred.width * scale <= available.width && preferred.height * scale <= available.height) { 57 | // return scale; 58 | // } 59 | // }); 60 | 61 | // return fittingScale || 0.25; 62 | 63 | const scaleX = availableSize.width / preferredSize.width; 64 | const scaleY = availableSize.height / preferredSize.height; 65 | 66 | // use the smaller scale 67 | const maxScale = Math.min(scaleX, scaleY, 1); 68 | 69 | // round it 70 | const rounded = Math.floor(maxScale * 100) / 100; 71 | 72 | // if preferred scale is smaller than max scale, use preferred scale 73 | return Math.min(rounded, preferredScale); 74 | }; 75 | 76 | export const IFramePreview = (props: Props) => { 77 | const iframe = useRef(null); 78 | const resizeContainer = useRef(null); 79 | const livePreviewContainer = useRef(null); 80 | 81 | const [sizeAndScale, setSizeAndScale] = useState({ 82 | ...SCREEN_SIZES[0], 83 | scale: 1, 84 | }); 85 | 86 | useEffect(() => { 87 | const availableSize = { 88 | width: livePreviewContainer.current!.clientWidth, 89 | height: livePreviewContainer.current!.clientHeight, 90 | }; 91 | 92 | setSizeAndScale({ 93 | ...sizeAndScale, 94 | ...availableSize, 95 | }); 96 | }, []); 97 | 98 | useResizeObserver(resizeContainer, ([entry]) => { 99 | setSizeAndScale(oldValue => ({ 100 | ...oldValue, 101 | width: entry.contentRect.width / oldValue.scale, 102 | height: entry.contentRect.height / oldValue.scale, 103 | })); 104 | }); 105 | 106 | const selectSize = (item: ScreenSize) => { 107 | const availableSize = { 108 | width: livePreviewContainer.current!.clientWidth, 109 | height: livePreviewContainer.current!.clientHeight, 110 | }; 111 | 112 | if (item.slug === "responsive") { 113 | setSizeAndScale({ 114 | ...item, 115 | ...availableSize, 116 | scale: 1, 117 | }); 118 | } else { 119 | const preferredSize = item; 120 | 121 | const scale = calculateScale(availableSize, preferredSize); 122 | 123 | setSizeAndScale({ 124 | ...preferredSize, 125 | scale, 126 | }); 127 | } 128 | }; 129 | 130 | const selectScale = (preferredScale: number) => { 131 | const availableSize = { 132 | width: livePreviewContainer.current!.clientWidth, 133 | height: livePreviewContainer.current!.clientHeight, 134 | }; 135 | 136 | const preferredSize = sizeAndScale; 137 | 138 | const scale = calculateScale(availableSize, preferredSize, preferredScale); 139 | 140 | setSizeAndScale({ 141 | ...sizeAndScale, 142 | scale, 143 | }); 144 | }; 145 | 146 | const rotatePreview = () => { 147 | const availableSize = { 148 | width: livePreviewContainer.current!.clientWidth, 149 | height: livePreviewContainer.current!.clientHeight, 150 | }; 151 | 152 | const preferredSize = { 153 | width: sizeAndScale.height, 154 | height: sizeAndScale.width, 155 | }; 156 | 157 | const scale = calculateScale(availableSize, preferredSize); 158 | 159 | setSizeAndScale({ 160 | ...sizeAndScale, 161 | ...preferredSize, 162 | scale, 163 | }); 164 | }; 165 | 166 | const previewUrl = usePreview(props.previewUrlFn, iframe); 167 | 168 | const resizeContainerStyles: CSSProperties = { 169 | width: sizeAndScale.width * sizeAndScale.scale, 170 | height: sizeAndScale.height * sizeAndScale.scale, 171 | }; 172 | 173 | const iframeStyles: CSSProperties = { 174 | transform: `scale(${sizeAndScale.scale})`, 175 | width: `${(1 / sizeAndScale.scale) * 100}%`, 176 | height: `${(1 / sizeAndScale.scale) * 100}%`, 177 | }; 178 | 179 | return ( 180 |
    181 |
    186 |
    187 | ( 191 | { label: item.label, action: () => selectSize(item) } 192 | ))} 193 | /> 194 | 195 | selectScale(1) }, 200 | { label: "75%", action: () => selectScale(0.75) }, 201 | { label: "50%", action: () => selectScale(0.5) }, 202 | ]} 203 | /> 204 | 205 | 210 | 211 |
    212 | {Math.round(sizeAndScale.width)} x {Math.round(sizeAndScale.height)} 213 |
    214 | 215 |
    216 | 219 | 222 |
    223 |
    224 | 225 |