├── .gitignore ├── .github ├── example.gif └── spectro-logo.png ├── .editorconfig ├── src ├── helpers │ ├── get-random-key.ts │ ├── get-current-origin.ts │ ├── is-stateless-component.ts │ ├── get-display-name.ts │ ├── is-valid-message.ts │ ├── chain.ts │ ├── compose.ts │ ├── drop-canvaz-props.ts │ └── convert-to-class-component.ts ├── canvaz.d.ts ├── constants.ts ├── index.ts ├── hocs │ ├── with-canvaz.tsx │ ├── with-styles.tsx │ ├── with-data.tsx │ └── with-enhance.tsx ├── tree │ ├── is-valid-node.ts │ ├── assign-keys.ts │ ├── dehydrate.ts │ ├── rehydrate.ts │ └── index.ts ├── components │ ├── drop-placeholder.ts │ └── text-editable.ts ├── containers │ ├── rehydration-provider.ts │ └── canvaz-container.ts ├── models │ ├── portal.ts │ └── history.ts └── media │ └── component.ts ├── tslint.json ├── .vscode └── settings.json ├── webpack ├── development.js ├── shared.js ├── production.js └── example.js ├── tsconfig.json ├── example ├── index.html ├── styles.ts └── index.tsx ├── README.md ├── LICENSE └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | lib/ 4 | .DS_Store 5 | *.log 6 | -------------------------------------------------------------------------------- /.github/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbekrin/canvaz/HEAD/.github/example.gif -------------------------------------------------------------------------------- /.github/spectro-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbekrin/canvaz/HEAD/.github/spectro-logo.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | charset = utf-8 -------------------------------------------------------------------------------- /src/helpers/get-random-key.ts: -------------------------------------------------------------------------------- 1 | export default function getRandomKey() { 2 | return Math.random().toString(36).substr(2, 5); 3 | } 4 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-react", "tslint-config-prettier"], 3 | "rules": { 4 | "jsx-boolean-value": "never" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/helpers/get-current-origin.ts: -------------------------------------------------------------------------------- 1 | export default function getCurrentOrigin() { 2 | return `${document.location.protocol}//${document.location.host}`; 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.singleQuote": true, 3 | "prettier.trailingComma": "es5", 4 | "editor.formatOnSave": true, 5 | "html.format.enable": false, 6 | "tslint.enable": true 7 | } 8 | -------------------------------------------------------------------------------- /src/helpers/is-stateless-component.ts: -------------------------------------------------------------------------------- 1 | export default function isStatelessComponent( 2 | component: React.ComponentType 3 | ) { 4 | return !(component.prototype && component.prototype.render); 5 | } 6 | -------------------------------------------------------------------------------- /webpack/development.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const sharedConfig = require('./shared'); 3 | 4 | module.exports = merge(sharedConfig, { 5 | devtool: '#inline-source-map', 6 | debug: true, 7 | }); 8 | -------------------------------------------------------------------------------- /src/helpers/get-display-name.ts: -------------------------------------------------------------------------------- 1 | export default function getDisplayName( 2 | component: React.ComponentType, 3 | fallback?: string 4 | ) { 5 | return component.displayName || component.name || fallback || 'Unknown'; 6 | } 7 | -------------------------------------------------------------------------------- /src/helpers/is-valid-message.ts: -------------------------------------------------------------------------------- 1 | import getCurrentOrigin from '~/helpers/get-current-origin'; 2 | 3 | export default function isValidPostMessage(event) { 4 | return event.origin === getCurrentOrigin() && event.data.canvaz; 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "sourceMap": true, 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "lib": ["dom", "es2015"], 8 | "baseUrl": "./", 9 | "paths": { 10 | "~/*": ["./src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/canvaz.d.ts: -------------------------------------------------------------------------------- 1 | interface CanvazNode { 2 | type: string; 3 | props?: { 4 | [key: string]: any; 5 | key?: string; 6 | }; 7 | children?: string | number | CanvazNode[]; 8 | } 9 | 10 | interface CanvazConfig { 11 | label?: string; 12 | accept?: { [key: string]: React.ComponentType }; 13 | void?: boolean; 14 | } 15 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const noop = () => {}; 2 | export const HISTORY_CONTAINER = '__CANVAZ_HISTORY_CONTAINER__'; 3 | export const CANVAZ_CONTEXT = '__canvaz'; 4 | export const COMPONENTS_CONTEXT = '__rehydration'; 5 | export const DND_START = 'DragAndDropStart'; 6 | export const DND_OVER = 'DragAndDropOver'; 7 | export const DND_END = 'DragAndDropEnd'; 8 | -------------------------------------------------------------------------------- /src/helpers/chain.ts: -------------------------------------------------------------------------------- 1 | export default function chain(...input: any[]): Function { 2 | const handlers = input.filter(item => typeof item === 'function'); 3 | return (event: React.SyntheticEvent) => 4 | handlers.forEach(handler => { 5 | if (!event.isPropagationStopped()) { 6 | handler(event); 7 | } 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Canvaz Example 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import withCanvaz from '~/hocs/with-canvaz'; 2 | import withData from '~/hocs/with-data'; 3 | import RehydrationProvider from '~/containers/rehydration-provider'; 4 | import CanvazContainer from '~/containers/canvaz-container'; 5 | import TextEditable from '~/components/text-editable'; 6 | 7 | export default withCanvaz; 8 | export { withData, RehydrationProvider, CanvazContainer, TextEditable }; 9 | -------------------------------------------------------------------------------- /src/helpers/compose.ts: -------------------------------------------------------------------------------- 1 | export default function compose(...input: any[]): Function { 2 | const functions = input.filter(item => typeof item === 'function'); 3 | 4 | if (functions.length === 0) { 5 | return argument => argument; 6 | } 7 | 8 | if (functions.length === 1) { 9 | return functions[0]; 10 | } 11 | 12 | return functions.reduce((a, b) => (...args) => a(b(...args))); 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/drop-canvaz-props.ts: -------------------------------------------------------------------------------- 1 | export default function dropCanvazProps(props: any): {} { 2 | const { 3 | hovered, 4 | selected, 5 | isRoot, 6 | isEditing, 7 | getNode, 8 | getIndex, 9 | getDndDragNode, 10 | getDndTargetNode, 11 | getDndDropNode, 12 | getDndDropIndex, 13 | canDrop, 14 | proceedDrop, 15 | updateNode, 16 | removeNode, 17 | duplicateNode, 18 | insertNodeAt, 19 | ...filteredProps, 20 | } = props; 21 | return filteredProps; 22 | } 23 | -------------------------------------------------------------------------------- /src/hocs/with-canvaz.tsx: -------------------------------------------------------------------------------- 1 | import compose from '~/helpers/compose'; 2 | import withStyles from '~/hocs/with-styles'; 3 | import withEnhance, { EnhanceProps } from '~/hocs/with-enhance'; 4 | import withData, { DataProps } from '~/hocs/with-data'; 5 | 6 | export default function withCanvaz

( 7 | config: CanvazConfig 8 | ): ( 9 | WrappedComponent: React.ComponentType

10 | ) => React.ComponentClass

{ 11 | return WrappedComponent => 12 | compose(withData, withEnhance(config), withStyles)(WrappedComponent); 13 | } 14 | -------------------------------------------------------------------------------- /src/tree/is-valid-node.ts: -------------------------------------------------------------------------------- 1 | export default function isValidNode(node: any | CanvazNode) { 2 | const isObject = typeof node === 'object'; 3 | if (!isObject) { 4 | return false; 5 | } 6 | 7 | const isValidType = typeof node.type === 'string'; 8 | const isValidProps = node.props ? typeof node.props === 'object' : true; 9 | const isValidChildren = node.children 10 | ? Array.isArray(node.children) || 11 | typeof node.children === 'string' || 12 | typeof node.children === 'number' 13 | : true; 14 | 15 | return isValidType && isValidProps && isValidChildren; 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Canvaz Editor [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 2 | Canvaz (former Spectro) is visual, modular content editor. Unlike any WYSIWYG-editors, 3 | Canvaz works with component tree instead of text. It provides extensibility 4 | and great visual control of output. 5 | 6 | ## Demo 7 | ![](https://github.com/sbekrin/spectro/raw/master/.github/example.gif) 8 | 9 | ## Development 10 | It's recomended to develop while running example. 11 | ```bash 12 | npm run example 13 | ``` 14 | 15 | To build UMD library, run `npm run build`. 16 | 17 | ## License 18 | MIT © [Sergey Bekrin](https://github.com/sbekrin) 19 | -------------------------------------------------------------------------------- /src/helpers/convert-to-class-component.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import getDisplayName from '~/helpers/get-display-name'; 3 | import isStateless from '~/helpers/is-stateless-component'; 4 | 5 | export default function convertToClassComponent

( 6 | component: React.ComponentType

7 | ): React.ComponentClass

{ 8 | if (isStateless(component)) { 9 | return class extends React.Component { 10 | static displayName = getDisplayName(component); 11 | 12 | render() { 13 | return (component as React.StatelessComponent

)( 14 | this.props, 15 | this.context 16 | ); 17 | } 18 | }; 19 | } 20 | 21 | return component as React.ComponentClass

; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/drop-placeholder.ts: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components'; 2 | 3 | const pulseAnimation = keyframes` 4 | 0% { opacity: 0.2; transform: scale(1); } 5 | 50% { opacity: 0.8; transform: scale(1.05); } 6 | 100% { opacity: 0.2; transform: scale(1); } 7 | `; 8 | 9 | const DropPlaceholder: React.ComponentClass = styled.hr` 10 | animation: ${pulseAnimation} 1s ease infinite; 11 | transition-duration: 100ms; 12 | transition-property: top, left, width, height; 13 | transition-timing-function: ease; 14 | border: none; 15 | height: 2px; 16 | margin-top: -1px; 17 | background-color: rgb(59, 153, 252); 18 | border-radius: 2px; 19 | position: absolute; 20 | pointer-events: none; 21 | `; 22 | 23 | export default DropPlaceholder; 24 | -------------------------------------------------------------------------------- /src/containers/rehydration-provider.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { object } from 'prop-types'; 3 | import { COMPONENTS_CONTEXT } from '~/constants'; 4 | 5 | interface ProviderProps { 6 | children: any; 7 | components: { 8 | [key: string]: React.ComponentType; 9 | }; 10 | } 11 | 12 | export default class RehydrationProvider extends React.Component< 13 | ProviderProps 14 | > { 15 | static childContextTypes = { 16 | [COMPONENTS_CONTEXT]: object.isRequired, 17 | }; 18 | 19 | getChildContext() { 20 | return { 21 | ...this.context, 22 | [COMPONENTS_CONTEXT]: this.props.components, 23 | }; 24 | } 25 | 26 | render() { 27 | // should have only one or no child at all 28 | return this.props.children 29 | ? React.Children.only(this.props.children) 30 | : null; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /webpack/shared.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: resolve(__dirname, '..', 'src', 'index.js'), 6 | output: { 7 | path: resolve(__dirname, '..', 'dist'), 8 | }, 9 | module: { 10 | loaders: [ 11 | { 12 | test: /\.tsx?$/, 13 | exclude: /node_modules/, 14 | loader: 'ts-loader', 15 | }, 16 | ], 17 | }, 18 | resolve: { 19 | alias: { 20 | '~': resolve(__dirname, '..', 'src'), 21 | }, 22 | extensions: ['.ts', '.tsx'], 23 | }, 24 | externals: { 25 | react: { 26 | commonjs: 'react', 27 | commonjs2: 'react', 28 | amd: 'React', 29 | root: 'React', 30 | }, 31 | }, 32 | plugins: [ 33 | new webpack.LoaderOptionsPlugin({ 34 | url: { 35 | dataUrlLimit: Infinity, 36 | }, 37 | }), 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /src/tree/assign-keys.ts: -------------------------------------------------------------------------------- 1 | import isValidNode from '~/tree/is-valid-node'; 2 | import getRandomKey from '~/helpers/get-random-key'; 3 | import { mergeNodes } from '~/tree'; 4 | 5 | export default function assignKeys(node: CanvazNode) { 6 | if (!isValidNode(node)) { 7 | if (process.env.NODE_ENV === 'development') { 8 | throw new TypeError('Invalid Canvaz node provided to assignKeys'); 9 | } 10 | } 11 | 12 | const traverse = (node: CanvazNode) => { 13 | const key = 14 | (node.props && node.props.key) || 15 | `${node.type.toLowerCase()}$${getRandomKey()}`; 16 | return mergeNodes(node, { 17 | props: { 18 | key, 19 | id: key, // Keep same key to use in API 20 | }, 21 | children: Array.isArray(node.children) 22 | ? node.children.map(traverse) 23 | : node.children, 24 | }); 25 | }; 26 | 27 | return traverse(node); 28 | } 29 | -------------------------------------------------------------------------------- /webpack/production.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const merge = require('webpack-merge'); 3 | const sharedConfig = require('./shared'); 4 | const { name: packageName } = require('../package.json'); 5 | 6 | const target = process.env.TARGET || 'umd_prod'; 7 | 8 | module.exports = merge(sharedConfig, { 9 | output: { 10 | filename: { 11 | es: `${packageName}.es.js`, 12 | umd_dev: `${packageName}.js`, 13 | umd_prod: `${packageName}.min.js`, 14 | }[target], 15 | library: { 16 | root: 'Canvaz', 17 | umd: packageName, 18 | commonjs: packageName, 19 | }, 20 | libraryTarget: target === 'es' ? 'commonjs-module' : 'umd', 21 | umdNamedDefine: true, 22 | }, 23 | plugins: [ 24 | target === 'umd_prod' && 25 | new webpack.optimize.UglifyJsPlugin({ 26 | comments: false, 27 | sourceMap: false, 28 | }), 29 | ].filter(Boolean), 30 | }); 31 | -------------------------------------------------------------------------------- /src/models/portal.ts: -------------------------------------------------------------------------------- 1 | import { 2 | unstable_renderSubtreeIntoContainer as renderSubtreeIntoContainer, 3 | unmountComponentAtNode, 4 | } from 'react-dom'; 5 | 6 | export default class Portal { 7 | node: HTMLDivElement | null = null; 8 | instance: React.Component; 9 | 10 | constructor(instance: React.Component) { 11 | this.instance = instance; 12 | this.mount(); 13 | } 14 | 15 | mount() { 16 | const node = document.createElement('div'); 17 | node.setAttribute('data-canvaz', 'true'); 18 | document.body.appendChild(node); 19 | this.node = node; 20 | } 21 | 22 | render(element: React.ReactElement) { 23 | if (!this.node) { 24 | throw new Error('Canvaz: mount Portal before rendering to it'); 25 | } 26 | 27 | renderSubtreeIntoContainer(this.instance, element, this.node); 28 | } 29 | 30 | unmount() { 31 | unmountComponentAtNode(this.node); 32 | document.body.removeChild(this.node); 33 | this.node = null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2015-present, Sergey Bekrin 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /webpack/example.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: '#inline-source-map', 6 | devServer: { 7 | hot: true, 8 | contentBase: resolve(__dirname, '..', 'example'), 9 | noInfo: true, 10 | stats: { 11 | colors: true, 12 | }, 13 | }, 14 | entry: './example/index.tsx', 15 | output: { 16 | path: resolve(__dirname, '..', 'example'), 17 | filename: 'bundle.js', 18 | publicPath: '/', 19 | }, 20 | module: { 21 | loaders: [ 22 | { 23 | test: /\.tsx?$/, 24 | exclude: /node_modules/, 25 | loader: 'ts-loader', 26 | }, 27 | ], 28 | }, 29 | resolve: { 30 | alias: { 31 | '~': resolve(__dirname, '..', 'src'), 32 | }, 33 | extensions: ['.js', '.ts', '.tsx'], 34 | }, 35 | plugins: [ 36 | new webpack.LoaderOptionsPlugin({ 37 | debug: true, 38 | url: { 39 | dataUrlLimit: Infinity, 40 | }, 41 | }), 42 | new webpack.HotModuleReplacementPlugin(), 43 | new webpack.NoEmitOnErrorsPlugin(), 44 | ], 45 | }; 46 | -------------------------------------------------------------------------------- /src/models/history.ts: -------------------------------------------------------------------------------- 1 | export default class History { 2 | history = []; 3 | current = 0; 4 | 5 | constructor(initial: T[] = []) { 6 | this.history = initial; 7 | } 8 | 9 | // Push new item to history 10 | push(item: T): void { 11 | this.rewind(); 12 | this.history = [...this.history, item]; 13 | this.current = this.history.length - 1; 14 | } 15 | 16 | // Reset current index 17 | rewind(): void { 18 | this.history = this.history.slice(0, this.current + 1); 19 | this.current = this.history.length - 1; 20 | } 21 | 22 | // Safely set current index 23 | setCurrent(index: number): number { 24 | const current = Math.min(this.history.length - 1, Math.max(0, index)); 25 | this.current = current; 26 | return current; 27 | } 28 | 29 | // Returns current item 30 | getCurrent(): T { 31 | return this.history[this.current]; 32 | } 33 | 34 | // Move forward in history 35 | forward(): boolean { 36 | const index = this.current + 1; 37 | return index === this.setCurrent(index); 38 | } 39 | 40 | // Move backward in history 41 | backward(): boolean { 42 | const index = this.current - 1; 43 | return index === this.setCurrent(index); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/tree/dehydrate.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default function dehydrate( 4 | component: React.ReactElement 5 | ): CanvazNode { 6 | if (!React.isValidElement(component)) { 7 | if (process.env.NODE_ENV === 'development') { 8 | throw new TypeError('Invalid React element provided to `dehydrate`'); 9 | } 10 | } 11 | 12 | const type = 13 | (component.type as React.ComponentClass).displayName || 14 | (component.type as React.StatelessComponent).name || 15 | (component.type as string) || 16 | 'Component'; 17 | const props = { ...component.props }; 18 | 19 | // Security stuff 20 | if (props.dangerouslySetInnerHTML) { 21 | delete props.dangerouslySetInnerHTML; 22 | if (process.env.NODE_ENV === 'development') { 23 | console.error( 24 | '`dangerouslySetInnerHTML` props is ignored due to security reasons. ' + 25 | 'Provide data in different prop and handle it in target component ' + 26 | 'if you need to display raw html.' 27 | ); 28 | } 29 | } 30 | 31 | const children = Boolean(component.props.children) 32 | ? React.Children.toArray(component.props.children).map(dehydrate) 33 | : []; 34 | return { type, props, children }; 35 | } 36 | -------------------------------------------------------------------------------- /src/tree/rehydrate.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import getRandomKey from '~/helpers/get-random-key'; 3 | import isValidNode from '~/tree/is-valid-node'; 4 | 5 | export default function rehydrate( 6 | node: CanvazNode, 7 | components: { [key: string]: React.ComponentType }, 8 | setSchema: (type: string, map: { [key: string]: boolean }) => void 9 | ): React.ReactElement { 10 | if (!isValidNode(node)) { 11 | if (process.env.NODE_ENV === 'development') { 12 | throw new TypeError('Invalid Canvaz node provided to `rehydrate`'); 13 | } 14 | } 15 | 16 | const Component: React.ComponentType | string = 17 | components[node.type] || node.type; 18 | const config: CanvazConfig | null = 19 | typeof Component === 'function' ? (Component as any).canvaz : null; 20 | const props = { ...node.props }; 21 | const children = Array.isArray(node.children) 22 | ? node.children.map(child => rehydrate(child, components, setSchema)) 23 | : node.children; 24 | 25 | if (config) { 26 | // Retrieve data for schema 27 | const allowedChildList = Object.keys(config.accept); 28 | const allowedChildMap = 29 | allowedChildList.length > 0 30 | ? allowedChildList.reduce((map, type) => ({ ...map, [type]: true }), {}) 31 | : {}; 32 | setSchema(node.type, allowedChildMap); 33 | } 34 | 35 | // Signature of React.createElement does't support React.ComponentType 36 | // and string types as union type 37 | return React.createElement(Component as any, props, children); 38 | } 39 | -------------------------------------------------------------------------------- /example/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from 'styled-components'; 2 | 3 | export default css` 4 | -webkit-font-smoothing: antialiased; 5 | font-family: sans-serif; 6 | 7 | input { 8 | margin: 0; 9 | } 10 | 11 | article { 12 | margin: 3rem auto; 13 | max-width: 700px; 14 | } 15 | 16 | hr { 17 | border: none; 18 | height: 3px; 19 | margin: 2rem 0; 20 | background-color: #ddd; 21 | } 22 | 23 | header { 24 | margin: 1em; 25 | 26 | h1 { 27 | font-size: 3.5em; 28 | font-weight: bold; 29 | margin: 0 0 0.5em 0; 30 | } 31 | 32 | p { 33 | font-size: 1.35rem; 34 | line-height: 1.5; 35 | font-style: italic; 36 | color: #666; 37 | margin: 0 0 1rem 0; 38 | } 39 | } 40 | 41 | main { 42 | h2 { 43 | font-size: 1.5rem; 44 | font-weight: bold; 45 | margin: 2rem 1rem 1rem 1rem; 46 | } 47 | 48 | p { 49 | font-size: 1.2rem; 50 | line-height: 1.5; 51 | margin: 1rem; 52 | } 53 | 54 | ol, ul { 55 | list-style-position: inside; 56 | margin: 1rem; 57 | padding: 0; 58 | 59 | li { 60 | padding: 0.5rem; 61 | line-height: 1.25; 62 | } 63 | } 64 | 65 | .video { 66 | text-align: center; 67 | } 68 | 69 | .layout { 70 | margin: 30px 0; 71 | display: flex; 72 | 73 | .column { 74 | flex: 1; 75 | 76 | &:first-of-type { 77 | margin-right: 10px; 78 | } 79 | 80 | &:last-of-type { 81 | margin-left: 10px; 82 | } 83 | } 84 | } 85 | } 86 | `; 87 | -------------------------------------------------------------------------------- /src/media/component.ts: -------------------------------------------------------------------------------- 1 | import { css, keyframes } from 'styled-components'; 2 | 3 | const blinkAnimation = keyframes` 4 | 0% { opacity: 0.2; } 5 | 50% { opacity: 0.8; } 6 | 100% { opacity: 0.2; } 7 | `; 8 | 9 | export function base() { 10 | return css` 11 | // Enable animations 12 | transition-duration: 100ms; 13 | transition-property: outline-color, background-color; 14 | outline: 1px solid transparent; 15 | 16 | // Highlight component on click 17 | :focus { 18 | outline-color: rgb(59, 153, 252); 19 | } 20 | 21 | // Don't let empty components to collapse 22 | :empty:after { 23 | color: black; 24 | font-weight: bold; 25 | text-transform: lowercase; 26 | font-variant: small-caps; 27 | content: '∅ ' attr(aria-label); 28 | display: flex; 29 | height: 100%; 30 | width: 100%; 31 | align-items: center; 32 | justify-content: center; 33 | box-shadow: inset 0 0 0 1px #666; 34 | font-family: sans-serif; 35 | opacity: 0.25; 36 | } 37 | 38 | // Set cursor depending on contenteditable attr 39 | &:not([contenteditable="true"]) { cursor: pointer; } 40 | &[contenteditable="true"] { 41 | user-select: text; 42 | cursor: text; 43 | } 44 | `; 45 | } 46 | 47 | export function hovered() { 48 | return css` 49 | // Visually show component boundary on hover 50 | outline-color: rgba(59, 153, 252, 0.5); 51 | background-color: rgba(59, 153, 252, 0.1); 52 | `; 53 | } 54 | 55 | export function voided() { 56 | return css` 57 | // Disallow iteractions for empty components 58 | * { 59 | pointer-events: none; 60 | } 61 | `; 62 | } 63 | 64 | export function grabbed() { 65 | return css` 66 | animation: ${blinkAnimation} 1s ease infinite; 67 | `; 68 | } 69 | -------------------------------------------------------------------------------- /src/hocs/with-styles.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled, { css } from 'styled-components'; 3 | import isStatelessComponent from '~/helpers/is-stateless-component'; 4 | import convertToClassComponent from '~/helpers/convert-to-class-component'; 5 | import dropCanvazProps from '~/helpers/drop-canvaz-props'; 6 | import getDisplayName from '~/helpers/get-display-name'; 7 | import { EnhanceProps } from '~/hocs/with-enhance'; 8 | import { DataProps } from '~/hocs/with-data'; 9 | import { base, voided, hovered, grabbed } from '~/media/component'; 10 | 11 | export default function withStyles

( 12 | WrappedComponent 13 | ): React.ComponentType

{ 14 | // Convert component to class component to being extended 15 | const ClassComponent: React.ComponentClass< 16 | P & DataProps & EnhanceProps 17 | > = isStatelessComponent(WrappedComponent) 18 | ? convertToClassComponent(WrappedComponent) 19 | : WrappedComponent; 20 | 21 | // Hack render to inject additional props and add className 22 | class EnhancedComponent extends ClassComponent { 23 | static displayName = getDisplayName(WrappedComponent); 24 | 25 | render() { 26 | const element = super.render(); 27 | if (element) { 28 | const { className: originalClassNames = '' } = element.props; 29 | const { enhance, className: injectedClassNames } = this.props; 30 | return enhance(element, { 31 | className: [originalClassNames, injectedClassNames].join(' '), 32 | }); 33 | } 34 | 35 | return element; 36 | } 37 | } 38 | 39 | return styled

(EnhancedComponent)` 40 | // Set grab cursor for non-editable components 41 | [draggable="true"]:not([contenteditable="true"]):active { 42 | cursor: move; 43 | cursor: -moz-grabbing; 44 | cursor: -webkit-grabbing; 45 | } 46 | 47 | ${base} 48 | ${props => props.hovered && hovered} 49 | ${props => props.void && voided} 50 | ${props => props.grabbed && grabbed} 51 | ${props => props.droppable && droppable} 52 | `; 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "canvaz", 3 | "version": "1.0.0-alpha", 4 | "description": "Visual, modular content editor for React", 5 | "main": "lib/index.js", 6 | "module": "dist/canvaz.es.js", 7 | "jsnext:main": "dist/canvaz.es.js", 8 | "scripts": { 9 | "tslint": "tslint --type-check --project tsconfig.json", 10 | "prettier": "prettier --single-quote --trailing-comma es5 --write \"{src,webpack,example}/**/*.js\"", 11 | "start": "cross-env NODE_ENV=development webpack --config webpack/development.js --progress --colors --watch", 12 | "build:es": "cross-env TARGET=es cross-env NODE_ENV=production webpack --config webpack/production.js", 13 | "build:umd-dev": "cross-env TARGET=umd_dev cross-env NODE_ENV=production webpack --config webpack/production.js", 14 | "build:umd-prod": "cross-env TARGET=umd_prod cross-env NODE_ENV=production webpack --config webpack/production.js", 15 | "prebuild:dist": "rimraf ./dist", 16 | "build:dist": "npm run build:es && npm run build:umd-dev && npm run build:umd-prod", 17 | "prebuild:lib": "rimraf ./lib", 18 | "build": "npm run build:lib && npm run build:dist", 19 | "test": "npm run tslint", 20 | "example": "cross-env NODE_ENV=development webpack-dev-server --config webpack/example.js" 21 | }, 22 | "author": "Sergey Bekrin http://bekrin.me", 23 | "repository": "sbekrin/spectro", 24 | "keywords": [ 25 | "react", 26 | "canvaz", 27 | "editor", 28 | "visual", 29 | "modular", 30 | "content" 31 | ], 32 | "files": [ 33 | "lib", 34 | "dist", 35 | "readme.md" 36 | ], 37 | "license": "MIT", 38 | "dependencies": { 39 | "hoist-non-react-statics": "^2.2.1", 40 | "invariant": "^2.2.2", 41 | "styled-components": "^2.1.1" 42 | }, 43 | "devDependencies": { 44 | "@types/node": "^8.0.17", 45 | "@types/react": "^15.0.39", 46 | "@types/react-dom": "^15.5.2", 47 | "cross-env": "^5.0.0", 48 | "express": "^4.15.3", 49 | "prettier": "^1.5.3", 50 | "prop-types": "^15.5.10", 51 | "react": "^15.6.1", 52 | "react-codemirror": "^1.0.0", 53 | "react-dom": "^15.6.1", 54 | "react-highlight": "^0.10.0", 55 | "react-syntax-highlighter": "^5.6.2", 56 | "react-youtube": "^7.4.0", 57 | "rimraf": "^2.6.1", 58 | "ts-loader": "^2.3.1", 59 | "tslint": "^5.5.0", 60 | "tslint-config-prettier": "^1.1.0", 61 | "tslint-react": "^3.1.0", 62 | "typescript": "^2.4.2", 63 | "webpack": "^3.3.0", 64 | "webpack-dev-server": "^2.6.1", 65 | "webpack-merge": "^4.1.0" 66 | }, 67 | "peerDependencies": { 68 | "react": "^15.3.2", 69 | "react-dom": "^15.3.2" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/text-editable.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { string } from 'prop-types'; 3 | import styled, { css } from 'styled-components'; 4 | import withCanvazData, { DataProps } from '~/hocs/with-data'; 5 | import dropCanvazProps from '~/helpers/drop-canvaz-props'; 6 | import chain from '~/helpers/chain'; 7 | import { base, hovered } from '~/media/component'; 8 | 9 | interface TextEditableProps { 10 | onInput?: (event: any) => void; 11 | id?: string; 12 | prop?: string; 13 | } 14 | 15 | interface TextEditableState { 16 | edit: boolean; 17 | } 18 | 19 | class TextEditable extends React.Component< 20 | TextEditableProps & DataProps, 21 | TextEditableState 22 | > { 23 | static defaultProps = { 24 | prop: null, 25 | }; 26 | 27 | nodeRef?: HTMLElement = null; 28 | state = { 29 | edit: false, 30 | }; 31 | 32 | shouldComponentUpdate( 33 | nextProps: TextEditableProps & DataProps, 34 | nextState: TextEditableState 35 | ) { 36 | // Exit early if state is different 37 | if (nextState !== this.state) { 38 | return true; 39 | } 40 | 41 | // Exit then no ref exist yet 42 | if (!this.nodeRef) { 43 | return this.props !== nextProps; 44 | } 45 | 46 | // Compare text with current DOM value to avoid caret jumping 47 | const nextText = nextProps.children.props.children.trim(); 48 | const currentText = this.nodeRef.innerText.trim(); 49 | if (nextText === currentText) { 50 | // Check rest of props 51 | return Boolean( 52 | Object.keys(this.props) 53 | .filter(prop => prop !== 'children') 54 | .find(prop => this.props[prop] !== nextProps[prop]) 55 | ); 56 | } 57 | 58 | return true; 59 | } 60 | 61 | receiveRef = (ref: HTMLElement) => { 62 | this.nodeRef = ref; 63 | }; 64 | 65 | onMouseOver = chain((event: React.MouseEvent) => { 66 | event.stopPropagation(); 67 | }, this.props.onMouseOver); 68 | 69 | onDragStart = chain((event: React.DragEvent) => { 70 | if (this.state.edit) event.stopPropagation(); 71 | }, this.props.onDragStart); 72 | 73 | onDoubleClick = chain((event: React.MouseEvent) => { 74 | this.setState({ edit: true }, () => { 75 | this.nodeRef.focus(); 76 | }); 77 | }, this.props.onDoubleClick); 78 | 79 | onBlur = chain((event: React.FocusEvent) => { 80 | this.setState({ edit: false }); 81 | }, this.props.onBlur); 82 | 83 | onInput = chain((event: React.KeyboardEvent) => { 84 | // Update prop if specified or children by default 85 | const nextText = (event.target as HTMLElement).innerText; 86 | const nextNode = this.props.prop 87 | ? { props: { [this.props.prop]: nextText } } 88 | : { children: nextText }; 89 | this.props.updateNode(nextNode); 90 | }, this.props.onInput); 91 | 92 | onKeyDown = chain((event: React.KeyboardEvent) => { 93 | if (this.state.edit) { 94 | // Prevent Backspace / Delete keys to bubble 95 | switch (event.key) { 96 | case 'Backspace': 97 | case 'Delete': 98 | event.stopPropagation(); 99 | break; 100 | } 101 | } 102 | }, this.props.onKeyDown); 103 | 104 | onKeyPress = chain((event: React.KeyboardEvent) => { 105 | switch (event.key) { 106 | case 'Enter': 107 | event.preventDefault(); 108 | this.props.duplicateNode(); 109 | break; 110 | } 111 | }, this.props.onKeyPress); 112 | 113 | render() { 114 | const { children, prop, tabIndex = 0, ...props } = this.props; 115 | const child = React.Children.only(children); 116 | 117 | // Check if this is styled component 118 | const type = (child as React.ReactElement).type; 119 | const isStyledComponent = Boolean( 120 | typeof type === 'function' && (type as any).styledComponentId 121 | ); 122 | 123 | return this.props.isEditing 124 | ? React.cloneElement(child, { 125 | ...dropCanvazProps(props), 126 | tabIndex, // Allow to focus on non-components as well 127 | [isStyledComponent ? 'innerRef' : 'ref']: this.receiveRef, 128 | contentEditable: this.state.edit, 129 | suppressContentEditableWarning: true, 130 | onMouseOver: this.onMouseOver, 131 | onDragStart: this.onDragStart, 132 | onKeyDown: this.onKeyDown, 133 | onKeyPress: this.onKeyPress, 134 | onDoubleClick: this.onDoubleClick, 135 | onBlur: this.onBlur, 136 | onInput: this.onInput, 137 | }) 138 | : child; 139 | } 140 | } 141 | 142 | const StyledTextEditable = styled(TextEditable)` 143 | :hover { 144 | ${hovered} 145 | } 146 | 147 | ${props => props.isEditing && base} 148 | `; 149 | 150 | export default withCanvazData(StyledTextEditable); 151 | -------------------------------------------------------------------------------- /src/hocs/with-data.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { object, string } from 'prop-types'; 3 | import hoistStatics = require('hoist-non-react-statics'); 4 | import getDisplayName from '~/helpers/get-display-name'; 5 | import getRandomKey from '~/helpers/get-random-key'; 6 | import * as Tree from '~/tree'; 7 | import { CANVAZ_CONTEXT, COMPONENTS_CONTEXT } from '~/constants'; 8 | 9 | export interface DataProps { 10 | id?: string; 11 | isRoot?: boolean; 12 | isEditing?: boolean; 13 | getNode: () => CanvazNode; 14 | getDndDragNode: () => CanvazNode; 15 | getDndTargetNode: () => CanvazNode; 16 | getDndDropIndex: () => number; 17 | canDrop: () => boolean; 18 | proceedDrop: () => void; 19 | updateNode: (data: {}) => CanvazNode; 20 | removeNode: () => CanvazNode; 21 | duplicateNode: () => CanvazNode; 22 | moveNodeAfter: (after: string) => CanvazNode; 23 | [key: string]: any; 24 | } 25 | 26 | export default function withData

( 27 | WrappedComponent: React.ComponentType

28 | ): React.ComponentType

{ 29 | class WithData extends React.Component< 30 | P & { id?: string; isRoot?: boolean } 31 | > { 32 | static wrappedWithCanvaz = true; 33 | static displayName = `withData(${getDisplayName(WrappedComponent)})`; 34 | static WrappedComponent = WrappedComponent; 35 | static contextTypes = { 36 | [CANVAZ_CONTEXT]: object, 37 | }; 38 | 39 | getIndex = (): number => { 40 | return Tree.getNodeIndex( 41 | this.context[CANVAZ_CONTEXT].data, 42 | this.props.id 43 | ); 44 | }; 45 | 46 | getNode = (): CanvazNode => { 47 | return Tree.getNode(this.context[CANVAZ_CONTEXT].data, this.props.id); 48 | }; 49 | 50 | getDndDragNode = (): CanvazNode => { 51 | const canvaz = this.context[CANVAZ_CONTEXT]; 52 | return Tree.getNode(canvaz.data, canvaz.dndDraggedKey); 53 | }; 54 | 55 | getDndTargetNode = (): CanvazNode => { 56 | const canvaz = this.context[CANVAZ_CONTEXT]; 57 | return Tree.getNode(canvaz.data, canvaz.dndTargetKey); 58 | }; 59 | 60 | getDndDropNode = (): CanvazNode => { 61 | const canvaz = this.context[CANVAZ_CONTEXT]; 62 | const node = Tree.getParentNode(canvaz.data, canvaz.dndTargetKey); 63 | const index = this.getDndDropIndex(); 64 | return node ? node.children[index] : canvaz.data; 65 | }; 66 | 67 | getDndDropIndex = (): number => { 68 | return this.context[CANVAZ_CONTEXT].dndDropIndex; 69 | }; 70 | 71 | canDrop = () => { 72 | const canvaz = this.context[CANVAZ_CONTEXT]; 73 | const currentNode = this.getNode(); 74 | const draggedNode = this.getDndDragNode(); 75 | return Boolean( 76 | currentNode && 77 | draggedNode && 78 | canvaz.schema[currentNode.type][draggedNode.type] 79 | ); 80 | }; 81 | 82 | proceedDrop = () => { 83 | const node = this.getDndDragNode(); 84 | const index = this.getDndDropIndex(); 85 | this.insertNodeAt(node, index); 86 | }; 87 | 88 | updateNode = (nextNode: {}): CanvazNode => { 89 | const canvaz = this.context[CANVAZ_CONTEXT]; 90 | const nextData = Tree.updateNode(canvaz.data, this.props.id, nextNode); 91 | return canvaz.setData(nextData); 92 | }; 93 | 94 | removeNode = (): CanvazNode => { 95 | const canvaz = this.context[CANVAZ_CONTEXT]; 96 | const nextData = Tree.removeNode(canvaz.data, this.props.id); 97 | return canvaz.setData(nextData); 98 | }; 99 | 100 | duplicateNode = (): CanvazNode => { 101 | const canvaz = this.context[CANVAZ_CONTEXT]; 102 | const currentNode = this.getNode(); 103 | const key = getRandomKey(); 104 | const node = Tree.mergeNodes(currentNode, { 105 | props: { key, id: key }, 106 | children: '', 107 | }); 108 | const nextData = Tree.insertNodeAfter(canvaz.data, this.props.id, node); 109 | return canvaz.setData(nextData); 110 | }; 111 | 112 | insertNodeAt = (node: CanvazNode, index: number = 0) => { 113 | const canvaz = this.context[CANVAZ_CONTEXT]; 114 | const cleanedData = Tree.removeNode(canvaz.data, node.props.id); 115 | const key = this.props.id; 116 | const nextData = Tree.insertNodeAtIndex(cleanedData, key, node, index); 117 | return canvaz.setData(nextData); 118 | }; 119 | 120 | render() { 121 | const props = { 122 | isEditing: this.context[CANVAZ_CONTEXT].editing, 123 | getIndex: this.getIndex, 124 | getNode: this.getNode, 125 | getDndTargetNode: this.getDndTargetNode, 126 | getDndDragNode: this.getDndDragNode, 127 | getDndDropNode: this.getDndDropNode, 128 | getDndDropIndex: this.getDndDropIndex, 129 | canDrop: this.canDrop, 130 | proceedDrop: this.proceedDrop, 131 | updateNode: this.updateNode, 132 | insertNodeAt: this.insertNodeAt, 133 | 134 | // Add non-root-specific props 135 | ...!this.props.isRoot && { 136 | removeNode: this.removeNode, 137 | duplicateNode: this.duplicateNode, 138 | }, 139 | }; 140 | 141 | return ; 142 | } 143 | } 144 | 145 | return hoistStatics(WithData, WrappedComponent); 146 | } 147 | -------------------------------------------------------------------------------- /src/tree/index.ts: -------------------------------------------------------------------------------- 1 | /** Checks if node is root node */ 2 | export function isRootNode(tree: CanvazNode, key: string) { 3 | return tree.props.key === key; 4 | } 5 | 6 | /** Finds node in tree by its key */ 7 | export function getNode(tree: CanvazNode, key: string) { 8 | const traverse = (node: CanvazNode): CanvazNode => { 9 | // Return current node if found 10 | if (node.props.key === key) { 11 | return node; 12 | } 13 | 14 | // Iterate through children 15 | if (Array.isArray(node.children)) { 16 | return node.children.map(traverse).find(Boolean); 17 | } 18 | 19 | return null; 20 | }; 21 | 22 | return traverse(tree); 23 | } 24 | 25 | /** Get parent node */ 26 | export function getParentNode(tree: CanvazNode, childKey: string) { 27 | const traverse = (node: CanvazNode): CanvazNode => { 28 | if (Array.isArray(node.children)) { 29 | const hasChild = Boolean( 30 | node.children.find(({ props }) => props.key === childKey) 31 | ); 32 | 33 | if (hasChild) { 34 | return node; 35 | } 36 | 37 | return node.children.map(traverse).find(Boolean); 38 | } 39 | 40 | return null; 41 | }; 42 | 43 | return traverse(tree); 44 | } 45 | 46 | /** Returns index of node relative to parent's children array */ 47 | export function getNodeIndex(tree: CanvazNode, key: string): number { 48 | // Check root node 49 | if (tree.props.key === key) { 50 | return 0; 51 | } 52 | 53 | const traverse = (node: CanvazNode): number => { 54 | if (Array.isArray(node.children)) { 55 | const index = node.children.findIndex(child => child.props.key === key); 56 | 57 | // If found 58 | if (index > -1) { 59 | return index; 60 | } 61 | 62 | // Iterate children if not yet found 63 | return node.children.map(traverse).find(index => index > -1); 64 | } 65 | 66 | return -1; 67 | }; 68 | 69 | return traverse(tree); 70 | } 71 | 72 | /** Replaces node with new one */ 73 | export function replaceNode( 74 | tree: CanvazNode, 75 | key: string, 76 | newNode: CanvazNode 77 | ) { 78 | const traverse = (node: CanvazNode): CanvazNode => { 79 | // Repalce node if found 80 | if (node.props.key === key) { 81 | return newNode; 82 | } 83 | 84 | // Iterate through children 85 | if (Array.isArray(node.children)) { 86 | return mergeNodes(node, { 87 | children: node.children.map(traverse).filter(Boolean), 88 | }); 89 | } 90 | 91 | return node; 92 | }; 93 | 94 | return traverse(tree); 95 | } 96 | 97 | /** Merges two nodes into one */ 98 | export function mergeNodes( 99 | leftNode: { 100 | type?: string; 101 | props?: { [key: string]: any }; 102 | children?: CanvazNode[] | string | number; 103 | }, 104 | rightNode: { 105 | type?: string; 106 | props?: { [key: string]: any }; 107 | children?: CanvazNode[] | string | number; 108 | } 109 | ): CanvazNode { 110 | const type = rightNode.type || leftNode.type; 111 | const props = { ...leftNode.props || {}, ...rightNode.props || {} }; 112 | const children = 113 | rightNode.children === undefined ? leftNode.children : rightNode.children; 114 | return { type, props, children }; 115 | } 116 | 117 | /** Updates node with new data */ 118 | export function updateNode( 119 | tree: CanvazNode, 120 | key: string, 121 | nextNode: { 122 | type?: string; 123 | props?: { [key: string]: any }; 124 | children?: CanvazNode[] | string | number; 125 | } 126 | ) { 127 | const prevNode = getNode(tree, key); 128 | if (!prevNode) { 129 | throw new Error(`No Node found with such id: ${key}`); 130 | } 131 | return replaceNode(tree, key, mergeNodes(prevNode, nextNode)); 132 | } 133 | 134 | /** Removes node from tree */ 135 | export function removeNode(tree: CanvazNode, key: string) { 136 | return replaceNode(tree, key, null); 137 | } 138 | 139 | /** Inserts node inside node with target key at specific index */ 140 | export function insertNode( 141 | tree: CanvazNode, 142 | parentKey: string, 143 | nodeToInsert: CanvazNode, 144 | atIndex: number = -1 145 | ) { 146 | const traverse = (node: CanvazNode): CanvazNode => { 147 | if (Array.isArray(node.children)) { 148 | // Immutably modify children 149 | const children = node.children; 150 | if (node.props.key === parentKey) { 151 | return mergeNodes(node, { 152 | children: [ 153 | ...children.slice(0, atIndex), 154 | nodeToInsert, 155 | ...children.slice(atIndex, children.length), 156 | ], 157 | }); 158 | } 159 | 160 | return mergeNodes(node, { 161 | children: node.children.map(traverse), 162 | }); 163 | } 164 | 165 | return node; 166 | }; 167 | 168 | return traverse(tree); 169 | } 170 | 171 | /** Insert node at specific position of node with provided key */ 172 | export function insertNodeAtIndex( 173 | tree: CanvazNode, 174 | parentKey: string, 175 | nodeToInsert: CanvazNode, 176 | atIndex: number 177 | ) { 178 | const targetNode = getNode(tree, parentKey); 179 | if (targetNode) { 180 | return insertNode(tree, parentKey, nodeToInsert, atIndex); 181 | } 182 | return tree; 183 | } 184 | 185 | /** Insert node before one with provided key */ 186 | export function insertNodeBefore( 187 | tree: CanvazNode, 188 | targetKey: string, 189 | node: CanvazNode 190 | ) { 191 | const parentNode = getParentNode(tree, targetKey); 192 | if (!parentNode) { 193 | return tree; 194 | } 195 | 196 | const parentKey = parentNode.props.key; 197 | const childIndex = getNodeIndex(tree, targetKey) - 1; 198 | return insertNodeAtIndex(tree, parentKey, node, childIndex); 199 | } 200 | 201 | /** Insert node after one with provided key */ 202 | export function insertNodeAfter( 203 | tree: CanvazNode, 204 | targetKey: string, 205 | node: CanvazNode 206 | ) { 207 | const parentNode = getParentNode(tree, targetKey); 208 | if (!parentNode) { 209 | return tree; 210 | } 211 | 212 | const parentKey = parentNode.props.key; 213 | const childIndex = getNodeIndex(tree, targetKey) + 1; 214 | return insertNodeAtIndex(tree, parentKey, node, childIndex); 215 | } 216 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable jsx-no-lambda */ 2 | import * as React from 'react'; 3 | import { render } from 'react-dom'; 4 | import styled from 'styled-components'; 5 | import YouTube from 'react-youtube'; 6 | import styles from './styles'; 7 | import withCanvaz, { 8 | RehydrationProvider, 9 | CanvazContainer, 10 | TextEditable, 11 | } from '../src'; 12 | 13 | /* 14 | * Step 1: 15 | * 16 | * Define editor components. They are used 17 | * both for final rendering and while editing 18 | * content 19 | */ 20 | const Heading = withCanvaz({ 21 | label: 'Heading', 22 | })(({ id, children }) => 23 | 24 |

25 | {children} 26 |

27 | 28 | ); 29 | 30 | const Text = withCanvaz({ 31 | label: 'Text', 32 | })(({ id, children }: any) => 33 | 34 |

35 | {children} 36 |

37 |
38 | ); 39 | 40 | const Video = withCanvaz({ 41 | label: 'YouTube Video', 42 | void: true, 43 | })(({ videoId }) => 44 |
45 | 46 |
47 | ); 48 | 49 | const ListItem = withCanvaz({ 50 | label: 'List Item', 51 | })(({ children, id }) => 52 | 53 |
  • 54 | {children} 55 |
  • 56 |
    57 | ); 58 | 59 | const List = withCanvaz({ 60 | label: 'List', 61 | accept: { ListItem }, 62 | })( 63 | ({ children, ordered = true }) => 64 | ordered 65 | ?
      66 | {children} 67 |
    68 | :
      69 | {children} 70 |
    71 | ); 72 | 73 | const Column = withCanvaz({ 74 | label: 'Column', 75 | accept: { Heading, Text, Video, List }, 76 | })(({ children }) => 77 |
    78 | {children} 79 |
    80 | ); 81 | 82 | const Layout = withCanvaz({ 83 | label: 'Layout', 84 | accept: { Column }, 85 | })(({ children }) => 86 |
    87 | {children} 88 |
    89 | ); 90 | 91 | const Article = withCanvaz({ 92 | label: 'Article', 93 | accept: { Heading, Text, Video, Layout, List }, 94 | })(({ id, title, description, children }) => 95 |
    96 |
    97 | 98 |

    99 | {title} 100 |

    101 |
    102 | 103 |

    104 | {description} 105 |

    106 |
    107 |
    108 |
    109 |
    110 | {children} 111 |
    112 |
    113 | ); 114 | 115 | /** 116 | * Step 2: 117 | * 118 | * Create root editor component with some initial 119 | * data tree (e.g. JSON from server) and 120 | * provide set of components which required 121 | * to deserialize editor tree. 122 | */ 123 | class App extends React.Component { 124 | render() { 125 | return ( 126 |
    127 | 139 | console.log(data)} 141 | edit={true} 142 | data={{ 143 | type: 'Article', 144 | props: { 145 | title: 'Beautiful content starts with beautiful editor', 146 | description: [ 147 | 'Everyone used to visual text editors.', 148 | 'They exists for a long time, but we need to move forward.', 149 | 'It should be easy and accessible for everyone to craft', 150 | 'content without special knowledge, with just right tool.', 151 | ].join(' '), 152 | }, 153 | children: [ 154 | { type: 'Heading', children: 'Switching to modular' }, 155 | { 156 | type: 'Text', 157 | children: [ 158 | 'Even though text-manipulation is intuitive,', 159 | 'it is limited. User interfaces are built with', 160 | 'component-based architecture nowadays, so why not adopt', 161 | 'it for content we make?', 162 | ].join(' '), 163 | }, 164 | { 165 | type: 'Text', 166 | children: [ 167 | 'Go ahead and play around with this content.', 168 | 'You can edit any text with double click,', 169 | 'remove block by Delete or Backspace and drag-and-drop', 170 | 'components. Rollback your changes by Ctrl + Z or Cmd + Z.', 171 | ].join(' '), 172 | }, 173 | { 174 | type: 'Heading', 175 | children: 'Below is stuff for testing', 176 | }, 177 | { 178 | type: 'List', 179 | props: { 180 | ordered: true, 181 | }, 182 | children: [ 183 | { type: 'ListItem', children: 'First item' }, 184 | { type: 'ListItem', children: 'Second item' }, 185 | { type: 'ListItem', children: 'Third item' }, 186 | ], 187 | }, 188 | { 189 | type: 'Layout', 190 | children: [ 191 | { 192 | type: 'Column', 193 | children: [{ type: 'Text', children: 'Left Side' }], 194 | }, 195 | { 196 | type: 'Column', 197 | children: [{ type: 'Text', children: 'Right Side' }], 198 | }, 199 | ], 200 | }, 201 | { type: 'Video', props: { videoId: 'YE7VzlLtp-4' } }, 202 | ], 203 | }} 204 | /> 205 | 206 |
    207 | ); 208 | } 209 | } 210 | 211 | const StyledApp = styled(App)`${styles}`; 212 | 213 | /** 214 | * Step 3: 215 | * 216 | * Render it! 217 | */ 218 | render(, document.querySelector('[data-approot]')); 219 | -------------------------------------------------------------------------------- /src/hocs/with-enhance.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { object, string } from 'prop-types'; 3 | import hoistStatics = require('hoist-non-react-statics'); 4 | import styled, { css } from 'styled-components'; 5 | import CanvazContainer from '~/containers/canvaz-container'; 6 | import convertToClassComponent from '~/helpers/convert-to-class-component'; 7 | import getDisplayName from '~/helpers/get-display-name'; 8 | import chain from '~/helpers/chain'; 9 | import withData, { DataProps } from '~/hocs/with-data'; 10 | import { DND_START, DND_OVER, DND_END } from '~/constants'; 11 | 12 | const configDefaults: CanvazConfig = { 13 | accept: {}, 14 | void: false, 15 | }; 16 | 17 | export interface EnhanceProps { 18 | void: boolean; 19 | hovered: boolean; 20 | selected: boolean; 21 | enhance: ( 22 | element: React.ReactElement, 23 | overrides?: {} 24 | ) => React.ReactElement; 25 | [key: string]: any; 26 | } 27 | 28 | interface CanvazState { 29 | hovered: boolean; 30 | selected: boolean; 31 | grabbed: boolean; 32 | } 33 | 34 | export default function enhanceWithCanvaz

    ( 35 | userConfig: CanvazConfig = {} 36 | ): ( 37 | WrappedComponent: React.ComponentType

    38 | ) => React.ComponentType

    { 39 | const config = { ...configDefaults, ...userConfig }; 40 | return WrappedComponent => { 41 | const displayName = getDisplayName(WrappedComponent); 42 | class WithEnhance extends React.Component< 43 | P & DataProps & EnhanceProps, 44 | CanvazState 45 | > { 46 | static displayName = `withCanvaz(${displayName})`; 47 | static WrappedComponent = WrappedComponent; 48 | static canvaz = config; 49 | 50 | state = { 51 | hovered: false, 52 | selected: false, 53 | grabbed: false, 54 | }; 55 | 56 | onDragStart = (event: React.DragEvent) => { 57 | event.stopPropagation(); 58 | event.dataTransfer.setData('text/plain', this.props.children as string); 59 | event.dataTransfer.effectAllowed = 'move'; 60 | CanvazContainer.createPlaceholder(this); 61 | CanvazContainer.broadcast(DND_START, { key: this.props.id }); 62 | this.setState({ grabbed: true }); 63 | }; 64 | 65 | onDragEnter = (event: React.DragEvent) => { 66 | event.preventDefault(); 67 | 68 | if (this.props.canDrop()) { 69 | event.stopPropagation(); 70 | event.dataTransfer.dropEffect = 'move'; 71 | return; 72 | } 73 | 74 | // Update placeholder on last drag overed node 75 | CanvazContainer.broadcast(DND_OVER, { 76 | index: this.props.getIndex(), 77 | key: this.props.id, 78 | }); 79 | }; 80 | 81 | onDragOver = (event: React.DragEvent) => { 82 | event.preventDefault(); 83 | 84 | if (this.props.canDrop()) { 85 | event.stopPropagation(); 86 | 87 | // Use less hacky way of getting current node? 88 | const targetNode = this.props.getDndDropNode(); 89 | const hasChildren = targetNode.children.length > 0; 90 | 91 | // Render placeholder 92 | if (hasChildren) { 93 | const target = document.querySelector( 94 | `[data-canvaz-id="${targetNode.props.id}"]` 95 | ); 96 | const box = target.getBoundingClientRect(); 97 | const width = Math.floor(box.width); 98 | const height = Math.floor(box.height); 99 | const top = Math.floor(box.top + window.scrollY); 100 | const left = Math.floor(box.left + window.scrollX); 101 | const shouldDropAfter = event.pageY - height / 2 > top; 102 | const calculatedTop = top + (shouldDropAfter ? height : 0); 103 | CanvazContainer.movePlaceholder(calculatedTop, left, width); 104 | } else { 105 | // TODO: Highlight node as dropzone 106 | } 107 | } 108 | }; 109 | 110 | onDragEnd = (event: React.DragEvent) => { 111 | CanvazContainer.destroyPlaceholder(); 112 | CanvazContainer.broadcast(DND_END, { key: this.props.id }); 113 | this.setState({ grabbed: false }); 114 | }; 115 | 116 | onDrop = (event: React.DragEvent) => { 117 | event.preventDefault(); 118 | if (this.props.canDrop()) { 119 | event.stopPropagation(); 120 | this.props.proceedDrop(); 121 | CanvazContainer.destroyPlaceholder(); 122 | } 123 | }; 124 | 125 | onMouseOver = (event: React.MouseEvent) => { 126 | event.stopPropagation(); 127 | this.setState({ hovered: true }); 128 | }; 129 | 130 | onMouseOut = (event: React.MouseEvent) => { 131 | this.setState({ hovered: false }); 132 | }; 133 | 134 | onKeyDown = (event: React.KeyboardEvent) => { 135 | // Delete node on delete or backspace 136 | switch (event.key) { 137 | case 'Delete': 138 | case 'Backspace': 139 | event.stopPropagation(); 140 | this.props.removeNode(); 141 | break; 142 | } 143 | }; 144 | 145 | enhance = (element: React.ReactElement, overrides: {} = {}) => { 146 | // Do nothing if in view mode 147 | if (!this.props.isEditing) return element; 148 | 149 | const ariaProps = { 150 | 'aria-label': config.label, 151 | 'aria-dropeffect': this.props.canDrop() ? 'move' : 'none', 152 | 'aria-grabbed': this.state.grabbed.toString(), 153 | 'data-canvaz-id': this.props.id, 154 | }; 155 | 156 | return React.cloneElement(element, { 157 | ...overrides, 158 | ...ariaProps, 159 | tabIndex: this.props.tabIndex || 0, 160 | onMouseOver: this.onMouseOver, 161 | onMouseOut: this.onMouseOut, 162 | onDragEnter: this.onDragEnter, 163 | onDragOver: this.onDragOver, 164 | onDragEnd: this.onDragEnd, 165 | onDrop: this.onDrop, 166 | 167 | // Apply non-root-specific props 168 | ...!this.props.isRoot && { 169 | draggable: true, 170 | onDragStart: this.onDragStart, 171 | onKeyDown: this.onKeyDown, 172 | }, 173 | }); 174 | }; 175 | 176 | render() { 177 | const propsForStyling = { 178 | void: config.void, 179 | hovered: this.state.hovered, 180 | selected: this.state.selected, 181 | grabbed: this.state.grabbed, 182 | }; 183 | 184 | return ( 185 | 190 | ); 191 | } 192 | } 193 | 194 | return hoistStatics(WithEnhance, WrappedComponent); 195 | }; 196 | } 197 | -------------------------------------------------------------------------------- /src/containers/canvaz-container.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { object } from 'prop-types'; 3 | import DropPlaceholder from '~/components/drop-placeholder'; 4 | import isValidMessage from '~/helpers/is-valid-message'; 5 | import getCurrentOrigin from '~/helpers/get-current-origin'; 6 | import rehydrate from '~/tree/rehydrate'; 7 | import assignKeys from '~/tree/assign-keys'; 8 | import isValidNode from '~/tree/is-valid-node'; 9 | import Portal from '~/models/Portal'; 10 | import History from '~/models/history'; 11 | import { HISTORY_CONTAINER } from '~/constants'; 12 | import { 13 | CANVAZ_CONTEXT, 14 | COMPONENTS_CONTEXT, 15 | DND_START, 16 | DND_OVER, 17 | DND_END, 18 | noop, 19 | } from '~/constants'; 20 | 21 | interface ContainerProps { 22 | data: CanvazNode; 23 | onChange?: (newData: CanvazNode) => void; 24 | edit?: boolean; 25 | history?: boolean; 26 | children?: any; 27 | } 28 | 29 | interface ContainerState { 30 | data: CanvazNode; 31 | history: History; 32 | dndDraggedKey: string | null; 33 | dndTargetKey: string | null; 34 | dndDropIndex: number; 35 | } 36 | 37 | export default class CanvazContainer extends React.Component< 38 | ContainerProps, 39 | ContainerState 40 | > { 41 | static placeholder: Portal = null; 42 | static defaultProps = { 43 | edit: false, 44 | history: true, 45 | setData: noop, 46 | }; 47 | 48 | static contextTypes = { 49 | [COMPONENTS_CONTEXT]: object, 50 | }; 51 | 52 | static childContextTypes = { 53 | [COMPONENTS_CONTEXT]: object.isRequired, 54 | [CANVAZ_CONTEXT]: object.isRequired, 55 | }; 56 | 57 | static broadcast = (type, data) => { 58 | window.postMessage({ ...data, type, canvaz: true }, getCurrentOrigin()); 59 | }; 60 | 61 | static createPlaceholder = instance => { 62 | if (!CanvazContainer.placeholder) { 63 | CanvazContainer.placeholder = new Portal(instance); 64 | } 65 | }; 66 | 67 | static movePlaceholder = (top: number, left: number, width: number) => { 68 | if (CanvazContainer.placeholder) { 69 | CanvazContainer.placeholder.render( 70 | React.createElement(DropPlaceholder, { style: { top, left, width } }) 71 | ); 72 | } 73 | }; 74 | 75 | static destroyPlaceholder = () => { 76 | if (CanvazContainer.placeholder) { 77 | CanvazContainer.placeholder.unmount(); 78 | CanvazContainer.placeholder = null; 79 | } 80 | }; 81 | 82 | schema: { 83 | [key: string]: { 84 | [key: string]: boolean; 85 | }; 86 | } = {}; 87 | 88 | constructor(props: ContainerProps, context: {}) { 89 | super(props, context); 90 | const data = assignKeys(props.data); 91 | this.state = { 92 | data, 93 | history: new History([data]), 94 | dndDraggedKey: null, 95 | dndTargetKey: null, 96 | dndDropIndex: -1, 97 | }; 98 | } 99 | 100 | getChildContext() { 101 | const { data, dndDraggedKey, dndTargetKey, dndDropIndex } = this.state; 102 | const { schema } = this; 103 | return { 104 | ...this.context, 105 | [CANVAZ_CONTEXT]: { 106 | schema, 107 | data, 108 | dndDraggedKey, 109 | dndTargetKey, 110 | dndDropIndex, 111 | editing: this.props.edit, 112 | setData: this.setData, 113 | undo: this.undo, 114 | redo: this.redo, 115 | }, 116 | }; 117 | } 118 | 119 | componentDidMount() { 120 | if (global[HISTORY_CONTAINER]) { 121 | if (process.env.NODE_ENV === 'development') { 122 | console.warn( 123 | 'Canvaz: Keyboard control is disabled because another container ' + 124 | 'already registered it.' 125 | ); 126 | } 127 | return; 128 | } 129 | 130 | window.addEventListener('message', this.onMessage, false); 131 | window.addEventListener('keydown', this.onKeyDown, false); 132 | global[HISTORY_CONTAINER] = this; 133 | } 134 | 135 | componentWillUnmount() { 136 | if (global[HISTORY_CONTAINER] === this) { 137 | window.removeEventListener('message', this.onMessage, false); 138 | window.removeEventListener('keydown', this.onKeyDown, false); 139 | delete global[HISTORY_CONTAINER]; 140 | } 141 | } 142 | 143 | onMessage = (event: MessageEvent) => { 144 | if (!isValidMessage(event)) return; 145 | 146 | switch (event.data.type) { 147 | case DND_START: 148 | this.setState({ dndDraggedKey: event.data.key }); 149 | break; 150 | 151 | case DND_OVER: 152 | this.setState({ 153 | dndTargetKey: event.data.key, 154 | dndDropIndex: event.data.index, 155 | }); 156 | break; 157 | 158 | case DND_END: 159 | this.setState({ 160 | dndTargetKey: null, 161 | dndDropIndex: -1, 162 | dndDraggedKey: null, 163 | }); 164 | break; 165 | } 166 | }; 167 | 168 | onKeyDown = (event: KeyboardEvent) => { 169 | const isZKey = event.keyCode === 90; 170 | const isYKey = event.keyCode === 89; 171 | const isMacUndo = event.metaKey && isZKey; 172 | const isWinUndo = event.ctrlKey && isZKey; 173 | const isMacRedo = event.shiftKey && event.metaKey && isZKey; 174 | const isWinRedo = event.ctrlKey && isYKey; 175 | 176 | if (isMacRedo || isWinRedo) { 177 | this.redo(); 178 | } else if (isMacUndo || isWinUndo) { 179 | this.undo(); 180 | } 181 | }; 182 | 183 | // Trigger callback with latest data 184 | notifyDataChange = () => { 185 | this.props.onChange(this.state.data); 186 | }; 187 | 188 | // Check if history is eanbled and if can go back 189 | undo = () => { 190 | if (this.props.history && this.state.history.backward()) { 191 | this.setDataUnsafe(this.state.history.getCurrent()); 192 | } 193 | }; 194 | 195 | // Check if history is enabled and if can go forward 196 | redo = () => { 197 | if (this.props.history && this.state.history.forward()) { 198 | this.setDataUnsafe(this.state.history.getCurrent()); 199 | } 200 | }; 201 | 202 | // Sets data without node check and no history register 203 | setDataUnsafe = (data: CanvazNode): CanvazNode => { 204 | this.setState({ data }, this.notifyDataChange); 205 | return data; 206 | }; 207 | 208 | setData = (data: CanvazNode): CanvazNode => { 209 | if (process.env.NODE_ENV === 'development') { 210 | if (!isValidNode(data)) { 211 | throw new TypeError( 212 | 'Invalid data provided to setData. Valid Canvaz node expected.' 213 | ); 214 | } 215 | } 216 | 217 | // Keep change in history if enabled 218 | if (this.props.history) { 219 | this.state.history.push(data); 220 | } 221 | 222 | this.setState( 223 | { data: this.state.history.getCurrent() }, 224 | this.notifyDataChange 225 | ); 226 | 227 | return data; 228 | }; 229 | 230 | setSchema = (type: string, map: { [key: string]: boolean }) => { 231 | if (this.schema[type]) { 232 | return; 233 | } 234 | 235 | this.schema = { 236 | ...this.schema, 237 | [type]: map, 238 | }; 239 | }; 240 | 241 | render() { 242 | if (this.props.children) { 243 | if (process.env.NODE_ENV === 'development') { 244 | console.warn( 245 | "CanvazContainer does't support children rendering, provide `data` " + 246 | 'prop instead' 247 | ); 248 | } 249 | } 250 | 251 | return rehydrate( 252 | this.state.data, 253 | this.context[COMPONENTS_CONTEXT], 254 | this.setSchema 255 | ); 256 | } 257 | } 258 | --------------------------------------------------------------------------------