├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGES.md ├── README.md ├── demo ├── demo.js ├── fixture.js └── gremlin-prosemirror.js ├── gh-pages.sh ├── index.html ├── jsconfig.json ├── karma.conf.js ├── package.json ├── src ├── components │ ├── add-cover.js │ ├── add-fold.js │ ├── app.css │ ├── app.js │ ├── attribution-editor.css │ ├── attribution-editor.js │ ├── attribution-view.js │ ├── button-confirm.js │ ├── credit-add.js │ ├── credit-editor.js │ ├── dropdown-group.js │ ├── editable-feature-flags.css │ ├── editable-menu.css │ ├── editable.css │ ├── editable.js │ ├── icons.js │ ├── image-editor.js │ ├── image.css │ ├── image.js │ ├── modal.css │ ├── modal.js │ ├── nav-item-confirm.js │ ├── placeholder.js │ ├── rebass-theme.js │ ├── textarea-autosize.css │ ├── textarea-autosize.js │ ├── widget-cta-view.js │ ├── widget-cta.js │ ├── widget-edit.js │ ├── widget-iframe.js │ ├── widget-unsupported.js │ ├── widget-view.js │ └── widget.js ├── convert │ ├── determine-fold.js │ ├── doc-to-grid.js │ ├── grid-to-doc.js │ ├── space-content.js │ └── types.js ├── ed.js ├── inputrules │ ├── ed-input-rules.js │ ├── ed-keymap.js │ ├── input-code.js │ └── ios-double-space.js ├── menu │ ├── ed-menu.js │ ├── menu-image.js │ ├── menu-link.js │ ├── menu-media.js │ └── menu-prompt.js ├── plugins │ ├── commands-interface.js │ ├── content-hints.js │ ├── fixed-menu-hack.js │ ├── iframe-info.js │ ├── placeholder.js │ ├── share-url.js │ └── store-ref.js ├── schema │ ├── block-meta.js │ ├── ed-schema.js │ ├── media.css │ └── media.js ├── store │ └── ed-store.js └── util │ ├── browser.js │ ├── drop.js │ ├── encode.js │ ├── lodash.js │ ├── pm.js │ └── url.js ├── targets ├── web-demo.html └── web.html ├── test ├── .eslintrc ├── components │ ├── image.js │ └── widget-cta.js ├── convert │ ├── determine-fold.js │ ├── doc-to-grid.js │ └── grid-to-doc.js ├── ed.js ├── index.html ├── index.js ├── plugins │ ├── placeholder.js │ ├── widget-flagged.js │ ├── widget-iframe.js │ └── widget.js └── schema │ └── block-meta.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "ecmaFeatures": { "modules": true }, 4 | "env": { 5 | "browser": true, 6 | "node": true, 7 | "es6": true 8 | }, 9 | "rules": { 10 | "no-multiple-empty-lines": [2, {"max": 2, "maxEOF": 1}], 11 | "comma-dangle": [2, "always-multiline"], 12 | "no-console": 2, 13 | "camelcase": 0 14 | }, 15 | "plugins": ["react"], 16 | "extends": ["standard", "plugin:react/recommended"] 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log* 3 | node_modules 4 | dist 5 | .DS_Store 6 | 7 | *.orig 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log* 3 | node_modules 4 | !dist/node_modules/ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 7.4 4 | before_install: 5 | - export CHROME_BIN=chromium-browser 6 | - export DISPLAY=:99.0 7 | - sh -e /etc/init.d/xvfb start 8 | script: npm test 9 | before_deploy: npm run build 10 | deploy: 11 | provider: npm 12 | skip_cleanup: true 13 | email: forrest@sembiki.com 14 | api_key: 15 | secure: KwIWPYT125xqr8CWMuMadU5gkh183Klp2lRuyH73q5h0nVH+OzwcxOVWirdCKX6UA3d2cT4fs28OaH58apvlDkqrDyE8j3YENFi7W5fyWr1lQlgNqD4pEIrBXQF1OP9A0yUDE9pZebQ+PiMvQ665qkl2GEKFaYjI/57vDO0jKaDIiGihrAQR1hRCIWGcWxTigq6vagmcYHVS/8udzqqxce6Cx9nJhHzHTc2ARPND0c/h6jySzZIzZCG781j0v0TWwzew20ibIzWhRrqq0l0lDFZD0usRXTzH8Bc6mDPX13jKvXJfm138iX5K/rRoL7lnBi9KTXMANNh/tR++tK4LJDr2226eapOFg6z1mhMAwhSXiDSf/NSbJtQvSKT9vCri4cDglKBqgE9H2PBrgH0FhPYmSzTcD59LQqq3yt5Sa1xKRmjidrmltbK+yjfPShr3huS2Vhj4aFGyIVxVw+YSFwUhTIg9/e6S42REkxS7zIvbNuRHBtlUSpB5ZdqaM7qMcavU8gsm38JRSqxgZR0XcCo5rIrOfG+A80B3xsGxyGf0+jjVv5WUAITDmcPw0GTe5G0H/yOf94FG1YCNLjel4NZUn3eBTghrYtH3WM95D6ua7kSowXs7OTWHLCAWgKk79lKekVhznYHtMyG82Rk9VJfJlav6/l1d85/n3enqymM= 16 | on: 17 | tags: true 18 | repo: the-grid/ed 19 | after_script: bash ./gh-pages.sh 20 | env: 21 | global: 22 | - GH_REF: github.com/the-grid/ed.git 23 | - secure: Pz7NfvRl0OWA1Jmwzjdzt5Ug8TCVw8x44JbmniBhiMooh7/hFAVN9b6RvCWPx/7eqf+uQXiyaFfG9UcXQNwLo9cEkvwWlEyhSW/k7sMMyddl3rOK8zf9PfmoNU9K6Q0t3YPvYOAXTe2Ma43d/4OEJY1JgbtlooDldjhHqQqIiFuPxUhk7rfLdtpfI4mijVt7wjSmY7pPg2jtddRfNFExAk0p4KZOSSQlrn6R9tdIMeSALRfo7oulKheGq0V5CLTaX5OlRojeI5UIrGPJpIk4QbYaN19u6opGetlLzQ1qqyiSs8pDL8XXV55GPyPjB8m4iEJXnAjIDgqc5LTtDdhRpmlti+23xX5GKIto9/uj4TU/knouuAs54lcXgwuyBT+G8MLDsED3Xzpxmw8JmvpKiiilRwn58bT1SZ2BHkJdvB/qnzQQ7G4726OuAo0rsMR3S8eRk3ysnf4Vva+rHtA26zuYeIZlP0GAkZCjs45r56fJVS0hvLqPANBrhZETdxBKOyRo64KAft0ql3oQUpdXmuwY4IbhKDKtYDjlQe3J262wLBnSo878RFWoB6YTvVxojhOv98nc2yuTcUjX8n72fDtnceSFkj4x4pMRmg6a+eAwN0Qq28BwlMm+H1913AGQ3wsVNakwQ5CSWU8C5LuacUd0HOV3q2PfhcroVnWNmhc= 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `npm start` 2 | 3 | # ed 4 | 5 | [![Build Status](https://travis-ci.org/the-grid/ed.svg?branch=master)](https://travis-ci.org/the-grid/ed) 6 | 7 | Using [ProseMirror](http://prosemirror.net/) with data from [the Grid API](http://developer.thegrid.io/) 8 | 9 | Demo: [the-grid.github.io/ed/](https://the-grid.github.io/ed/), [with fixture](https://the-grid.github.io/ed/#fixture) 10 | 11 | The demo shows translating from ProseMirror to the the Grid API JSON and back. 12 | 13 | ## purpose 14 | 15 | ProseMirror provides a high-level schema-based interface for interacting with `contenteditable`, taking care of that pain. Ed is focused on: 16 | 17 | * Schema to translate between the Grid API data and ProseMirror doc type 18 | * Coordinating widgets (block editors specialized by type) ([example](https://github.com/the-grid/ced)) 19 | 20 | # use 21 | 22 | ## Using as a React ⚛ component 23 | 24 | Ed exposes [a React component](./src/components/app.js) by default. 25 | 26 | ``` jsx 27 | import Ed from '@the-grid/ed' 28 | 29 | export default class PostEditor extends React.Component { 30 | render() { 31 | return ( 32 | 33 | ) 34 | } 35 | } 36 | ``` 37 | 38 | ## Using as a stand-alone library in iframe or similar 39 | 40 | Including `dist/build.js` in your page exposes `window.TheGridEd` 41 | 42 | ``` html 43 | 44 | ``` 45 | 46 | There are `{mountApp, unmountApp}` helper methods 47 | available to use like this: 48 | 49 | ``` js 50 | var container = document.querySelector('#ed') 51 | window.TheGridEd.mountApp(container, { 52 | // REQUIRED -- Content array from post 53 | initialContent: [], 54 | // OPTIONAL (default true) enable or disable the default menu 55 | menuBar: true, 56 | // REQUIRED -- Hit on every change 57 | onChange: function () { 58 | /* App can show "unsaved changes" in UI */ 59 | }, 60 | // REQUIRED 61 | onShareFile: function (index) { 62 | /* App triggers native file picker */ 63 | /* App calls ed.insertPlaceholders(index, count) and gets array of ids back */ 64 | /* App uploads files and sets status on placeholder blocks with ed.updateProgress */ 65 | /* On upload / measurement finishing, app replaces placeholder blocks with ed.setContent */ 66 | }, 67 | // REQUIRED 68 | onRequestCoverUpload: function (id) { 69 | /* Similar to onShareFile, but hit with block id instead of index */ 70 | /* App uploads files and sets status on blocks with ed.updateProgress */ 71 | /* Once upload is complete, app hits ed.setCoverSrc */ 72 | }, 73 | // REQUIRED 74 | onShareUrl: function ({block, url}) { 75 | /* Ed made the placeholder with block id */ 76 | /* App shares url with given block id */ 77 | /* App updates status on placeholder blocks with ed.updateProgress */ 78 | /* On share / measurement finishing, app replaces placeholder blocks with ed.setContent */ 79 | }, 80 | // REQUIRED 81 | onPlaceholderCancel: function (id) { 82 | /* Ed removed the placeholder if you call ed.getContent() now */ 83 | /* App should cancel the share or upload */ 84 | }, 85 | // OPTIONAL 86 | onRequestLink: function (value) { 87 | /* 88 | If defined, Ed will _not_ show prompt for link 89 | If selection is url-like, value will be the selected string 90 | App can then call `ed.execCommand('link:toggle', {href, title})` 91 | Note: If that is called while command 'link:toggle' is 'active', it will remove the link, not replace it 92 | */ 93 | }, 94 | // OPTIONAL 95 | onDropFiles: function (index, files) { 96 | /* App calls ed.insertPlaceholders(index, files.length) and gets array of ids back */ 97 | /* App uploads files and sets status on placeholder blocks with ed.updateProgress */ 98 | /* On upload / measurement finishing, app replaces placeholder blocks with ed.setContent */ 99 | }, 100 | // OPTIONAL 101 | onDropFileOnBlock: function (id, file) { 102 | /* App uploads files and sets status on block with ed.updateProgress */ 103 | /* Once upload is complete, app hits ed.setCoverSrc */ 104 | }, 105 | // OPTIONAL 106 | onMount: function (mounted) { 107 | /* Called once PM and widgets are mounted */ 108 | window.ed = mounted 109 | }, 110 | // OPTIONAL 111 | onCommandsChanged: function (commands) { 112 | /* Object with commandName keys and one of inactive, active, disabled */ 113 | }, 114 | // OPTIONAL -- imgflo image proxy config 115 | imgfloConfig: { 116 | server: 'https://imgflo.herokuapp.com/', 117 | key: 'key', 118 | secret: 'secret' 119 | }, 120 | // OPTIONAL -- where iframe widgets live relative to app (or absolute) 121 | widgetPath: './node_modules/', 122 | // OPTIONAL -- site-wide settings to allow cover filter, crop, overlay; default true 123 | coverPrefs: { 124 | filter: false, 125 | crop: true, 126 | overlay: true 127 | }, 128 | // OPTIONAL -- site or user flags to reduce functionality 129 | featureFlags: { 130 | edCta: false, 131 | edEmbed: false 132 | } 133 | }) 134 | 135 | // Returns array of inserted placeholder ids 136 | ed.insertPlaceholders(index, count) 137 | 138 | // Update placeholder metadata 139 | // {status (string), progress (number 0-100), failed (boolean)} 140 | // metadata argument with {progress: null} will remove the progress bar 141 | ed.updateProgress(id, metadata) 142 | 143 | // Once block cover upload completes 144 | // `cover` is object with {src, width, height} 145 | ed.setCover(id, cover) 146 | 147 | // For placeholder or media block with uploading cover 148 | // `src` should be blob: or data: url of a 149 | // sized preview of the local image 150 | ed.setCoverPreview(id, src) 151 | 152 | // Returns content array 153 | // Expensive, so best to debounce and not call this on every change 154 | // Above the fold block is index 0, and starred 155 | ed.getContent() 156 | 157 | // Only inserts/updates placeholder blocks and converts placeholder blocks to media 158 | ed.setContent(contentArray) 159 | 160 | // Returns true if command applies successfully with current selection 161 | ed.execCommand(commandName) 162 | ``` 163 | 164 | Demo: [./demo/demo.js](./demo/demo.js) 165 | 166 | ## commands 167 | 168 | With `onCommandsChanged` prop, app will get an object containing these commandName keys. 169 | Values will be one of these strings: `inactive`, `active`, `disabled`, `flagged`. 170 | 171 | Apps can apply formatting / editing commands with `ed.execCommand(commandName)` 172 | 173 | Special case: `ed.execCommand('link:toggle', {href, title})` (title optional) to set link of current selection. 174 | 175 | Supported `commandName` keys: 176 | 177 | ``` 178 | strong:toggle 179 | em:toggle 180 | link:toggle 181 | paragraph:make 182 | heading:make1 183 | heading:make2 184 | heading:make3 185 | bullet_list:wrap 186 | ordered_list:wrap 187 | horizontal_rule:insert 188 | lift 189 | undo 190 | redo 191 | ed_upload_image 192 | ed_add_code 193 | ed_add_location 194 | ed_add_userhtml 195 | ed_add_cta 196 | ed_add_quote 197 | ``` 198 | 199 | # dev 200 | 201 | ## server 202 | 203 | `npm start` and open [http://localhost:8080/](http://localhost:8080/) 204 | 205 | In development mode, webpack builds and serves the targets in memory from /webpack/ 206 | 207 | Changes will trigger a browser refresh. 208 | 209 | ## plugins 210 | 211 | [Plugins](./src/plugins) are ES2015 classes with 2 required methods: 212 | 213 | * `constructor (ed) {}` gets a reference to the main `ed`, where you can 214 | * listen to PM events: `ed.pm.on('draw', ...)` 215 | * and set up UI: `ed.pluginContainer.appendChild(...)` 216 | * `teardown () {}` where all listeners and UI should be removed 217 | 218 | ## widgets 219 | 220 | Widgets are mini-editors built to edit specific media types 221 | 222 | ### iframe 223 | 224 | Run in iframe and communicate via postMessage 225 | 226 | Example: [ced - widget for code editing](https://github.com/the-grid/ced) 227 | 228 | ### native 229 | 230 | Example: WIP 231 | 232 | ## styling 233 | 234 | 1. Primary: [Rebass](http://jxnblk.com/rebass/) defaults and [rebass-theme](./src/components/rebass-theme.js) for global overrides 235 | 2. Secondary: inlined JS `style` objects ([example](./src/components/textarea-autosize.js)) 236 | 3. Deprecating: `require('./component-name.css')` style includes, but needed for some responsive hacks and ProseMirror overrides 237 | 238 | ## code style 239 | 240 | Feross [standard](https://github.com/feross/standard#rules) checked by ESLint with `npm test` or `npm run lint` 241 | 242 | * no unneeded semicolons 243 | * no trailing spaces 244 | * single quotes 245 | 246 | To automatically fix easy stuff like trailing whitespace: `npm run lintfix` 247 | 248 | ## test 249 | 250 | `npm test` 251 | 252 | [Karma is set up](./karma.conf.js) to run tests in local Chrome and Firefox. 253 | 254 | Tests will also run in mobile platforms via [BrowserStack](https://www.browserstack.com/), if you have these environment variables set up: 255 | 256 | ``` 257 | BROWSERSTACK_USERNAME 258 | BROWSERSTACK_ACCESSKEY 259 | ``` 260 | 261 | ## build 262 | 263 | `npm run build` 264 | 265 | Outputs minified dist/ed.js and copies widgets defined in [package.json](./package.json). 266 | 267 | ## deploying 268 | 269 | `npm version patch` - style tweaks, hot bug fixes 270 | 271 | `npm version minor` - adding features, backwards-compatible changes 272 | 273 | `npm version major` - removing features, non-backwards-compatible changes 274 | 275 | These shortcuts will run tests, tag, change package version, and push changes and tags to GH. 276 | 277 | Travis will then publish new tags to [npm](https://www.npmjs.com/package/@the-grid/ed) 278 | and build the demo to publish to [gh-pages](https://the-grid.github.io/ed/). -------------------------------------------------------------------------------- /demo/demo.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | 3 | import {mountApp, unmountApp} from '../src/ed' 4 | import fixture from './fixture' 5 | import Gremlins from 'gremlins.js/src/main' 6 | import gremlinProsemirror from './gremlin-prosemirror' 7 | 8 | let ed 9 | const fixtureContent = fixture.content 10 | 11 | let apiJSON = document.querySelector('#debug-api') 12 | 13 | // ProseMirror setup 14 | function setup (options) { 15 | const container = document.querySelector('#app') 16 | 17 | if (ed) { 18 | unmountApp(container) 19 | ed = null 20 | } 21 | if (options.initialContent) { 22 | apiJSON.value = JSON.stringify(options.initialContent, null, 2) 23 | } 24 | const props = 25 | { initialContent: (options.initialContent || []), 26 | onChange: () => { console.log('onChange') }, 27 | onMount: function (mounted) { 28 | ed = mounted 29 | console.log(ed) 30 | window.ed = ed 31 | }, 32 | onShareFile: onShareFileDemo, 33 | onShareUrl: onShareUrlDemo, 34 | onRequestCoverUpload: onRequestCoverUploadDemo, 35 | onPlaceholderCancel: onPlaceholderCancelDemo, 36 | onCommandsChanged: function (commands) { 37 | // console.log(commands) 38 | }, 39 | onDropFiles: onDropFilesDemo, 40 | onDropFileOnBlock: onDropFileOnBlockDemo, 41 | // onRequestLink: function (value) { 42 | // console.log('onRequestLink', value) 43 | // }, 44 | imgfloConfig: null, 45 | widgetPath: './node_modules/', 46 | coverPrefs: { filter: false }, 47 | menuBar: true, 48 | featureFlags: { 49 | edCta: false, 50 | edEmbed: false, 51 | }, 52 | } 53 | mountApp(container, props) 54 | 55 | // Only for fixture demo 56 | initializePlaceholderMetadata(props.initialContent) 57 | } 58 | const initialContent = (window.location.hash === '#fixture' ? fixtureContent : []) 59 | setup({initialContent}) 60 | 61 | // onShareFile upload demo 62 | let input 63 | function onShareFileDemo (index) { 64 | console.log('onShareFile: app triggers native picker', index) 65 | 66 | // Remove old input from DOM 67 | if (input && input.parentNode) { 68 | input.parentNode.removeChild(input) 69 | } 70 | input = document.createElement('input') 71 | input.type = 'file' 72 | input.multiple = true 73 | input.accept = 'image/*' 74 | input.onchange = makeInputOnChange(index) 75 | input.style.display = 'none' 76 | document.body.appendChild(input) 77 | input.click() 78 | } 79 | function makeInputOnChange (index) { 80 | return function (event) { 81 | event.stopPropagation() 82 | const input = event.target 83 | const files = input.files 84 | if (!files || !files.length) return 85 | filesUploadSim(index, files) 86 | } 87 | } 88 | 89 | function filesUploadSim (index, files) { 90 | // Make placeholder blocks 91 | let names = [] 92 | for (let i = 0, len = files.length; i < len; i++) { 93 | const file = files[i] 94 | const name = file.name.substr(0, file.name.indexOf('.')) 95 | names.push(name) 96 | } 97 | 98 | // Insert placeholder blocks into content 99 | console.log(`app calls ed.insertPlaceholders(${index}, ${files.length}) and gets array of ids`) 100 | const ids = ed.insertPlaceholders(index, files.length) 101 | 102 | for (let i = 0, len = files.length; i < len; i++) { 103 | const file = files[i] 104 | const url = URL.createObjectURL(file) 105 | const id = ids[i] 106 | ed.setCoverPreview(id, url) 107 | } 108 | 109 | console.log('app uploads files now and calls `ed.updateProgress(id, meta)` with updates') 110 | 111 | simulateProgress( 112 | function (progress) { 113 | ids.forEach(function (id, index) { 114 | let status = `Uploading ${names[index]}` 115 | ed.updateProgress(id, {status, progress}) 116 | }) 117 | }, 118 | function () { 119 | const updatedBlocks = ids.map(function (id, index) { 120 | ed.updateProgress(id, {progress: null}) 121 | return ( 122 | { id, 123 | type: 'image', 124 | metadata: {title: names[index]}, 125 | } 126 | ) 127 | }) 128 | ed.setContent(updatedBlocks) 129 | } 130 | ) 131 | } 132 | 133 | // File picker debug 134 | document.getElementById('upload').onclick = function () { 135 | ed.execCommand('ed_upload_image') 136 | } 137 | 138 | // onShareUrl demo 139 | function onShareUrlDemo (share) { 140 | const {block, url} = share 141 | console.log('onShareUrl: app shares url now and calls ed.updateProgress() with updates', share) 142 | 143 | simulateProgress( 144 | function (progress) { 145 | const status = `Sharing ${url}` 146 | ed.updateProgress(block, {status, progress}) 147 | }, 148 | function () { 149 | console.log('Share: mount block') 150 | ed.setContent([ 151 | { id: block, 152 | type: 'article', 153 | metadata: 154 | { title: 'Shared article title', 155 | description: `Simulated share from ${url}`, 156 | }, 157 | }, 158 | ]) 159 | window.setTimeout(function () { 160 | console.log('Share: mount block + cover') 161 | ed.setContent([ 162 | { id: block, 163 | type: 'article', 164 | metadata: 165 | { title: 'Shared article title + cover', 166 | description: `Simulated share from ${url}`, 167 | }, 168 | cover: 169 | { src: 'http://meemoo.org/images/meemoo-illo-by-jyri-pieniniemi-400.png', 170 | width: 400, 171 | height: 474, 172 | }, 173 | }, 174 | ]) 175 | }, 1000) 176 | } 177 | ) 178 | } 179 | 180 | function simulateProgress (progress, complete) { 181 | let percent = 0 182 | let animate = function () { 183 | percent += 0.5 184 | if (percent < 100) { 185 | // Loop animation 186 | requestAnimationFrame(animate) 187 | // Update placeholder status 188 | progress(percent) 189 | } else { 190 | // Change placeholder to article block 191 | complete() 192 | } 193 | } 194 | animate() 195 | } 196 | 197 | // Debug buttons 198 | 199 | // Toggle debug 200 | let showDebug = false 201 | let debug = document.getElementById('debug') 202 | let toggleDebug = document.getElementById('debug-toggle') 203 | toggleDebug.onclick = () => { 204 | if (showDebug) { 205 | debug.style.display = 'none' 206 | } else { 207 | debug.style.display = 'block' 208 | } 209 | showDebug = !showDebug 210 | } 211 | 212 | // Hydrate 213 | function APIToEditor () { 214 | let json 215 | try { 216 | json = JSON.parse(apiJSON.value) 217 | } catch (e) { 218 | return console.warn('bad json') 219 | } 220 | ed.setContent(json) 221 | } 222 | document.querySelector('#hydrate').onclick = APIToEditor 223 | 224 | // Dehydrate 225 | function EditorToAPI () { 226 | apiJSON.value = JSON.stringify(ed.getContent(), null, 2) 227 | } 228 | document.querySelector('#dehydrate').onclick = EditorToAPI 229 | 230 | // Simulate changes from API 231 | const bangOnContent = document.querySelector('#sim') 232 | let timeout 233 | let simulateUpdates = function () { 234 | // Loop 235 | timeout = setTimeout(simulateUpdates, 1000) 236 | 237 | let content = ed.getContent() 238 | // console.log(content[6].url) 239 | ed.setContent(content) 240 | } 241 | let toggleUpdates = function () { 242 | if (timeout) { 243 | clearTimeout(timeout) 244 | timeout = null 245 | bangOnContent.textContent = 'Sim changes from API ▶' 246 | } else { 247 | timeout = setTimeout(simulateUpdates, 500) 248 | bangOnContent.textContent = 'Sim changes from API ◼︎' 249 | } 250 | } 251 | bangOnContent.onclick = toggleUpdates 252 | bangOnContent.click() 253 | 254 | // Load full post 255 | function loadFixture () { 256 | window.location.hash = '#fixture' 257 | setup({initialContent: fixtureContent}) 258 | } 259 | document.querySelector('#fixture').onclick = loadFixture 260 | 261 | function initializePlaceholderMetadata (content) { 262 | for (let i = 0, len = content.length; i < len; i++) { 263 | const block = content[i] 264 | if (!block || !block.id || !block.metadata) { 265 | continue 266 | } 267 | 268 | const {progress, status, failed} = block.metadata 269 | if (progress === undefined && status === undefined && failed === undefined) { 270 | continue 271 | } 272 | ed.updateProgress(block.id, {progress, status, failed}) 273 | 274 | const {cover} = block 275 | if (cover && cover.src) { 276 | ed.setCoverPreview(block.id, cover.src) 277 | } 278 | } 279 | } 280 | 281 | function onPlaceholderCancelDemo (id) { 282 | console.log(`App would cancel the share or upload with id: ${id}`) 283 | } 284 | 285 | // Cover change 286 | 287 | function onRequestCoverUploadDemo (id) { 288 | console.log('onRequestCoverUpload: app triggers native picker', id) 289 | 290 | // Remove old input from DOM 291 | if (input && input.parentNode) { 292 | input.parentNode.removeChild(input) 293 | } 294 | input = document.createElement('input') 295 | input.type = 'file' 296 | input.multiple = false 297 | input.accept = 'image/*' 298 | input.onchange = makeRequestCoverUploadInputOnChange(id) 299 | input.style.display = 'none' 300 | document.body.appendChild(input) 301 | input.click() 302 | } 303 | 304 | function makeRequestCoverUploadInputOnChange (id) { 305 | return function (event) { 306 | event.stopPropagation() 307 | 308 | const file = input.files[0] 309 | const src = URL.createObjectURL(file) 310 | ed.setCoverPreview(id, src) 311 | ed.updateProgress(id, {failed: false}) 312 | 313 | console.log('app uploads files now and calls ed.updateProgress with updates') 314 | 315 | simulateProgress( 316 | function (progress) { 317 | let status = 'Uploading...' 318 | ed.updateProgress(id, {status, progress}) 319 | }, 320 | function () { 321 | ed.updateProgress(id, {progress: null}) 322 | // Apps should have dimensions from API 323 | // and should not need to load the image client-side 324 | const img = new Image() 325 | img.onload = function () { 326 | const {width, height} = img 327 | ed.setCover(id, {src, width, height}) 328 | } 329 | img.src = src 330 | } 331 | ) 332 | } 333 | } 334 | 335 | function onDropFilesDemo (index, files) { 336 | console.log('onDropFiles: files dropped') 337 | filesUploadSim(index, files) 338 | } 339 | 340 | function onDropFileOnBlockDemo (id, file) { 341 | console.log('onDropFileOnBlock: file dropped') 342 | 343 | const src = URL.createObjectURL(file) 344 | ed.setCoverPreview(id, src) 345 | 346 | console.log('app uploads files now and calls ed.updateProgress with updates') 347 | 348 | simulateProgress( 349 | function (progress) { 350 | let status = 'Uploading...' 351 | ed.updateProgress(id, {status, progress}) 352 | }, 353 | function () { 354 | // Apps should have dimensions from API 355 | // and should not need to load the image client-side 356 | const img = new Image() 357 | img.onload = function () { 358 | const {width, height} = img 359 | ed.setCover(id, {src, width, height}) 360 | } 361 | img.src = src 362 | } 363 | ) 364 | } 365 | 366 | /* Fuzz / Monkey / Gremlin.js testing */ 367 | 368 | const fuzz = document.getElementById('fuzz') 369 | let fuzzer = null 370 | fuzz.addEventListener('click', function () { 371 | if (fuzzer) { 372 | fuzzer.stop() 373 | fuzzer = null 374 | } else { 375 | fuzzer = Gremlins 376 | .createHorde() 377 | // .allGremlins() 378 | .gremlin(gremlinProsemirror) 379 | fuzzer.unleash() 380 | } 381 | }) 382 | -------------------------------------------------------------------------------- /demo/gremlin-prosemirror.js: -------------------------------------------------------------------------------- 1 | import configurable from 'gremlins.js/src/utils/configurable' 2 | 3 | function randomChar () { 4 | return String.fromCharCode(0x00FF * Math.random()) 5 | } 6 | 7 | function randomFromArray (arr) { 8 | return arr[Math.floor(Math.random() * arr.length)] 9 | } 10 | 11 | 12 | const config = 13 | { logger: null, 14 | randomizer: null, 15 | } 16 | 17 | function pmSelecter (pm) { 18 | const max = pm.doc.nodeSize 19 | const anchor = config.randomizer.natural({max}) 20 | const head = config.randomizer.natural({max}) 21 | try { 22 | pm.setTextSelection(anchor, head) 23 | } catch (e) {} 24 | } 25 | 26 | function pmSelecterCollapsed (pm) { 27 | const max = pm.doc.nodeSize 28 | const anchor = config.randomizer.natural({max}) 29 | try { 30 | pm.setTextSelection(anchor) 31 | } catch (e) {} 32 | } 33 | 34 | function pmSelecterNode (pm) { 35 | const max = pm.doc.nodeSize 36 | const pos = config.randomizer.natural({max}) 37 | try { 38 | pm.setNodeSelection(pos) 39 | } catch (e) {} 40 | } 41 | 42 | function pmFocuser (pm) { 43 | pm.focus() 44 | } 45 | 46 | function pmTyper (pm) { 47 | pm.tr.typeText(randomChar()).apply() 48 | } 49 | 50 | function pmSplitter (pm) { 51 | const max = pm.doc.nodeSize 52 | const pos = config.randomizer.natural({max}) 53 | try { 54 | pm.tr.split(pos).apply() 55 | } catch (e) {} 56 | } 57 | 58 | function pmFormatter (pm) { 59 | const icons = document.body.querySelectorAll('.ProseMirror-icon') 60 | if (!icons.length) return 61 | 62 | const icon = randomFromArray(icons) 63 | const event = new MouseEvent('mousedown') 64 | icon.dispatchEvent(event) 65 | } 66 | 67 | const subgremlins = 68 | [ pmSelecter, 69 | pmSelecterCollapsed, 70 | pmSelecterNode, 71 | pmTyper, 72 | pmFocuser, 73 | pmSplitter, 74 | pmFormatter, 75 | ] 76 | 77 | function pmGremlin () { 78 | const {pm} = window.ed 79 | const gremlin = randomFromArray(subgremlins) 80 | gremlin(pm) 81 | } 82 | 83 | configurable(pmGremlin, config) 84 | 85 | export default pmGremlin 86 | -------------------------------------------------------------------------------- /gh-pages.sh: -------------------------------------------------------------------------------- 1 | if [ "$TRAVIS_TAG" = "" ] 2 | then 3 | echo "Not a tag, not publishing" 4 | exit 0 5 | else 6 | echo "==> Building and publishing demo tag $TRAVIS_TAG <==" 7 | fi 8 | 9 | #!/bin/bash 10 | set -e # exit with nonzero exit code if anything fails 11 | 12 | # run our compile script, discussed above 13 | npm run builddemo 14 | 15 | # move demo stuff around 16 | mkdir dist/webpack 17 | mv dist/demo.js dist/webpack/demo.js 18 | mv dist/demo.map dist/webpack/demo.map 19 | 20 | # no need for jekyll in this demo (jekyll 3.3+ blocks node_modules) 21 | touch dist/.nojekyll 22 | 23 | # go to the build directory and create a *new* Git repo 24 | cd dist 25 | git init 26 | 27 | # inside this git repo we'll pretend to be a new user 28 | git config user.name "Travis CI" 29 | git config user.email "f.bot@forresto.com" 30 | 31 | # The first and only commit to this new Git repo contains all the 32 | # files present with the commit message "Deploy to GitHub Pages". 33 | git add . 34 | git commit -m "demo $TRAVIS_TAG to gh-pages" 35 | 36 | # Force push from the current repo's master branch to the remote 37 | # repo's gh-pages branch. (All previous history on the gh-pages branch 38 | # will be lost, since we are overwriting it.) We redirect any output to 39 | # /dev/null to hide any sensitive credential data that might otherwise be exposed. 40 | git push --force --quiet "https://${GH_TOKEN}@${GH_REF}" master:gh-pages > /dev/null 2>&1 -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | the-grid/ed demo page 5 | 6 | 13 | 14 | 15 | 16 |
17 | 18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |

source

27 |
28 | 29 |
30 | 31 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6" 4 | } 5 | } -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | 3 | process.env.KARMA = 'true' 4 | var webpackConf = require('./webpack.config.js') 5 | 6 | 7 | module.exports = function (config) { 8 | var cfg = 9 | { browsers: ['Chrome', 'Firefox'] 10 | , files: ['./test/index.js'] 11 | , frameworks: ['chai', 'mocha'] 12 | , plugins: 13 | [ 'karma-browserstack-launcher' 14 | , 'karma-chrome-launcher' 15 | , 'karma-chai' 16 | , 'karma-firefox-launcher' 17 | , 'karma-mocha' 18 | , 'karma-mocha-reporter' 19 | , 'karma-sourcemap-loader' 20 | , 'karma-webpack' 21 | ] 22 | , preprocessors: 23 | { 'test/index.js': ['webpack', 'sourcemap'] 24 | } 25 | , reporters: ['mocha'] 26 | , singleRun: true 27 | , webpack: webpackConf 28 | , webpackMiddleware: { noInfo: true } 29 | , browserDisconnectTimeout: 10*1000 30 | , browserDisconnectTolerance: 1 31 | , browserNoActivityTimeout: 60*1000 32 | , captureTimeout: 2*60*1000 33 | } 34 | 35 | if (process.env.TRAVIS) { 36 | cfg.customLaunchers = 37 | { Chrome_travis_ci: 38 | { base: 'Chrome' 39 | , flags: ['--no-sandbox'] 40 | } 41 | } 42 | cfg.browsers = ['Chrome_travis_ci', 'Firefox'] 43 | } 44 | 45 | // if (process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESSKEY) { 46 | // cfg.browserStack = 47 | // { username: process.env.BROWSERSTACK_USERNAME 48 | // , accessKey: process.env.BROWSERSTACK_ACCESSKEY 49 | // } 50 | // if (!cfg.customLaunchers) cfg.customLaunchers = {} 51 | // cfg.customLaunchers.iOS8 = 52 | // { base: 'BrowserStack' 53 | // , device: 'iPhone 6' 54 | // , os: 'ios' 55 | // , os_version: '8.3' 56 | // } 57 | // cfg.browsers.push('iOS8') 58 | 59 | // cfg.customLaunchers.iOS9 = 60 | // { base: 'BrowserStack' 61 | // , device: 'iPhone 6S' 62 | // , os: 'ios' 63 | // , os_version: '9.0' 64 | // } 65 | // cfg.browsers.push('iOS9') 66 | 67 | // cfg.customLaunchers.Android4 = 68 | // { base: 'BrowserStack' 69 | // , device: 'Samsung Galaxy S5' 70 | // , os: 'android' 71 | // , os_version: '4.4' 72 | // } 73 | // cfg.browsers.push('Android4') 74 | 75 | // // cfg.customLaunchers.Android5 = 76 | // // { base: 'BrowserStack' 77 | // // , device: 'Google Nexus 5' 78 | // // , os: 'android' 79 | // // , os_version: '5.0' 80 | // // } 81 | // // cfg.browsers.push('Android5') 82 | // } 83 | 84 | config.set(cfg) 85 | } 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@the-grid/ed", 3 | "author": "Forrest Oliphant, The Grid", 4 | "license": "MIT", 5 | "version": "2.1.3", 6 | "description": "the grid api with prosemirror", 7 | "main": "dist/ed.js", 8 | "scripts": { 9 | "start": "export DEV=true; webpack-dev-server --inline --host 0.0.0.0", 10 | "babel": "mkdir -p dist && babel src --out-dir dist", 11 | "copycss": "(cd src && rsync -R -v **/*.css ../dist)", 12 | "build": "npm run clean; webpack; npm run babel; npm run copycss", 13 | "builddemo": "npm run clean; export DEMO=true; webpack", 14 | "clean": "rm -rf dist", 15 | "test": "npm run lint && npm run karma", 16 | "lint": "eslint src demo test", 17 | "lintfix": "eslint src demo test --fix", 18 | "karma": "karma start", 19 | "preversion": "npm test", 20 | "postversion": "git push && git push --tags" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/the-grid/ed.git" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/the-grid/ed/issues" 28 | }, 29 | "homepage": "https://github.com/the-grid/ed#readme", 30 | "dependencies": { 31 | "@the-grid/ced": "0.1.3", 32 | "@the-grid/ed-location": "2.0.1", 33 | "@the-grid/ed-userhtml": "0.3.0", 34 | "crel": "3.0.0", 35 | "he": "1.1.0", 36 | "imgflo-url": "1.2.0", 37 | "lodash": "4.17.4", 38 | "prosemirror-commands": "0.17.1", 39 | "prosemirror-dropcursor": "0.17.2", 40 | "prosemirror-example-setup": "0.17.0", 41 | "prosemirror-history": "0.17.0", 42 | "prosemirror-inputrules": "0.17.0", 43 | "prosemirror-keymap": "0.17.0", 44 | "prosemirror-menu": "0.17.0", 45 | "prosemirror-model": "0.17.0", 46 | "prosemirror-schema-basic": "0.17.0", 47 | "prosemirror-schema-list": "0.17.0", 48 | "prosemirror-state": "0.17.0", 49 | "prosemirror-transform": "0.17.0", 50 | "prosemirror-view": "0.17.2", 51 | "react": "15.4.2", 52 | "react-dom": "15.4.2", 53 | "rebass": "0.3.3", 54 | "uuid": "3.0.1" 55 | }, 56 | "widgets": { 57 | "@the-grid/ced": { 58 | "include": [ 59 | "/editor/index.html", 60 | "/lib/mount.js", 61 | "/lib/mount.js.map" 62 | ] 63 | }, 64 | "@the-grid/ed-location": { 65 | "include": [ 66 | "/edit.html", 67 | "/ed-location.js" 68 | ] 69 | }, 70 | "@the-grid/ed-userhtml": { 71 | "include": [ 72 | "/edit.html", 73 | "/dist/edit.js" 74 | ] 75 | } 76 | }, 77 | "devDependencies": { 78 | "babel-cli": "6.18.0", 79 | "babel-core": "6.21.0", 80 | "babel-eslint": "7.1.1", 81 | "babel-loader": "6.2.10", 82 | "babel-preset-es2015": "6.18.0", 83 | "bob-ross-lipsum": "1.1.1", 84 | "chai": "3.5.0", 85 | "copy-webpack-plugin": "4.0.1", 86 | "eslint": "3.13.1", 87 | "eslint-config-standard": "6.2.1", 88 | "eslint-plugin-promise": "3.4.0", 89 | "eslint-plugin-react": "6.9.0", 90 | "eslint-plugin-standard": "2.0.1", 91 | "estraverse": "4.2.0", 92 | "estraverse-fb": "1.3.1", 93 | "gremlins.js": "marmelab/gremlins.js", 94 | "html-flatten": "0.3.6", 95 | "json-loader": "0.5.4", 96 | "karma": "1.4.0", 97 | "karma-browserstack-launcher": "1.1.1", 98 | "karma-chai": "0.1.0", 99 | "karma-chrome-launcher": "2.0.0", 100 | "karma-cli": "1.0.1", 101 | "karma-firefox-launcher": "1.0.0", 102 | "karma-mocha": "1.3.0", 103 | "karma-mocha-reporter": "2.2.1", 104 | "karma-sourcemap-loader": "0.3.7", 105 | "karma-webpack": "2.0.1", 106 | "mocha": "3.2.0", 107 | "mocha-loader": "1.1.0", 108 | "raw-loader": "0.5.1", 109 | "style-loader": "0.13.1", 110 | "webpack": "1.14.0", 111 | "webpack-dev-server": "1.16.2" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/components/add-cover.js: -------------------------------------------------------------------------------- 1 | import React, {createElement as el} from 'react' 2 | import ButtonOutline from 'rebass/dist/ButtonOutline' 3 | 4 | export const buttonStyle = 5 | { textTransform: 'uppercase', 6 | borderRadius: 4, 7 | padding: '10px 16px', 8 | margin: '0.25em 0.5em', 9 | } 10 | 11 | class AddCover extends React.Component { 12 | constructor (props, context) { 13 | super(props) 14 | 15 | this.state = {hasCover: false} 16 | 17 | this.boundUpdateHints = this.updateHints.bind(this) 18 | this.boundAddImage = this.addImage.bind(this) 19 | 20 | const {store} = context 21 | store.on('plugin.contenthints', this.boundUpdateHints) 22 | } 23 | componentWillUnmount () { 24 | const {store} = this.context 25 | store.off('plugin.contenthints', this.boundUpdateHints) 26 | } 27 | render () { 28 | const {hasCover} = this.state 29 | if (hasCover) return null 30 | 31 | return el(ButtonOutline 32 | , { id: 'AddCover', 33 | style: buttonStyle, 34 | onClick: this.boundAddImage, 35 | rounded: true, 36 | } 37 | , 'Add Image' 38 | ) 39 | } 40 | addImage () { 41 | const {store} = this.context 42 | store.routeChange('ADD_IMAGE_TOP') 43 | } 44 | updateHints (hints) { 45 | const {hasCover} = hints 46 | this.setState({hasCover}) 47 | } 48 | } 49 | AddCover.contextTypes = { store: React.PropTypes.object } 50 | export default React.createFactory(AddCover) 51 | -------------------------------------------------------------------------------- /src/components/add-fold.js: -------------------------------------------------------------------------------- 1 | import React, {createElement as el} from 'react' 2 | import ButtonOutline from 'rebass/dist/ButtonOutline' 3 | import {buttonStyle} from './add-cover' 4 | 5 | class AddFold extends React.Component { 6 | constructor (props, context) { 7 | super(props) 8 | 9 | this.state = {hasFold: false} 10 | 11 | this.boundUpdateHints = this.updateHints.bind(this) 12 | this.boundAddFold = this.addFold.bind(this) 13 | 14 | const {store} = context 15 | store.on('plugin.contenthints', this.boundUpdateHints) 16 | } 17 | componentWillUnmount () { 18 | const {store} = this.context 19 | store.off('plugin.contenthints', this.boundUpdateHints) 20 | } 21 | render () { 22 | const {hasFold} = this.state 23 | if (hasFold) return null 24 | 25 | return el(ButtonOutline 26 | , { id: 'AddFold', 27 | style: buttonStyle, 28 | onClick: this.boundAddFold, 29 | rounded: true, 30 | } 31 | , 'Make Full Post' 32 | ) 33 | } 34 | addFold () { 35 | const {store} = this.context 36 | store.routeChange('ADD_FOLD_DELIMITER') 37 | this.setState({hasFold: true}) 38 | } 39 | updateHints (hints) { 40 | const {hasFold} = hints 41 | this.setState({hasFold}) 42 | } 43 | } 44 | AddFold.contextTypes = { store: React.PropTypes.object } 45 | export default React.createFactory(AddFold) 46 | -------------------------------------------------------------------------------- /src/components/app.css: -------------------------------------------------------------------------------- 1 | .Ed * { 2 | box-sizing: border-box; 3 | line-height: 1.5; 4 | } 5 | 6 | .Ed-Hints { 7 | text-align: center; 8 | } 9 | 10 | @media screen and (max-width: 500px) { 11 | .Ed-Hints { 12 | display: flex; 13 | } 14 | .Ed-Hints button { 15 | width: 100% !important; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/app.js: -------------------------------------------------------------------------------- 1 | require('./app.css') 2 | 3 | import React, {createElement as el} from 'react' 4 | 5 | import AddCover from './add-cover' 6 | import AddFold from './add-fold' 7 | import Editable from './editable' 8 | import rebassTheme from './rebass-theme' 9 | import WidgetEdit from './widget-edit' 10 | import Modal from './modal' 11 | 12 | import EdStore from '../store/ed-store' 13 | import {edCommands} from '../menu/ed-menu' 14 | 15 | import {version as PACKAGE_VERSION} from '../../package.json' 16 | 17 | 18 | export default class App extends React.Component { 19 | constructor (props) { 20 | super(props) 21 | 22 | if (!props.initialContent) { 23 | throw new Error('Missing props.initialContent') 24 | } 25 | if (!props.onChange) { 26 | throw new Error('Missing props.onChange') 27 | } 28 | if (!props.onShareUrl) { 29 | throw new Error('Missing props.onShareUrl') 30 | } 31 | if (!props.onShareFile) { 32 | throw new Error('Missing props.onShareFile') 33 | } 34 | if (!props.onRequestCoverUpload) { 35 | throw new Error('Missing props.onRequestCoverUpload') 36 | } 37 | 38 | const { 39 | initialContent, 40 | onChange, 41 | onShareFile, 42 | onShareUrl, 43 | onRequestCoverUpload, 44 | onRequestLink, 45 | onDropFiles, 46 | onDropFileOnBlock, 47 | onCommandsChanged, 48 | } = props 49 | 50 | this._store = new EdStore( 51 | { initialContent, 52 | onChange, 53 | onShareFile, 54 | onShareUrl, 55 | onRequestCoverUpload, 56 | onRequestLink, 57 | onDropFiles, 58 | onDropFileOnBlock, 59 | onCommandsChanged, 60 | } 61 | ) 62 | 63 | this.routeChange = this._store.routeChange.bind(this._store) 64 | 65 | this._store.on('media.block.edit.open', (blockID) => { 66 | // TODO expose prop for native editors? 67 | this.setState({blockToEdit: blockID}) 68 | this.blur() 69 | }) 70 | this.closeMediaBlockModal = () => { 71 | this.setState({blockToEdit: null}) 72 | } 73 | this._store.on('media.block.edit.close', () => { 74 | this.closeMediaBlockModal() 75 | }) 76 | 77 | this.state = { 78 | blockToEdit: null, 79 | } 80 | } 81 | componentDidMount () { 82 | this.boundOnDragOver = this.onDragOver.bind(this) 83 | window.addEventListener('dragover', this.boundOnDragOver) 84 | this.boundOnDrop = this.onDrop.bind(this) 85 | window.addEventListener('drop', this.boundOnDrop) 86 | if (this.props.onMount) { 87 | this.props.onMount(this) 88 | } 89 | } 90 | componentWillUnmount () { 91 | window.removeEventListener('dragover', this.boundOnDragOver) 92 | window.removeEventListener('drop', this.boundOnDrop) 93 | } 94 | getChildContext () { 95 | const {imgfloConfig, featureFlags} = this.props 96 | return ({ 97 | imgfloConfig, 98 | featureFlags, 99 | rebass: rebassTheme, 100 | store: this._store, 101 | }) 102 | } 103 | render () { 104 | return el('div', 105 | {className: 'Ed'}, 106 | this.renderContent(), 107 | // this.renderHints(), 108 | this.renderModal() 109 | ) 110 | } 111 | renderContent () { 112 | const { initialContent 113 | , menuBar 114 | , onShareFile 115 | , onShareUrl 116 | , onCommandsChanged 117 | , onDropFiles 118 | , widgetPath 119 | , coverPrefs } = this.props 120 | 121 | return el('div' 122 | , { className: 'Ed-Content', 123 | style: 124 | { zIndex: 1, 125 | }, 126 | } 127 | , el(Editable 128 | , { initialContent, 129 | menuBar, 130 | onChange: this.routeChange, 131 | onShareFile, 132 | onShareUrl, 133 | onCommandsChanged, 134 | onDropFiles, 135 | widgetPath, 136 | coverPrefs, 137 | } 138 | ) 139 | ) 140 | } 141 | renderHints () { 142 | return el('div' 143 | , { className: 'Ed-Hints', 144 | } 145 | , el(AddCover, {}) 146 | , el(AddFold, {}) 147 | ) 148 | } 149 | renderModal () { 150 | const {blockToEdit} = this.state 151 | if (!blockToEdit) return 152 | const initialBlock = this._store.getBlock(blockToEdit) 153 | if (!initialBlock) return 154 | const {coverPrefs} = this.props 155 | 156 | return el(Modal, 157 | { 158 | onClose: this.closeMediaBlockModal, 159 | child: el(WidgetEdit, { 160 | initialBlock, 161 | coverPrefs, 162 | }), 163 | } 164 | ) 165 | } 166 | onDragOver (event) { 167 | // Listening to window 168 | event.preventDefault() 169 | } 170 | onDrop (event) { 171 | // Listening to window, for drops not caught by content 172 | event.preventDefault() 173 | } 174 | // Exposed methods 175 | getContent () { 176 | return this._store.getContent() 177 | } 178 | setContent (content) { 179 | this._store.setContent(content) 180 | } 181 | execCommand (commandName, attrs) { 182 | const item = edCommands[commandName] 183 | if (!item) { 184 | throw new Error('commandName not found') 185 | } 186 | const view = this.pm.editor 187 | item.spec.run(view.state, view.dispatch, view, attrs) 188 | } 189 | insertPlaceholders (index, count) { 190 | return this._store.insertPlaceholders(index, count) 191 | } 192 | updatePlaceholder () { 193 | throw new Error('updatePlaceholder is deprecated: use updateProgress') 194 | } 195 | updateProgress (id, metadata) { 196 | this._store.updateProgress(id, metadata) 197 | } 198 | setCoverPreview (id, src) { 199 | this._store.setCoverPreview(id, src) 200 | } 201 | setCover (id, cover) { 202 | this._store.setCover(id, cover) 203 | } 204 | indexOfFold () { 205 | return this._store.indexOfFold() 206 | } 207 | blur () { 208 | this.pm.editor.content.blur() 209 | window.getSelection().removeAllRanges() 210 | } 211 | get pm () { 212 | return this._store.pm 213 | } 214 | get version () { 215 | return PACKAGE_VERSION 216 | } 217 | } 218 | App.childContextTypes = { 219 | imgfloConfig: React.PropTypes.object, 220 | store: React.PropTypes.object, 221 | rebass: React.PropTypes.object, 222 | featureFlags: React.PropTypes.object, 223 | } 224 | App.propTypes = { 225 | initialContent: React.PropTypes.array.isRequired, 226 | onChange: React.PropTypes.func.isRequired, 227 | onShareFile: React.PropTypes.func.isRequired, 228 | onShareUrl: React.PropTypes.func.isRequired, 229 | onDropFiles: React.PropTypes.func, 230 | onCommandsChanged: React.PropTypes.func, 231 | onRequestCoverUpload: React.PropTypes.func.isRequired, 232 | onRequestLink: React.PropTypes.func, 233 | imgfloConfig: React.PropTypes.object, 234 | widgetPath: React.PropTypes.string, 235 | coverPrefs: React.PropTypes.object, 236 | menuBar: React.PropTypes.bool, 237 | onMount: React.PropTypes.func, 238 | onDropFileOnBlock: React.PropTypes.func, 239 | featureFlags: React.PropTypes.object, 240 | } 241 | App.defaultProps = { 242 | widgetPath: './node_modules/', 243 | menuBar: true, 244 | coverPrefs: {}, 245 | featureFlags: {}, 246 | } 247 | -------------------------------------------------------------------------------- /src/components/attribution-editor.css: -------------------------------------------------------------------------------- 1 | .AttributionEditor { 2 | margin-bottom: -1rem; 3 | } 4 | 5 | .AttributionEditor-title textarea { 6 | font-size: 24px !important; 7 | margin-top: 0 !important; 8 | line-height: 1.2; 9 | } 10 | 11 | .AttributionEditor-links { 12 | text-align: right; 13 | top: -1px; 14 | position: relative; 15 | } 16 | 17 | .AttributionEditor-links > * { 18 | vertical-align: middle; 19 | } 20 | 21 | .AttributionEditor-metadata:hover { 22 | opacity: 1 !important; 23 | } 24 | 25 | 26 | .AttributionEditor-quote { 27 | border-width: 0 0 0 1px !important; 28 | padding: 0 0 0 64px !important; 29 | } 30 | 31 | .AttributionEditor-quote:before { 32 | font-size: 96px; 33 | line-height: 100%; 34 | 35 | color: hsl(0, 0%, 80%); 36 | 37 | content: '“'; 38 | font-family: Georgia; 39 | position: absolute; 40 | margin-top: 0; 41 | left: 10px; 42 | } 43 | 44 | .AttributionEditor-quote .AttributionEditor-description textarea { 45 | font-size: 24px !important; 46 | line-height: 1.45 !important; 47 | } 48 | 49 | .AttributionEditor .Menu { 50 | width: 100%; 51 | } 52 | 53 | @media screen and (min-width: 432px){ 54 | .AttributionEditor .Menu { 55 | width: auto; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/attribution-view.js: -------------------------------------------------------------------------------- 1 | import React, {createElement as el} from 'react' 2 | import imgflo from 'imgflo-url' 3 | import Avatar from 'rebass/dist/Avatar' 4 | 5 | class AttributionView extends React.Component { 6 | constructor (props) { 7 | super(props) 8 | this.renderAuthor = this.renderAuthor.bind(this) 9 | } 10 | render () { 11 | const {metadata} = this.props 12 | if (!metadata) return null 13 | 14 | return el('div', {}, 15 | this.renderAuthors(), 16 | this.renderPublisher(), 17 | this.renderVia() 18 | ) 19 | } 20 | renderAuthors () { 21 | const {metadata} = this.props 22 | const {author} = metadata 23 | if (!author || !author.length) return null 24 | return el('span', {}, 25 | 'by ', 26 | author.map(this.renderAuthor), 27 | ) 28 | } 29 | renderAuthor (author, i) { 30 | const {name, avatar} = author 31 | const {imgfloConfig} = this.context 32 | let avatarEl 33 | if (avatar && avatar.src) { 34 | let {src} = avatar 35 | if (imgfloConfig) { 36 | const params = { 37 | input: src, 38 | width: 24, 39 | } 40 | src = imgflo(imgfloConfig, 'passthrough', params) 41 | } 42 | avatarEl = el(Avatar, 43 | { 44 | src, 45 | size: 24, 46 | style: {verticalAlign: 'middle'}, 47 | } 48 | ) 49 | } 50 | return el('span', {key: `author${i}`}, 51 | avatarEl, 52 | ' ', 53 | (name || 'Credit') 54 | ) 55 | } 56 | renderPublisher () { 57 | const {metadata} = this.props 58 | const {publisher} = metadata 59 | if (!publisher || !publisher.name) return null 60 | return el('span', {}, 61 | ' on ', 62 | publisher.name 63 | ) 64 | } 65 | renderVia () { 66 | const {metadata} = this.props 67 | const {via} = metadata 68 | if (!via || !via.name) return null 69 | return el('span', {}, 70 | ' via ', 71 | via.name 72 | ) 73 | } 74 | } 75 | AttributionView.propTypes = { 76 | metadata: React.PropTypes.object, 77 | } 78 | AttributionView.contextTypes = 79 | { imgfloConfig: React.PropTypes.object } 80 | 81 | export default React.createFactory(AttributionView) 82 | -------------------------------------------------------------------------------- /src/components/button-confirm.js: -------------------------------------------------------------------------------- 1 | import React, {createElement as el} from 'react' 2 | 3 | import ButtonOutline from 'rebass/dist/ButtonOutline' 4 | 5 | class ButtonConfirm extends React.Component { 6 | constructor (props) { 7 | super(props) 8 | this.state = {open: false} 9 | this.boundOnConfirm = this.onConfirm.bind(this) 10 | } 11 | render () { 12 | const {confirm, label, theme, style, onClick} = this.props 13 | const {open} = this.state 14 | 15 | return el(ButtonOutline 16 | , { children: (open ? confirm : label), 17 | onClick: (open ? onClick : this.boundOnConfirm), 18 | theme, 19 | style, 20 | } 21 | ) 22 | } 23 | onConfirm () { 24 | this.setState({open: true}) 25 | } 26 | } 27 | ButtonConfirm.propTypes = 28 | { confirm: React.PropTypes.string.isRequired, 29 | label: React.PropTypes.string.isRequired, 30 | theme: React.PropTypes.string, 31 | style: React.PropTypes.object, 32 | onClick: React.PropTypes.func.isRequired, 33 | } 34 | export default React.createFactory(ButtonConfirm) 35 | -------------------------------------------------------------------------------- /src/components/credit-add.js: -------------------------------------------------------------------------------- 1 | import {createElement as el} from 'react' 2 | 3 | import NavItem from 'rebass/dist/NavItem' 4 | import NavItemConfirm from './nav-item-confirm' 5 | 6 | 7 | export default function CreditAdd (props) { 8 | const {schema, metadata, onClick} = props 9 | return el('div' 10 | , { style: {} } 11 | , makeLinks(schema, metadata, onClick) 12 | ) 13 | } 14 | 15 | 16 | function makeLinks (schema, metadata = {}, onClick) { 17 | let links = [] 18 | if (schema.isBasedOnUrl && metadata.isBasedOnUrl == null) { 19 | links.push(makeLink('isBasedOnUrl', 'Add Source Link', onClick)) 20 | } 21 | // TODO allow multiple authors? 22 | if (schema.author && (!metadata.author || !metadata.author[0])) { 23 | links.push(makeLink('author', 'Add Credit', onClick)) 24 | } 25 | if (schema.via && !metadata.via) { 26 | links.push(makeLink('via', 'Add Via', onClick)) 27 | } 28 | if (schema.publisher && !metadata.publisher) { 29 | links.push(makeLink('publisher', 'Add Publisher', onClick)) 30 | } 31 | links.push(makeLink('delete', 'Remove Block', onClick, true)) 32 | return links 33 | } 34 | 35 | function makeLink (key, label, onClick, confirm = false) { 36 | let Component = NavItem 37 | let props = { 38 | key, 39 | children: label, 40 | label, 41 | style: { display: 'block' }, 42 | onClick: makeClick(key, onClick), 43 | } 44 | if (confirm) { 45 | Component = NavItemConfirm 46 | props.confirm = 'Delete forever now.' 47 | props.theme = 'warning' 48 | } 49 | return el(Component, props) 50 | } 51 | 52 | function makeClick (key, onClick) { 53 | return function () { 54 | onClick(key) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/credit-editor.js: -------------------------------------------------------------------------------- 1 | import React, {createElement as el} from 'react' 2 | import imgflo from 'imgflo-url' 3 | 4 | import TextareaAutosize from './textarea-autosize' 5 | import {isUrlOrBlank} from '../util/url' 6 | 7 | import Avatar from 'rebass/dist/Avatar' 8 | import ButtonConfirm from './button-confirm' 9 | 10 | 11 | export default function CreditEditor (props, context) { 12 | const {name, label, url, avatar, onChange, onlyUrl, path} = props 13 | 14 | return el('div', { 15 | style: { 16 | padding: '1rem', 17 | width: 360, 18 | maxWidth: '100%', 19 | }, 20 | } 21 | , renderAvatar(avatar, context.imgfloConfig) 22 | , (onlyUrl ? '' : renderLabel(label)) 23 | , (onlyUrl 24 | ? renderBasedOnUrl(url, onChange, path) 25 | : renderFields(name, url, avatar, onChange, path) 26 | ) 27 | , renderRemove(onChange, path) 28 | ) 29 | } 30 | CreditEditor.contextTypes = {imgfloConfig: React.PropTypes.object} 31 | 32 | 33 | function renderAvatar (avatar, imgfloConfig) { 34 | if (!avatar || !avatar.src) return 35 | let {src} = avatar 36 | if (imgfloConfig) { 37 | const params = { 38 | input: src, 39 | width: 72, 40 | } 41 | src = imgflo(imgfloConfig, 'passthrough', params) 42 | } 43 | return el(Avatar, 44 | { key: 'avatar', 45 | style: {float: 'right'}, 46 | src, 47 | } 48 | ) 49 | } 50 | 51 | function renderRemove (onChange, path) { 52 | return el(ButtonConfirm 53 | , { onClick: makeRemove(onChange, path), 54 | style: {float: 'right'}, 55 | theme: 'warning', 56 | title: 'delete attribution from block', 57 | label: 'Remove', 58 | confirm: 'Remove: Are you sure?', 59 | } 60 | ) 61 | } 62 | 63 | function renderLabel (label) { 64 | return el('div' 65 | , {style: {marginBottom: '0.5rem'}} 66 | , label 67 | ) 68 | } 69 | 70 | function renderFields (name, url, avatar, onChange, path) { 71 | return ( 72 | [ renderTextField('name', 'Name', name, onChange, path.concat(['name']), true), 73 | renderTextField('url', 'Link', url, onChange, path.concat(['url']), false, isUrlOrBlank, 'https...'), 74 | ] 75 | ) 76 | } 77 | 78 | function renderBasedOnUrl (value, onChange, path) { 79 | return renderTextField('url', 'Link', value, onChange, path, true, isUrlOrBlank, 'https...') 80 | } 81 | 82 | function renderTextField (key, label, value, onChange, path, defaultFocus, validator, placeholder) { 83 | return el(TextareaAutosize 84 | , { className: `AttributionEditor-${key}`, 85 | label, 86 | defaultValue: value, 87 | defaultFocus, 88 | key: key, 89 | name: key, 90 | multiLine: true, 91 | style: {width: '100%'}, 92 | onChange: makeChange(onChange, path), 93 | validator, 94 | placeholder, 95 | } 96 | ) 97 | } 98 | 99 | function makeChange (onChange, path) { 100 | return function (event) { 101 | const {value} = event.target 102 | onChange(path, value) 103 | } 104 | } 105 | 106 | function makeRemove (onChange, path) { 107 | return function () { 108 | onChange(path, undefined) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/components/dropdown-group.js: -------------------------------------------------------------------------------- 1 | /* eslint react/no-find-dom-node: [0] */ 2 | 3 | import React, {createElement as el} from 'react' 4 | 5 | import Menu from 'rebass/dist/Menu' 6 | import ButtonOutline from 'rebass/dist/ButtonOutline' 7 | 8 | function hasParentWithClassName (el, className) { 9 | while (el && el.parentNode) { 10 | if (el.classList.contains(className)) { 11 | return true 12 | } 13 | el = el.parentNode 14 | } 15 | return false 16 | } 17 | 18 | 19 | class DropdownGroup extends React.Component { 20 | constructor (props) { 21 | super(props) 22 | this.state = { 23 | openMenu: null, 24 | } 25 | this.boundCloseMenu = this.closeMenu.bind(this) 26 | this.nodeRefCallback = (node) => { this.node = node } 27 | this.onKeyDown = (event) => { 28 | if (event.key === 'Enter') { 29 | event.preventDefault() 30 | event.stopPropagation() 31 | this.setState({openMenu: null}) 32 | } 33 | } 34 | } 35 | componentDidMount () { 36 | // Click away to dismiss 37 | document.body.addEventListener('click', this.boundCloseMenu) 38 | } 39 | componentWillUnmount () { 40 | document.body.removeEventListener('click', this.boundCloseMenu) 41 | } 42 | componentWillReceiveProps (nextProps) { 43 | if (this.state.openMenu == null) { 44 | return 45 | } 46 | // Find and open new menu 47 | if (nextProps.menus.length > this.props.menus.length) { 48 | for (let i = 0, len = nextProps.menus.length; i < len; i++) { 49 | const menu = this.props.menus[i] 50 | const nextMenu = nextProps.menus[i] 51 | if (menu && nextMenu && menu.key !== nextMenu.key) { 52 | this.setState({openMenu: i}) 53 | return 54 | } 55 | } 56 | } 57 | // Close if removed 58 | if (nextProps.menus.length < this.props.menus.length) { 59 | this.setState({openMenu: null}) 60 | } 61 | } 62 | componentDidUpdate (_, prevState) { 63 | // Focus on open 64 | if (!prevState.open && this.state.open && this.node) { 65 | const el = this.node.querySelector('textarea') 66 | if (el) { 67 | el.focus() 68 | } 69 | } 70 | } 71 | render () { 72 | return el('div', 73 | { 74 | className: 'DropdownGroup', 75 | ref: this.nodeRefCallback, 76 | onKeyDown: this.onKeyDown, 77 | }, 78 | this.renderButtons(), 79 | this.renderMenu() 80 | ) 81 | } 82 | renderButtons () { 83 | const {menus, theme} = this.props 84 | const {openMenu} = this.state 85 | 86 | let buttons = [] 87 | for (let i = 0, len = menus.length; i < len; i++) { 88 | // HACK 89 | const {name, label} = menus[i].props 90 | buttons.push( 91 | el(ButtonOutline, { 92 | key: `button${i}`, 93 | onClick: this.makeOpenMenu(i), 94 | theme: (openMenu === i ? 'primary' : theme), 95 | inverted: false, 96 | style: { 97 | borderWidth: 0, 98 | boxShadow: 'none', 99 | outline: 'none', 100 | }, 101 | rounded: false, 102 | title: `Edit ${label}`, 103 | }, 104 | el('span', { 105 | style: { 106 | maxWidth: '15rem', 107 | verticalAlign: 'middle', 108 | display: 'inline-block', 109 | whiteSpace: 'pre', 110 | overflow: 'hidden', 111 | textOverflow: 'ellipsis', 112 | textTransform: 'uppercase', 113 | }}, 114 | (name || label) 115 | ) 116 | ) 117 | ) 118 | } 119 | return buttons 120 | } 121 | renderMenu () { 122 | const {menus, theme} = this.props 123 | const {openMenu} = this.state 124 | 125 | if (openMenu == null) return 126 | 127 | return el('div', {style: { position: 'relative' }}, 128 | el(Menu, { 129 | theme, 130 | style: { 131 | textAlign: 'left', 132 | position: 'absolute', 133 | top: -1, 134 | right: -1, 135 | zIndex: 100, 136 | marginBottom: '5rem', 137 | }, 138 | }, menus[openMenu] 139 | ) 140 | ) 141 | } 142 | makeOpenMenu (index) { 143 | return () => { 144 | const {openMenu} = this.state 145 | const toggleOrOpen = (openMenu === index ? null : index) 146 | this.setState({openMenu: toggleOrOpen}) 147 | } 148 | } 149 | closeMenu (event) { 150 | // Hack since we can't stopPropagation to body 151 | if (event.type === 'click' && hasParentWithClassName(event.target, 'DropdownGroup')) { 152 | // Keep open 153 | return 154 | } 155 | // Close menu 156 | const {openMenu} = this.state 157 | if (openMenu != null) { 158 | this.setState({openMenu: null}) 159 | } 160 | } 161 | } 162 | DropdownGroup.propTypes = 163 | { menus: React.PropTypes.array.isRequired, 164 | theme: React.PropTypes.string, 165 | } 166 | DropdownGroup.defaultProps = 167 | { theme: 'secondary' } 168 | export default React.createFactory(DropdownGroup) 169 | -------------------------------------------------------------------------------- /src/components/editable-feature-flags.css: -------------------------------------------------------------------------------- 1 | .FlaggedWidget > div { 2 | position: relative; 3 | } 4 | .FlaggedWidget > div::after { 5 | content: 'Paid feature: will not show on site until upgrade.'; 6 | font-size: 80%; 7 | color: red; 8 | background: white; 9 | top: 0; 10 | right: 0; 11 | position: absolute; 12 | border: 2px solid red; 13 | padding: 2px 4px; 14 | border-radius: 0 2px 0 0; 15 | } 16 | 17 | .ProseMirror-menu-dropdown-item .flaggedFeature { 18 | color: red; 19 | } 20 | .ProseMirror-menu-dropdown-item .flaggedFeature::after { 21 | font-size: 80%; 22 | content: 'paid feature'; 23 | color: red; 24 | display: block; 25 | padding-left: 1rem; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/editable-menu.css: -------------------------------------------------------------------------------- 1 | .ProseMirror-menubar { 2 | max-width: 856px; 3 | margin: 0 auto; 4 | position: absolute; 5 | top: 0; 6 | padding: 0rem 0.25rem; 7 | } 8 | 9 | .ProseMirror-menuitem { 10 | cursor: pointer; 11 | } 12 | 13 | .ProseMirror-menubar .ProseMirror-icon, 14 | .ProseMirror-menubar .ProseMirror-menu-dropdown, 15 | .ProseMirror-menubar .EdMenuText { 16 | padding: 0.5rem 0.75rem; 17 | } 18 | 19 | .ProseMirror-menubar .ProseMirror-menu-dropdown { 20 | padding-right: 1.5rem; 21 | } 22 | 23 | .ProseMirror-menubar .ProseMirror-menu-dropdown:after { 24 | right: 0.5rem; 25 | } 26 | 27 | .ProseMirror-menu-dropdown-item { 28 | padding: 0.5rem 4px; 29 | } 30 | 31 | .ProseMirror-menuseparator { 32 | display: inline-block; 33 | height: 2rem; 34 | vertical-align: middle; 35 | margin: 0 3px; 36 | } 37 | 38 | .ProseMirror-menuitem { 39 | margin-right: 0; 40 | } 41 | 42 | .ProseMirror-menuitem:hover { 43 | color: #000; 44 | } 45 | 46 | /* Upload Image button */ 47 | .EdMenuText { 48 | cursor: pointer; 49 | font-size: 90%; 50 | } 51 | 52 | .ProseMirror-tooltip .ProseMirror-menuseparator { 53 | margin: 0 6px; 54 | } 55 | 56 | @media screen and (min-width: 728px) { 57 | .ProseMirror-menubar { 58 | padding: 0rem 2.25rem; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/editable.css: -------------------------------------------------------------------------------- 1 | [contenteditable]:focus { 2 | outline: 0px solid transparent; 3 | } 4 | 5 | .Ed { 6 | font-family: -apple-system, ".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI", "Helvetica Neue", "Lucida Grande", sans-serif; 7 | } 8 | 9 | .Ed button:hover{ 10 | color: #222 !important; 11 | } 12 | 13 | .ProseMirror { 14 | border: none; 15 | } 16 | 17 | .ProseMirror-content { 18 | font-family: Georgia, Times, serif; 19 | border: none; 20 | margin: 0 auto 40px auto; 21 | padding: 1rem; 22 | white-space: pre-wrap; 23 | max-width: 856px; 24 | 25 | -webkit-user-modify: read-write-plaintext-only; 26 | user-modify: read-write-plaintext-only; 27 | } 28 | 29 | .ProseMirror-content:after { 30 | content: 'Type here or paste links to images, articles, videos...'; 31 | display: block; /* For Firefox */ 32 | position: relative; 33 | opacity: .2; 34 | bottom: 0; 35 | /* Needed for click to correctly focus editable */ 36 | z-index: -1; 37 | } 38 | 39 | .ProseMirror-focused .ProseMirror-content:after { 40 | opacity: 0; 41 | } 42 | 43 | 44 | 45 | /* Font junk */ 46 | 47 | .ProseMirror-content > * { 48 | margin: 1.75rem 0px; 49 | padding: 0px; 50 | } 51 | 52 | .ProseMirror-content h1, 53 | .ProseMirror-content h2, 54 | .ProseMirror-content h3, 55 | .ProseMirror-content h4, 56 | .ProseMirror-content h5, 57 | .ProseMirror-content h6 { 58 | font-family: -apple-system, ".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI", "Helvetica Neue", "Lucida Grande", sans-serif; 59 | font-weight: normal; 60 | } 61 | 62 | .ProseMirror-content img { 63 | max-width: 100%; 64 | } 65 | 66 | .ProseMirror-content h1 { 67 | font-size: 250%; 68 | line-height: 1.2; 69 | } 70 | 71 | .ProseMirror-content h2 { 72 | font-size: 175%; 73 | line-height: 1.3; 74 | } 75 | 76 | .ProseMirror-content h3 { 77 | font-size: 125%; 78 | line-height: 1.4; 79 | } 80 | 81 | .ProseMirror-content p { 82 | font-size: 100%; 83 | line-height: 1.5; 84 | } 85 | 86 | .ProseMirror-content p:first-child, .ProseMirror-content h1:first-child, .ProseMirror-content h2:first-child, .ProseMirror-content h3:first-child, .ProseMirror-content h4:first-child, .ProseMirror-content h5:first-child, .ProseMirror-content h6:first-child { 87 | margin-top: 0.3em; 88 | padding-top: 0px; 89 | } 90 | 91 | .ProseMirror-content li > p { 92 | margin-bottom: 0.3em 93 | } 94 | 95 | .ProseMirror-menubar { 96 | position: absolute; 97 | top: 0; 98 | left: 0; 99 | width: 100%; 100 | z-index: 3; 101 | } 102 | 103 | .ProseMirror-tooltip { 104 | height: auto; 105 | padding: 10px 15px 7px; 106 | border-color: #ddd; 107 | } 108 | .ProseMirror-tooltip-pointer { 109 | border-top-color: #ccc; 110 | } 111 | 112 | .ProseMirror-prompt { 113 | background-color: white; 114 | border: 1px silver solid; 115 | 116 | position: absolute; 117 | padding: 0.5em; 118 | } 119 | 120 | .ProseMirror-prompt input { 121 | padding: .5em; 122 | display: block; 123 | margin-bottom: 0.5em; 124 | } 125 | 126 | .ProseMirror-prompt h5 { 127 | margin: 0 0 0.5em 0; 128 | } 129 | 130 | .ProseMirror-prompt .ProseMirror-prompt-close { 131 | left: auto; 132 | right: 5px; 133 | } 134 | 135 | .ProseMirror-content hr { 136 | border-width: 2px 0 0 0; 137 | /* Make it easier to tap */ 138 | padding-bottom: 1rem; 139 | margin-bottom: 0.75rem; 140 | } 141 | 142 | /* Placeholder text hacks */ 143 | .ProseMirror-content > .empty { 144 | position: relative; 145 | } 146 | .ProseMirror-content > h1.empty::after { 147 | content: 'Title'; 148 | opacity: 0.2; 149 | position: absolute; 150 | top: 0; 151 | } 152 | .ProseMirror-content > h2.empty::after { 153 | content: 'Section'; 154 | opacity: 0.2; 155 | position: absolute; 156 | top: 0; 157 | } 158 | .ProseMirror-content > h3.empty::after { 159 | content: 'Subsection'; 160 | opacity: 0.2; 161 | position: absolute; 162 | top: 0; 163 | } 164 | .ProseMirror-content > p.empty::after { 165 | content: '¶'; 166 | opacity: 0.2; 167 | position: absolute; 168 | top: 0; 169 | } 170 | 171 | 172 | /* Responsive font size magic, small screen first */ 173 | 174 | .ProseMirror-content { 175 | font-size: 1.2rem; 176 | } 177 | 178 | @media screen and (min-width: 728px) { 179 | .ProseMirror-content { 180 | padding: 1rem 3rem 3rem 3rem; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/components/editable.js: -------------------------------------------------------------------------------- 1 | require('prosemirror-menu/style/menu.css') 2 | require('prosemirror-view/style/prosemirror.css') 3 | require('./editable.css') 4 | require('./editable-menu.css') 5 | require('./editable-feature-flags.css') 6 | 7 | import React, {createElement as el} from 'react' 8 | import {EditorState, Plugin, NodeSelection} from 'prosemirror-state' 9 | import {history as pluginHistory} from 'prosemirror-history' 10 | // import {dropCursor as pluginDropCursor} from 'prosemirror-dropcursor' 11 | 12 | import {MenuBarEditorView} from 'prosemirror-menu' 13 | import {edMenuPlugin, edMenuEmptyPlugin, patchMenuWithFeatureFlags} from '../menu/ed-menu' 14 | 15 | import GridToDoc from '../convert/grid-to-doc' 16 | import EdSchema from '../schema/ed-schema' 17 | import {MediaNodeView} from '../schema/media' 18 | import {edInputRules, edBaseKeymap, edKeymap} from '../inputrules/ed-input-rules' 19 | import {posToIndex} from '../util/pm' 20 | import {isDropFileEvent} from '../util/drop' 21 | 22 | import PluginShareUrl from '../plugins/share-url' 23 | // import PluginContentHints from '../plugins/content-hints' 24 | import PluginPlaceholder from '../plugins/placeholder' 25 | import PluginFixedMenuHack from '../plugins/fixed-menu-hack' 26 | import PluginCommandsInterface from '../plugins/commands-interface' 27 | import {PluginStoreRef} from '../plugins/store-ref' 28 | 29 | 30 | class Editable extends React.Component { 31 | constructor (props) { 32 | super(props) 33 | this.boundOnDrop = this.onDrop.bind(this) 34 | } 35 | setState () { 36 | throw new Error('Can not setState of Editable') 37 | } 38 | render () { 39 | return el('div', { 40 | className: 'Editable', 41 | }, 42 | el('div', {className: 'Editable-Mirror', ref: 'mirror'}), 43 | el('div', {className: 'Editable-Plugins', ref: 'plugins'}) 44 | ) 45 | } 46 | componentDidMount () { 47 | const {mirror, plugins} = this.refs 48 | const { initialContent 49 | , menuBar 50 | , onChange 51 | , onCommandsChanged 52 | , widgetPath 53 | , coverPrefs } = this.props 54 | const {store, imgfloConfig, featureFlags} = this.context 55 | 56 | let edPlugins = [ 57 | pluginHistory(), 58 | edInputRules, 59 | edKeymap, 60 | edBaseKeymap, 61 | ] 62 | 63 | let edPluginClasses = [ 64 | PluginStoreRef, 65 | PluginShareUrl, 66 | PluginPlaceholder, 67 | ] 68 | 69 | patchMenuWithFeatureFlags(featureFlags) 70 | if (menuBar) { 71 | edPlugins.push(edMenuPlugin) 72 | edPluginClasses.push(PluginFixedMenuHack) 73 | } else { 74 | edPlugins.push(edMenuEmptyPlugin) 75 | } 76 | 77 | if (onCommandsChanged) { 78 | edPluginClasses.push(PluginCommandsInterface) 79 | } 80 | 81 | const pluginProps = { 82 | ed: store, 83 | editableView: this, 84 | elMirror: mirror, 85 | elPlugins: plugins, 86 | widgetPath, 87 | coverPrefs, 88 | } 89 | 90 | edPluginClasses.forEach(function (plugin) { 91 | // FIXME least knowledge per plugin 92 | plugin.edStuff = pluginProps 93 | const p = new Plugin(plugin) 94 | edPlugins.push(p) 95 | }) 96 | 97 | const state = EditorState.create({ 98 | schema: EdSchema, 99 | doc: GridToDoc(initialContent), 100 | plugins: edPlugins, 101 | ed: store, 102 | }) 103 | 104 | let view 105 | 106 | const applyTransaction = (transaction) => { 107 | view.updateState(view.editor.state.apply(transaction)) 108 | if (transaction.steps.length) { 109 | onChange('EDITABLE_CHANGE', this.pm) 110 | } 111 | } 112 | 113 | // PM setup 114 | let pmOptions = 115 | { state, 116 | autoInput: true, 117 | spellcheck: true, 118 | dispatchTransaction: applyTransaction, 119 | handleClickOn: function (_view, _pos, node) { return node.type.name === 'media' }, 120 | nodeViews: { 121 | media: (node, view, getPos) => { 122 | return new MediaNodeView(node, view, getPos, store, imgfloConfig, coverPrefs, widgetPath, featureFlags) 123 | }, 124 | }, 125 | editable: function (state) { return true }, 126 | attributes: { class: 'ProseMirror-content' }, 127 | handleDOMEvents: { 128 | drop: this.boundOnDrop, 129 | }, 130 | // Don't type over node selection 131 | handleTextInput: function (view, from, to, text) { 132 | if (view.state.selection instanceof NodeSelection) { 133 | return true 134 | } 135 | }, 136 | } 137 | 138 | view = this.pm = new MenuBarEditorView(mirror, pmOptions) 139 | this.pm.ed = store 140 | 141 | onChange('EDITABLE_INITIALIZE', this) 142 | } 143 | componentWillUnmount () { 144 | this.pm.editor.destroy() 145 | } 146 | onDrop (editor, event) { 147 | if (!isDropFileEvent(event)) return 148 | const {onDropFiles} = this.props 149 | if (!onDropFiles) return 150 | const {pos} = this.pm.editor.posAtCoords({left: event.clientX, top: event.clientY}) 151 | if (pos == null) return 152 | const index = posToIndex(editor.state.doc, pos) 153 | if (index == null) return 154 | event.preventDefault() 155 | event.stopPropagation() 156 | onDropFiles(index, event.dataTransfer.files) 157 | } 158 | } 159 | Editable.contextTypes = { 160 | store: React.PropTypes.object, 161 | imgfloConfig: React.PropTypes.object, 162 | featureFlags: React.PropTypes.object, 163 | } 164 | Editable.propTypes = { 165 | initialContent: React.PropTypes.array.isRequired, 166 | menuBar: React.PropTypes.bool, 167 | onChange: React.PropTypes.func.isRequired, 168 | onShareFile: React.PropTypes.func, 169 | onShareUrl: React.PropTypes.func, 170 | onDropFiles: React.PropTypes.func, 171 | onEditableInit: React.PropTypes.func, 172 | onCommandsChanged: React.PropTypes.func, 173 | widgetPath: React.PropTypes.string, 174 | coverPrefs: React.PropTypes.object, 175 | } 176 | Editable.defaultProps = { 177 | coverPrefs: {}, 178 | } 179 | export default React.createFactory(Editable) 180 | -------------------------------------------------------------------------------- /src/components/icons.js: -------------------------------------------------------------------------------- 1 | // If we need more icons we can follow https://github.com/jxnblk/react-geomicons/blob/master/src/geomicon.js 2 | 3 | import React, {createElement as el} from 'react' 4 | 5 | // paths from https://github.com/jxnblk/geomicons-open/blob/master/src/play.js 6 | const PATHS = { 7 | play: 'M4 4 L28 16 L4 28 z', 8 | link: 'M0 16 A8 8 0 0 1 8 8 L14 8 A8 8 0 0 1 22 16 L18 16 A4 4 0 0 0 14 12 L8 12 A4 4 0 0 0 4 16 A4 4 0 0 0 8 20 L10 24 L8 24 A8 8 0 0 1 0 16z M22 8 L24 8 A8 8 0 0 1 32 16 A8 8 0 0 1 24 24 L18 24 A8 8 0 0 1 10 16 L14 16 A4 4 0 0 0 18 20 L24 20 A4 4 0 0 0 28 16 A4 4 0 0 0 24 12z', 9 | } 10 | 11 | 12 | export default function Icon (props) { 13 | const {fill, width, height, icon} = props 14 | return el('svg' 15 | , { viewBox: '0 0 32 32', 16 | fill, 17 | width, 18 | height, 19 | style: {verticalAlign: 'middle'}, 20 | } 21 | , el('path' 22 | , { d: PATHS[icon], 23 | } 24 | ) 25 | ) 26 | } 27 | Icon.defaultProps = { 28 | fill: 'currentColor', 29 | width: '1em', 30 | height: '1em', 31 | } 32 | Icon.propTypes = { 33 | icon: React.PropTypes.string.isRequired, 34 | fill: React.PropTypes.string, 35 | width: React.PropTypes.oneOfType([ 36 | React.PropTypes.string, 37 | React.PropTypes.number, 38 | ]), 39 | height: React.PropTypes.oneOfType([ 40 | React.PropTypes.string, 41 | React.PropTypes.number, 42 | ]), 43 | } 44 | -------------------------------------------------------------------------------- /src/components/image-editor.js: -------------------------------------------------------------------------------- 1 | import {createElement as el} from 'react' 2 | 3 | import Checkbox from 'rebass/dist/Checkbox' 4 | import ButtonOutline from 'rebass/dist/ButtonOutline' 5 | import ButtonConfirm from './button-confirm' 6 | 7 | 8 | export default function ImageEditor (props, context) { 9 | const {hasCover 10 | , allowCoverChange 11 | , allowCoverRemove 12 | , siteCoverPrefs 13 | , filter 14 | , crop 15 | , overlay 16 | , onChange 17 | , onUploadRequest 18 | , onCoverRemove, 19 | } = props 20 | 21 | let toggles = null 22 | if (hasCover) { 23 | toggles = el('div' 24 | , {} 25 | , renderToggle('filter', 'Allow filters', filter, onChange, ['metadata', 'coverPrefs', 'filter'], siteCoverPrefs.filter) 26 | , renderToggle('crop', 'Allow cropping', crop, onChange, ['metadata', 'coverPrefs', 'crop'], siteCoverPrefs.crop) 27 | , renderToggle('overlay', 'Allow overlay', overlay, onChange, ['metadata', 'coverPrefs', 'overlay'], siteCoverPrefs.overlay) 28 | ) 29 | } 30 | 31 | return el('div' 32 | , { style: 33 | { padding: '1rem', 34 | width: 288, 35 | maxWidth: '100%', 36 | }, 37 | } 38 | , toggles 39 | , (allowCoverChange ? renderUploadButton(onUploadRequest) : null) 40 | , (allowCoverRemove ? renderRemoveButton(onCoverRemove) : null) 41 | ) 42 | } 43 | 44 | function renderToggle (key, label, value, onChange, path, siteAllow) { 45 | const readOnly = (siteAllow === false) 46 | return el(Checkbox 47 | , { key, 48 | label: label + (readOnly ? ' (off site-wide)' : ''), 49 | name: key, 50 | checked: (siteAllow !== false && value !== false), 51 | style: (readOnly ? {opacity: 0.5} : {}), 52 | readOnly, 53 | disabled: readOnly, 54 | onChange: makeChange(path, onChange, true), 55 | } 56 | ) 57 | } 58 | 59 | function makeChange (path, onChange, checked = false) { 60 | return function (event) { 61 | const value = (checked ? event.target.checked : event.target.value) 62 | onChange(path, value) 63 | } 64 | } 65 | 66 | function renderUploadButton (onClick) { 67 | return el(ButtonOutline 68 | , { onClick, 69 | theme: 'warning', 70 | style: { width: '100%' }, 71 | } 72 | , 'Upload New Image' 73 | ) 74 | } 75 | 76 | function renderRemoveButton (onClick) { 77 | return el(ButtonConfirm 78 | , { onClick, 79 | label: 'Remove Image', 80 | confirm: 'Remove Image: Are you sure?', 81 | theme: 'warning', 82 | style: 83 | { width: '100%', 84 | marginTop: '0.5rem', 85 | }, 86 | } 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /src/components/image.css: -------------------------------------------------------------------------------- 1 | .Image { 2 | width: 100%; 3 | max-height: 900px; 4 | background-size: contain; 5 | background-position: 50% 50%; 6 | background-repeat: no-repeat; 7 | text-align: center; 8 | } 9 | 10 | .Image img{ 11 | max-width: 100%; 12 | max-height: 95vh; 13 | display: block; 14 | margin: auto; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/image.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Stateless functional component - let's use this pattern as much as possible 3 | * http://facebook.github.io/react/docs/reusable-components.html#stateless-functions 4 | */ 5 | 6 | require('./image.css') 7 | 8 | import React, {createElement as el} from 'react' 9 | import imgflo from 'imgflo-url' 10 | 11 | 12 | export default function Image (props, context) { 13 | let {src, title} = props 14 | const {width, height} = props 15 | if (context && context.imgfloConfig) { 16 | const params = 17 | { input: src, 18 | width: getSize(width, height), 19 | } 20 | src = imgflo(context.imgfloConfig, 'passthrough', params) 21 | } 22 | return el('div', {className: 'Image'} 23 | , el('img', {src, title}) 24 | ) 25 | } 26 | Image.contextTypes = { 27 | imgfloConfig: React.PropTypes.object, 28 | } 29 | 30 | 31 | // Proxy via imgflo with width multiple of 72 32 | function getSize (width, height) { 33 | let size = width || 216 34 | if (width && (width >= 216)) { 35 | size = 216 36 | } 37 | if (height && height > width && (width >= 144)) { 38 | size = 144 39 | } 40 | return size 41 | } 42 | -------------------------------------------------------------------------------- /src/components/modal.css: -------------------------------------------------------------------------------- 1 | .Modal-bg { 2 | padding: 0; 3 | } 4 | 5 | @media screen and (min-width: 432px) { 6 | .Modal-bg { 7 | padding: 1rem; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/modal.js: -------------------------------------------------------------------------------- 1 | require('./modal.css') 2 | 3 | import React, {createElement as el} from 'react' 4 | 5 | import ButtonOutline from 'rebass/dist/ButtonOutline' 6 | import {pseudoFixedStyle} from '../util/browser' 7 | function stopPropagation (event) { event.stopPropagation() } 8 | 9 | 10 | class Modal extends React.Component { 11 | constructor (props) { 12 | super(props) 13 | 14 | this.onKeyDown = (event) => { 15 | if (event.key === 'Enter') { 16 | event.preventDefault() 17 | event.stopPropagation() 18 | props.onClose() 19 | } 20 | } 21 | } 22 | render () { 23 | const {onClose, child} = this.props 24 | 25 | let bgStyle = pseudoFixedStyle() 26 | bgStyle.backgroundColor = 'rgba(128,128,128,0.8)' 27 | bgStyle.zIndex = 4 28 | bgStyle.overflowY = 'auto' 29 | 30 | return el('div', 31 | { 32 | className: 'Modal-bg', 33 | style: bgStyle, 34 | onClick: onClose, 35 | onKeyDown: this.onKeyDown, 36 | }, 37 | el('div', 38 | { 39 | className: 'Modal-container', 40 | style: { 41 | padding: '1rem', 42 | backgroundColor: 'white', 43 | maxWidth: 720, 44 | margin: '0 auto', 45 | border: '1px solid silver', 46 | borderRadius: 2, 47 | }, 48 | onClick: stopPropagation, 49 | }, 50 | el('div', 51 | { 52 | style: { 53 | textAlign: 'right', 54 | marginBottom: '1rem', 55 | }, 56 | }, 57 | el(ButtonOutline, 58 | { 59 | onClick: onClose, 60 | }, 61 | 'Close' 62 | ), 63 | ), 64 | child 65 | ) 66 | ) 67 | } 68 | } 69 | Modal.propTypes = { 70 | onClose: React.PropTypes.func, 71 | child: React.PropTypes.node, 72 | } 73 | export default React.createFactory(Modal) 74 | -------------------------------------------------------------------------------- /src/components/nav-item-confirm.js: -------------------------------------------------------------------------------- 1 | import React, {createElement as el} from 'react' 2 | 3 | import NavItem from 'rebass/dist/NavItem' 4 | 5 | class NavItemConfirm extends React.Component { 6 | constructor (props) { 7 | super(props) 8 | this.state = {open: false} 9 | this.boundOnConfirm = this.onConfirm.bind(this) 10 | } 11 | render () { 12 | const {confirm, label, theme, style, onClick} = this.props 13 | const {open} = this.state 14 | 15 | return el(NavItem, { 16 | children: (open ? confirm : label), 17 | onClick: (open ? onClick : this.boundOnConfirm), 18 | theme, 19 | style, 20 | }) 21 | } 22 | onConfirm () { 23 | this.setState({open: true}) 24 | } 25 | } 26 | NavItemConfirm.propTypes = 27 | { confirm: React.PropTypes.string.isRequired, 28 | label: React.PropTypes.string.isRequired, 29 | theme: React.PropTypes.string, 30 | style: React.PropTypes.object, 31 | onClick: React.PropTypes.func.isRequired, 32 | } 33 | export default React.createFactory(NavItemConfirm) 34 | -------------------------------------------------------------------------------- /src/components/placeholder.js: -------------------------------------------------------------------------------- 1 | import React, {createElement as el} from 'react' 2 | import Image from './image' 3 | import Message from 'rebass/dist/Message' 4 | import Progress from 'rebass/dist/Progress' 5 | import Space from 'rebass/dist/Space' 6 | import Close from 'rebass/dist/Close' 7 | 8 | export default function Placeholder (props, context) { 9 | const {store} = context 10 | const {id} = props.initialBlock 11 | const metadata = store.getProgressInfo(id) 12 | if (!metadata) { 13 | return el('div', {className: 'Placeholder'}) 14 | } 15 | const {status, progress, failed} = metadata 16 | 17 | const theme = (failed === true ? 'error' : 'info') 18 | 19 | return el('div' 20 | , { className: `Placeholder Placeholder-${theme}`, 21 | } 22 | , el(Message 23 | , { theme, 24 | style: {marginBottom: 0}, 25 | } 26 | , el('span', {className: 'Placeholder-status'}, status) 27 | , makePreview(id, store) 28 | , el(Space 29 | , {auto: true, x: 1} 30 | ) 31 | , el(Close 32 | , {onClick: makeCancel(store, id)} 33 | ) 34 | ) 35 | , makeProgress(progress, theme) 36 | ) 37 | } 38 | Placeholder.contextTypes = 39 | { store: React.PropTypes.object } 40 | 41 | function makePreview (id, store) { 42 | const preview = store.getCoverPreview(id) 43 | if (!preview) return 44 | return el('div' 45 | , { style: 46 | { width: 96, 47 | height: 72, 48 | display: 'inline-block', 49 | margin: '0px 16px', 50 | overflow: 'hidden', 51 | }, 52 | } 53 | , el(Image, {src: preview}) 54 | ) 55 | } 56 | 57 | function makeProgress (progress, color) { 58 | if (progress == null) return 59 | return el(Progress 60 | , { value: progress / 100, 61 | style: {marginTop: 16}, 62 | color, 63 | } 64 | ) 65 | } 66 | 67 | function makeCancel (store, id) { 68 | return function () { 69 | store.routeChange('PLACEHOLDER_CANCEL', id) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/rebass-theme.js: -------------------------------------------------------------------------------- 1 | import rebassDefaults from 'rebass/dist/config' 2 | 3 | export const sans = '-apple-system, ".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI", "Helvetica Neue", "Lucida Grande", sans-serif' 4 | // const serif = 'Georgia, Times, serif' 5 | 6 | export const colors = rebassDefaults.colors 7 | 8 | 9 | const theme = 10 | { name: 'Ed Theme', 11 | fontFamily: sans, 12 | colors: rebassDefaults.colors, 13 | Base: 14 | { fontFamily: sans, 15 | }, 16 | Button: 17 | { fontFamily: sans, 18 | }, 19 | ButtonOutline: 20 | { fontFamily: sans, 21 | boxShadow: 'inset 0 0 0 1px #ddd', 22 | }, 23 | NavItem: 24 | { fontFamily: sans, 25 | }, 26 | Panel: 27 | { fontFamily: sans, 28 | }, 29 | Message: 30 | { fontFamily: sans, 31 | }, 32 | } 33 | 34 | export default theme 35 | -------------------------------------------------------------------------------- /src/components/textarea-autosize.css: -------------------------------------------------------------------------------- 1 | .TextareaAutosize textarea { 2 | font-size: 16px; 3 | } 4 | 5 | .TextareaAutosize textarea:placeholder-shown { 6 | opacity: 0.5; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/textarea-autosize.js: -------------------------------------------------------------------------------- 1 | require('./textarea-autosize.css') 2 | 3 | import React, {createElement as el} from 'react' 4 | import {sans, colors} from './rebass-theme' 5 | 6 | const containerStyle = 7 | { fontFamily: sans, 8 | fontSize: 12, 9 | } 10 | 11 | const labelStyle = {} 12 | 13 | const labelStyleError = 14 | { color: colors.error, 15 | } 16 | 17 | const areaStyle = 18 | { fontFamily: sans, 19 | minHeight: '1.5rem', 20 | display: 'block', 21 | width: '100%', 22 | padding: 0, 23 | resize: 'none', 24 | color: 'inherit', 25 | border: 0, 26 | borderBottom: '1px dotted rgba(0, 136, 238, .2)', 27 | borderRadius: 0, 28 | outline: 'none', 29 | overflow: 'hidden', 30 | marginBottom: '0.75rem', 31 | } 32 | 33 | 34 | class TextareaAutosize extends React.Component { 35 | constructor (props) { 36 | super(props) 37 | this.boundResize = this.resize.bind(this) 38 | this.boundDebounceResize = this.debounceResize.bind(this) 39 | this.boundOnChange = this.onChange.bind(this) 40 | this.boundOnKeyDown = this.onKeyDown.bind(this) 41 | this.state = 42 | { value: props.defaultValue, 43 | valid: true, 44 | } 45 | } 46 | componentDidMount () { 47 | this.boundDebounceResize() 48 | if (this.props.defaultFocus === true) { 49 | this.refs.textarea.focus() 50 | } 51 | } 52 | componentDidUpdate () { 53 | this.boundDebounceResize() 54 | } 55 | componentWillUnmount () { 56 | if (this.debounce) { 57 | clearTimeout(this.debounce) 58 | } 59 | } 60 | componentWillReceiveProps (props) { 61 | const {defaultValue, validator} = props 62 | let valid = true 63 | if (validator) { 64 | valid = validator(defaultValue) 65 | } 66 | this.setState({value: defaultValue, valid}) 67 | } 68 | render () { 69 | const {label, placeholder} = this.props 70 | let {inputMode, autoCapitalize} = this.props 71 | const {value, valid} = this.state 72 | 73 | if (label === 'Link') { 74 | inputMode = 'url' 75 | autoCapitalize = 'none' 76 | } 77 | 78 | return el('div', 79 | { 80 | className: `TextareaAutosize ${this.props.className}`, 81 | style: containerStyle, 82 | }, 83 | el('label', 84 | {style: (valid ? labelStyle : labelStyleError)}, 85 | label, 86 | this.renderLink(), 87 | el('textarea', { 88 | ref: 'textarea', 89 | style: areaStyle, 90 | value: value || '', 91 | placeholder, 92 | inputMode, 93 | autoCapitalize, 94 | onChange: this.boundOnChange, 95 | rows: 1, 96 | onKeyDown: this.boundOnKeyDown, 97 | }) 98 | ) 99 | ) 100 | } 101 | renderLink () { 102 | const {label} = this.props 103 | if (label !== 'Link') return 104 | const {value, valid} = this.state 105 | if (!value) return 106 | if (!valid) { 107 | return el('span', {}, ' - must be a valid url (http...)') 108 | } 109 | return el('a' 110 | , { href: value, 111 | target: '_blank', 112 | rel: 'noreferrer noopener', 113 | style: 114 | { marginLeft: '0.5rem', 115 | textDecoration: 'none', 116 | }, 117 | } 118 | , 'open' 119 | ) 120 | } 121 | resize () { 122 | const { textarea } = this.refs 123 | textarea.style.height = 'auto' 124 | textarea.style.height = textarea.scrollHeight + 'px' 125 | } 126 | debounceResize () { 127 | if (this.debounce) { 128 | clearTimeout(this.debounce) 129 | } 130 | this.debounce = setTimeout(this.boundResize, 100) 131 | } 132 | onKeyDown (event) { 133 | if (this.props.onKeyDown) { 134 | this.props.onKeyDown(event) 135 | } 136 | if (!this.props.multiline && event.key === 'Enter') { 137 | event.preventDefault() 138 | } 139 | } 140 | onChange (event) { 141 | const {validator, onChange} = this.props 142 | let valid = true 143 | let {value} = event.target 144 | if (!this.props.multiline) { 145 | value = value 146 | .replace(/\r\n/g, ' ') 147 | .replace(/[\r\n]/g, ' ') 148 | } 149 | if (validator) { 150 | valid = validator(value) 151 | } 152 | this.setState({value, valid}) 153 | onChange(event) 154 | this.boundResize() 155 | } 156 | } 157 | TextareaAutosize.propTypes = { 158 | className: React.PropTypes.string, 159 | defaultValue: React.PropTypes.string, 160 | defaultFocus: React.PropTypes.bool, 161 | label: React.PropTypes.string, 162 | placeholder: React.PropTypes.string, 163 | inputMode: React.PropTypes.string, 164 | autoCapitalize: React.PropTypes.string, 165 | onChange: React.PropTypes.func.isRequired, 166 | onKeyDown: React.PropTypes.func, 167 | multiline: React.PropTypes.bool, 168 | validator: React.PropTypes.func, 169 | } 170 | TextareaAutosize.defaultProps = { 171 | multiline: false, 172 | inputMode: '', 173 | autoCapitalize: 'sentences', 174 | } 175 | export default React.createFactory(TextareaAutosize) 176 | -------------------------------------------------------------------------------- /src/components/widget-cta-view.js: -------------------------------------------------------------------------------- 1 | // If we need cover support for cta widget later, 2 | // don't add it here. 3 | 4 | import React, {createElement as el} from 'react' 5 | import Button from 'rebass/dist/Button' 6 | import ButtonOutline from 'rebass/dist/ButtonOutline' 7 | 8 | 9 | class WidgetCtaView extends React.Component { 10 | render () { 11 | const {initialBlock} = this.props 12 | const {label, url} = initialBlock 13 | 14 | return el('div', 15 | { 16 | className: 'WidgetView WidgetView-cta', 17 | style: { 18 | border: '1px solid silver', 19 | borderRadius: 2, 20 | padding: '1rem', 21 | backgroundColor: 'white', 22 | }, 23 | }, 24 | el(Button, 25 | { 26 | style: { 27 | fontSize: '200%', 28 | padding: '1.5rem', 29 | marginBottom: '1rem', 30 | display: 'block', 31 | }, 32 | title: url, 33 | big: true, 34 | onClick: this.props.onClickEdit, 35 | }, 36 | label || 'label', 37 | ), 38 | el(ButtonOutline, 39 | { 40 | onClick: this.props.onClickEdit, 41 | }, 42 | 'Edit' 43 | ) 44 | ) 45 | } 46 | } 47 | WidgetCtaView.propTypes = 48 | { initialBlock: React.PropTypes.object.isRequired, 49 | id: React.PropTypes.string.isRequired, 50 | onClickEdit: React.PropTypes.func, 51 | } 52 | WidgetCtaView.contextTypes = 53 | { store: React.PropTypes.object } 54 | 55 | export default React.createFactory(WidgetCtaView) 56 | -------------------------------------------------------------------------------- /src/components/widget-cta.js: -------------------------------------------------------------------------------- 1 | // If we need cover support for cta widget later, 2 | // don't add it here. 3 | 4 | import React, {createElement as el} from 'react' 5 | import Checkbox from 'rebass/dist/Checkbox' 6 | import ButtonOutline from 'rebass/dist/ButtonOutline' 7 | 8 | import TextareaAutosize from './textarea-autosize' 9 | import {isUrlOrBlank} from '../util/url' 10 | import {widgetLeftStyle, colors} from './rebass-theme' 11 | 12 | // Gets src or href from iframe or a 13 | // http://www.regexpal.com/ test string: 14 | // abc thing! asdf < iframe src = http...> 15 | const regexExtractLink = /<\s*(iframe|a)\s+[^>]*(?:src|href)\s*=[\s"']*(http[^"'\s>]+)[\s"']*[^>]*>/i 16 | 17 | export function extractLink (htmlString) { 18 | const extract = regexExtractLink.exec(htmlString) 19 | if (!extract || !extract[1] || !extract[2]) return null 20 | const tag = extract[1].toLowerCase() 21 | const link = extract[2] 22 | return {tag, link} 23 | } 24 | 25 | 26 | class WidgetCta extends React.Component { 27 | constructor (props) { 28 | super(props) 29 | let state = this.stateFromBlock(props.initialBlock) 30 | state.showImport = false 31 | state.importStatus = '' 32 | this.state = state 33 | 34 | this.changeLabel = (event) => { 35 | this.onChange(['label'], event.target.value) 36 | } 37 | this.changeUrl = (event) => { 38 | this.onChange(['url'], event.target.value) 39 | } 40 | this.changeModal = (event) => { 41 | this.onChange(['metadata', 'canFrame'], event.target.checked) 42 | } 43 | this.toggleImport = () => { 44 | const {showImport} = this.state 45 | this.setState( 46 | { showImport: !showImport, 47 | importStatus: '', 48 | } 49 | ) 50 | } 51 | this.boundImportHTML = this.importHTML.bind(this) 52 | } 53 | componentWillReceiveProps (props) { 54 | // TODO - this but ignore stale data from API??? 55 | // if (!props.initialBlock) return 56 | // this.setState(this.stateFromBlock(props.initialBlock)) 57 | } 58 | stateFromBlock (block) { 59 | if (!block) return {} 60 | const {label, url, metadata} = block 61 | let canFrame = false 62 | if (metadata && metadata.canFrame === true) { 63 | canFrame = true 64 | } 65 | return {label, url, canFrame} 66 | } 67 | render () { 68 | const {label, url, canFrame} = this.state 69 | 70 | return el('div' 71 | , { className: 'WidgetCta', 72 | } 73 | , el('div' 74 | , { className: 'WidgetCta-metadata', 75 | style: widgetLeftStyle, 76 | } 77 | , el(TextareaAutosize 78 | , { label: 'Label', 79 | key: 'label', 80 | placeholder: 'Sign up now!', 81 | defaultValue: label, 82 | onChange: this.changeLabel, 83 | style: {width: '100%'}, 84 | } 85 | ) 86 | , el(TextareaAutosize 87 | , { label: 'Link', 88 | key: 'url', 89 | placeholder: 'https://...', 90 | defaultValue: url, 91 | onChange: this.changeUrl, 92 | validator: isUrlOrBlank, 93 | style: {width: '100%'}, 94 | } 95 | ) 96 | , el(Checkbox 97 | , { key: 'canFrame', 98 | label: 'Link can open in frame', 99 | name: 'canFrame', 100 | checked: (canFrame === true), 101 | onChange: this.changeModal, 102 | } 103 | ) 104 | , this.renderImport() 105 | ) 106 | , el('div' 107 | , { style: {clear: 'both'} } 108 | ) 109 | ) 110 | } 111 | onChange (path, value) { 112 | const {store} = this.context 113 | const {id} = this.props 114 | // Send change up to store 115 | const block = store.routeChange('MEDIA_BLOCK_UPDATE_FIELD', {id, path, value}) 116 | // Send change to view 117 | this.setState(this.stateFromBlock(block)) 118 | } 119 | renderImport () { 120 | const {showImport, importStatus} = this.state 121 | if (!showImport) { 122 | return el(ButtonOutline 123 | , { onClick: this.toggleImport } 124 | , 'Import settings from embed HTML' 125 | ) 126 | } 127 | 128 | return el('form' 129 | , { onSubmit: this.boundImportHTML } 130 | , el(TextareaAutosize 131 | , { label: 'HTML', 132 | defaultValue: '', 133 | defaultFocus: true, 134 | placeholder: ' 30 |

👆 demo of mounting targets/web.html in iframe and loading post content

31 | 32 | 46 | 47 | -------------------------------------------------------------------------------- /targets/web.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The Grid Post Editor 5 | 12 | 13 | 14 |
15 | 16 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/components/image.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | 3 | import Image from '../../src/components/image' 4 | 5 | function expectImage (image, src) { 6 | expect(image.type).to.equal('div') 7 | expect(image.props.className).to.equal('Image') 8 | expect(image.props.children.type).to.equal('img') 9 | expect(image.props.children.props.src).to.equal(src) 10 | } 11 | 12 | 13 | describe('Image', function () { 14 | describe('without imageflo', function () { 15 | it('gives expected output', function () { 16 | const props = {src: 'http://a.com/b.jpg'} 17 | const image = Image(props) 18 | expectImage(image, 'http://a.com/b.jpg') 19 | }) 20 | }) 21 | 22 | describe('with imageflo', function () { 23 | const context = 24 | { imgfloConfig: 25 | { server: 'https://iflo.grid/', 26 | key: 'abc', 27 | secret: '123', 28 | }, 29 | } 30 | 31 | const urlAvatar = 'https://iflo.grid/graph/abc/12ecaddb783ca3f911391be0a6f9d718/passthrough.jpg?input=http%3A%2F%2Fa.com%2Fb.jpg&width=72' 32 | const urlLandscape = 'https://iflo.grid/graph/abc/ef734866cd7274c6d2eecd9999df378e/passthrough.jpg?input=http%3A%2F%2Fa.com%2Fb.jpg&width=216' 33 | const urlPortrait = 'https://iflo.grid/graph/abc/ed6bf64e851143e44f6bafedab7cf7b1/passthrough.jpg?input=http%3A%2F%2Fa.com%2Fb.jpg&width=144' 34 | 35 | it('without dimensions gives expected output', function () { 36 | const props = { 37 | src: 'http://a.com/b.jpg', 38 | } 39 | const image = Image(props, context) 40 | expectImage(image, urlLandscape) 41 | }) 42 | 43 | it('with landscape dimensions gives expected output', function () { 44 | const props = 45 | { src: 'http://a.com/b.jpg', 46 | width: 1000, 47 | height: 500, 48 | } 49 | const image = Image(props, context) 50 | expectImage(image, urlLandscape) 51 | }) 52 | 53 | it('with small landscape dimensions gives expected output', function () { 54 | const props = 55 | { src: 'http://a.com/b.jpg', 56 | width: 500, 57 | height: 300, 58 | } 59 | const image = Image(props, context) 60 | expectImage(image, urlLandscape) 61 | }) 62 | 63 | it('with tiny dimensions gives expected output', function () { 64 | const props = 65 | { src: 'http://a.com/b.jpg', 66 | width: 72, 67 | height: 72, 68 | } 69 | const image = Image(props, context) 70 | expectImage(image, urlAvatar) 71 | }) 72 | 73 | it('with portrait dimensions gives expected output', function () { 74 | const props = 75 | { src: 'http://a.com/b.jpg', 76 | width: 500, 77 | height: 1000, 78 | } 79 | const image = Image(props, context) 80 | expectImage(image, urlPortrait) 81 | }) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /test/components/widget-cta.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | 3 | import {extractLink} from '../../src/components/widget-cta' 4 | 5 | 6 | describe('WidgetCta', function () { 7 | describe('extract iframe src', function () { 8 | it('gives expected tag and link with iframe html string', function () { 9 | const extract = extractLink('') 10 | expect(extract).to.deep.equal({tag: 'iframe', link: 'https://embed...'}) 11 | }) 12 | it('gives expected tag and link with iframe html string with other text', function () { 13 | const extract = extractLink('abc abc') 14 | expect(extract).to.deep.equal({tag: 'iframe', link: 'https://embed...'}) 15 | }) 16 | it('gives expected tag and link, case-insensitive', function () { 17 | const extract = extractLink('') 18 | expect(extract).to.deep.equal({tag: 'iframe', link: 'https://embed...'}) 19 | }) 20 | }) 21 | describe('extract a href', function () { 22 | it('gives expected tag and link with `a` html string', function () { 23 | const extract = extractLink('buy now') 24 | expect(extract).to.deep.equal({tag: 'a', link: 'https://pay...'}) 25 | }) 26 | it('gives expected tag and link with `a` html string with other text', function () { 27 | const extract = extractLink('abc buy now abc') 28 | expect(extract).to.deep.equal({tag: 'a', link: 'https://pay...'}) 29 | }) 30 | it('gives expected tag and link, case-insensitive', function () { 31 | const extract = extractLink('buy now') 32 | expect(extract).to.deep.equal({tag: 'a', link: 'https://pay...'}) 33 | }) 34 | }) 35 | describe('extract first link', function () { 36 | it('gives first `a` with both in html string', function () { 37 | const extract = extractLink('buy now fff ') 38 | expect(extract).to.deep.equal({tag: 'a', link: 'https://pay...'}) 39 | }) 40 | it('gives first iframe with both in html string', function () { 41 | const extract = extractLink(' fff buy now') 42 | expect(extract).to.deep.equal({tag: 'iframe', link: 'https://embed...'}) 43 | }) 44 | }) 45 | describe('extract from @qfox\' weird cases', function () { 46 | it('gives expected with spaces', function () { 47 | const extract = extractLink('< iframe src = "https://embed...">') 48 | expect(extract).to.deep.equal({tag: 'iframe', link: 'https://embed...'}) 49 | }) 50 | it('gives expected with no quotes', function () { 51 | const extract = extractLink('') 52 | expect(extract).to.deep.equal({tag: 'iframe', link: 'https://embed...'}) 53 | }) 54 | it('gives expected with spaces and no quotes', function () { 55 | const extract = extractLink('< iframe src = https://embed... other="hmm">') 56 | expect(extract).to.deep.equal({tag: 'iframe', link: 'https://embed...'}) 57 | }) 58 | }) 59 | describe('extract without http in link', function () { 60 | it('gives null', function () { 61 | const extract = extractLink('') 62 | expect(extract).to.be.null 63 | }) 64 | }) 65 | describe('extract without a or iframe', function () { 66 | it('gives null', function () { 67 | const extract = extractLink('abc 123') 68 | expect(extract).to.be.null 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /test/convert/determine-fold.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | 3 | import determineFold from '../../src/convert/determine-fold' 4 | 5 | describe('determineFold', function () { 6 | describe('with media fold', function () { 7 | const fixture = 8 | [ { id: 'image-0000', 9 | type: 'image', 10 | metadata: {starred: true}, 11 | }, 12 | { type: 'h1', 13 | html: '

heading 1

', 14 | metadata: {starred: true}, 15 | }, 16 | { type: 'text', 17 | html: '

paragraph 0

', 18 | metadata: {starred: true}, 19 | }, 20 | {type: 'list', html: ''}, 21 | {id: 'image-0001', type: 'image'}, 22 | {type: 'text', html: '

paragraph 1

'}, 23 | ] 24 | const expected = 25 | { starred: 26 | [ { id: 'image-0000', 27 | type: 'image', 28 | metadata: {starred: true}, 29 | }, 30 | { type: 'h1', 31 | html: '

heading 1

', 32 | metadata: {starred: true}, 33 | }, 34 | { type: 'text', 35 | html: '

paragraph 0

', 36 | metadata: {starred: true}, 37 | }, 38 | ], 39 | unstarred: 40 | [ {type: 'list', html: ''}, 41 | {id: 'image-0001', type: 'image'}, 42 | {type: 'text', html: '

paragraph 1

'}, 43 | ], 44 | hasHR: false, 45 | } 46 | 47 | it('correctly splits content', function () { 48 | const folded = determineFold(fixture) 49 | expect(folded).to.deep.equal(expected) 50 | }) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /test/convert/doc-to-grid.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | 3 | import {Node} from 'prosemirror-model' 4 | 5 | import DocToGrid from '../../src/convert/doc-to-grid' 6 | import EdSchema from '../../src/schema/ed-schema' 7 | 8 | 9 | describe('DocToGrid', function () { 10 | describe('with full schema', function () { 11 | const content = 12 | [ {type: 'h1', html: '

heading 1

', metadata: {starred: true}}, 13 | {id: 'image-0000', type: 'image', metadata: {starred: true}}, 14 | {type: 'text', html: '

paragraph 1

', metadata: {starred: true}}, 15 | { type: 'hr', html: '
', metadata: { starred: false } }, 16 | {type: 'h2', html: '

heading 2

', metadata: { starred: false }}, 17 | {type: 'h3', html: '

heading 3

', metadata: { starred: false }}, 18 | {id: 'video-0000', type: 'video', metadata: {starred: false}}, 19 | ] 20 | const map = {} 21 | for (let i = 0, len = content.length; i < len; i++) { 22 | const block = content[i] 23 | if (block.id) { 24 | map[block.id] = block 25 | } 26 | } 27 | const doc = 28 | { 'type': 'doc', 29 | 'content': 30 | [ { 'type': 'heading', 31 | 'attrs': {'level': 1}, 32 | 'content': [{'type': 'text', 'text': 'heading 1'}], 33 | }, 34 | { 'type': 'media', 35 | 'attrs': 36 | { 'id': 'image-0000', 37 | 'type': 'image', 38 | 'widget': 'image', 39 | 'height': 50, 40 | }, 41 | }, 42 | { 'type': 'paragraph', 43 | 'content': [{'type': 'text', 'text': 'paragraph 1'}], 44 | }, 45 | { 'type': 'horizontal_rule', 46 | }, 47 | { 'type': 'heading', 48 | 'attrs': {'level': 2}, 49 | 'content': [{'type': 'text', 'text': 'heading 2'}], 50 | }, 51 | { 'type': 'heading', 52 | 'attrs': {'level': 3}, 53 | 'content': [{'type': 'text', 'text': 'heading 3'}], 54 | }, 55 | { 'type': 'media', 56 | 'attrs': 57 | { 'id': 'video-0000', 58 | 'type': 'video', 59 | 'widget': 'video', 60 | 'height': 50, 61 | }, 62 | }, 63 | ], 64 | } 65 | 66 | it('correctly converts full Doc to Grid content', function () { 67 | const node = Node.fromJSON(EdSchema, doc) 68 | const contentOut = DocToGrid(node, map) 69 | expect(contentOut).to.deep.equal(content) 70 | }) 71 | it('strips non-whitelisted keys out of blocks (and makes image html)', function () { 72 | let map = 73 | { 'image-0000': 74 | { id: 'image-0000', 75 | type: 'image', 76 | metadata: {starred: true}, 77 | foo: 'Wat.', 78 | cover: 79 | { src: 'https://...', 80 | width: 10, 81 | height: 10, 82 | saliency: 'fff', 83 | }, 84 | }, 85 | } 86 | let doc = 87 | { 'type': 'doc', 88 | 'content': 89 | [ { 'type': 'media', 90 | 'attrs': 91 | { 'id': 'image-0000', 92 | 'type': 'image', 93 | 'widget': 'image', 94 | 'height': 50, 95 | }, 96 | }, 97 | ], 98 | } 99 | let expected = 100 | [ { id: 'image-0000', 101 | type: 'image', 102 | html: '', 103 | metadata: {starred: true}, 104 | cover: 105 | { src: 'https://...', 106 | width: 10, 107 | height: 10, 108 | }, 109 | }, 110 | ] 111 | 112 | const node = Node.fromJSON(EdSchema, doc) 113 | const contentOut = DocToGrid(node, map) 114 | expect(contentOut).to.deep.equal(expected) 115 | }) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /test/convert/grid-to-doc.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | 3 | import GridToDoc from '../../src/convert/grid-to-doc' 4 | 5 | 6 | describe('GridToDoc', function () { 7 | describe('with full schema', function () { 8 | const fixture = 9 | [ {id: 'image-0000', type: 'image', metadata: {starred: true}}, 10 | {type: 'h1', html: '

heading 1

', metadata: {starred: true}}, 11 | {type: 'text', html: '

paragraph 1

', metadata: {starred: true}}, 12 | {type: 'h2', html: '

heading 2

'}, 13 | {type: 'h3', html: '

heading 3

'}, 14 | {id: 'quote-0000', type: 'quote', html: '
bq
'}, 15 | {id: 'video-0000', type: 'video'}, 16 | ] 17 | 18 | const expected = 19 | { 'type': 'doc', 20 | 'content': 21 | [ { 'type': 'paragraph' }, 22 | { 'type': 'media', 23 | 'attrs': 24 | { 'id': 'image-0000', 25 | 'type': 'image', 26 | 'widget': 'image', 27 | }, 28 | }, 29 | { 'type': 'heading', 30 | 'attrs': {'level': 1}, 31 | 'content': [{'type': 'text', 'text': 'heading 1'}], 32 | }, 33 | { 'type': 'paragraph', 34 | 'content': [{'type': 'text', 'text': 'paragraph 1'}], 35 | }, 36 | { 'type': 'horizontal_rule', 37 | }, 38 | { 'type': 'heading', 39 | 'attrs': {'level': 2}, 40 | 'content': [{'type': 'text', 'text': 'heading 2'}], 41 | }, 42 | { 'type': 'heading', 43 | 'attrs': {'level': 3}, 44 | 'content': [{'type': 'text', 'text': 'heading 3'}], 45 | }, 46 | { 'type': 'media', 47 | 'attrs': 48 | { 'id': 'quote-0000', 49 | 'type': 'quote', 50 | 'widget': 'quote', 51 | }, 52 | }, 53 | { 'type': 'paragraph' }, 54 | { 'type': 'media', 55 | 'attrs': 56 | { 'id': 'video-0000', 57 | 'type': 'video', 58 | 'widget': 'video', 59 | }, 60 | }, 61 | { 'type': 'paragraph' }, 62 | ], 63 | } 64 | 65 | it('correctly converts Grid content to Doc', function () { 66 | const doc = GridToDoc(fixture).toJSON() 67 | expect(doc).to.deep.equal(expected) 68 | }) 69 | }) 70 | describe('with no starred blocks', function () { 71 | const fixture = 72 | [ {id: 'image-0000', type: 'image', metadata: {starred: false}}, 73 | {id: 'video-0000', type: 'video', metadata: {starred: false}}, 74 | ] 75 | 76 | const expected = 77 | { 'type': 'doc', 78 | 'content': 79 | [ { 'type': 'paragraph' }, 80 | { 'type': 'horizontal_rule' }, 81 | { 'type': 'paragraph' }, 82 | { 'type': 'media', 83 | 'attrs': 84 | { 'id': 'image-0000', 85 | 'type': 'image', 86 | 'widget': 'image', 87 | }, 88 | }, 89 | { 'type': 'paragraph' }, 90 | { 'type': 'media', 91 | 'attrs': 92 | { 'id': 'video-0000', 93 | 'type': 'video', 94 | 'widget': 'video', 95 | }, 96 | }, 97 | { 'type': 'paragraph' }, 98 | ], 99 | } 100 | 101 | it('spaces with empty paragraphs', function () { 102 | const doc = GridToDoc(fixture).toJSON() 103 | expect(doc).to.deep.equal(expected) 104 | }) 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | The Grid Ed Tests 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai' 2 | chai.config.truncateThreshold = 0 3 | 4 | import './ed' 5 | import './components/image' 6 | import './components/widget-cta' 7 | import './convert/determine-fold' 8 | import './convert/doc-to-grid.js' 9 | import './convert/grid-to-doc.js' 10 | import './plugins/placeholder' 11 | import './plugins/widget' 12 | import './plugins/widget-flagged' 13 | import './plugins/widget-iframe' 14 | import './schema/block-meta' 15 | -------------------------------------------------------------------------------- /test/plugins/placeholder.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | 3 | import {mountApp, unmountApp} from '../../src/ed' 4 | 5 | 6 | describe('PluginPlaceholder', function () { 7 | let mount, ed 8 | const fixture = [ 9 | {type: 'h1', html: '

', metadata: {starred: true}}, 10 | {type: 'text', html: '

Text

', metadata: {starred: true}}, 11 | {type: 'text', html: '

', metadata: {starred: true}}, 12 | ] 13 | 14 | beforeEach(function (done) { 15 | mount = document.createElement('div') 16 | document.body.appendChild(mount) 17 | mountApp(mount, { 18 | initialContent: fixture, 19 | onChange: function () {}, 20 | onShareUrl: function () {}, 21 | onShareFile: function () {}, 22 | onRequestCoverUpload: function () {}, 23 | onMount: function (mounted) { 24 | ed = mounted 25 | ed._store.on('plugin.placeholder.initialized', done) 26 | }, 27 | }) 28 | }) 29 | afterEach(function () { 30 | unmountApp(mount) 31 | mount.parentNode.removeChild(mount) 32 | }) 33 | 34 | describe('Content mounting and merging', function () { 35 | it('has classes for placeholders', function () { 36 | const els = ed.pm.editor.content.children 37 | expect(els[0].classList.contains('empty')).to.be.true 38 | expect(els[1].classList.contains('empty')).to.be.false 39 | expect(els[2].classList.contains('empty')).to.be.true 40 | }) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /test/plugins/widget-flagged.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import {mountApp, unmountApp} from '../../src/ed' 3 | 4 | 5 | describe('PluginWidget + featureFlags', function () { 6 | let mount, commands 7 | const fixture = [ 8 | {type: 'h1', html: '

Title

', metadata: {starred: true}}, 9 | { 10 | id: '0000', 11 | type: 'cta', 12 | metadata: {starred: true}, 13 | }, 14 | ] 15 | 16 | describe('without feature flags', function () { 17 | beforeEach(function (done) { 18 | mount = document.createElement('div') 19 | document.body.appendChild(mount) 20 | mountApp(mount, 21 | { 22 | initialContent: fixture, 23 | onChange: function () {}, 24 | onShareUrl: function () {}, 25 | onShareFile: function () {}, 26 | onRequestCoverUpload: function () {}, 27 | featureFlags: {}, 28 | onCommandsChanged: function (c) { commands = c }, 29 | onMount: 30 | function (p) { 31 | done() 32 | }, 33 | } 34 | ) 35 | }) 36 | afterEach(function () { 37 | unmountApp(mount) 38 | mount.parentNode.removeChild(mount) 39 | }) 40 | 41 | it('does not have the class', function () { 42 | const els = document.body.querySelectorAll('.EdSchemaMedia') 43 | expect(els.length).to.equal(1) 44 | const el = document.body.querySelector('.FlaggedWidget') 45 | expect(el).to.not.exist 46 | }) 47 | 48 | it('has command enabled', function () { 49 | expect(commands.ed_add_cta).to.equal('inactive') 50 | }) 51 | }) 52 | 53 | describe('with feature flags', function () { 54 | beforeEach(function (done) { 55 | mount = document.createElement('div') 56 | document.body.appendChild(mount) 57 | mountApp(mount, 58 | { 59 | initialContent: fixture, 60 | onChange: function () {}, 61 | onShareUrl: function () {}, 62 | onShareFile: function () {}, 63 | onRequestCoverUpload: function () {}, 64 | featureFlags: { edCta: false }, 65 | onCommandsChanged: function (c) { commands = c }, 66 | onMount: 67 | function (p) { 68 | done() 69 | }, 70 | } 71 | ) 72 | }) 73 | afterEach(function () { 74 | unmountApp(mount) 75 | mount.parentNode.removeChild(mount) 76 | }) 77 | 78 | it('has the class', function () { 79 | const els = document.body.querySelectorAll('.EdSchemaMedia') 80 | expect(els.length).to.equal(1) 81 | const el = document.body.querySelector('.FlaggedWidget') 82 | expect(el).to.exist 83 | }) 84 | 85 | it('has command flagged', function () { 86 | expect(commands.ed_add_cta).to.equal('flagged') 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /test/plugins/widget-iframe.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import {mountApp, unmountApp} from '../../src/ed' 3 | 4 | 5 | describe('PluginWidgetIframe', function () { 6 | let mount, ed 7 | const fixture = [ 8 | { 9 | id: '0000', 10 | type: 'code', 11 | html: 'hello world;', 12 | metadata: {starred: true}, 13 | }, 14 | { type: 'text', html: '

Text

', metadata: {starred: true} }, 15 | ] 16 | 17 | beforeEach(function (done) { 18 | mount = document.createElement('div') 19 | document.body.appendChild(mount) 20 | mountApp(mount, 21 | { 22 | initialContent: fixture, 23 | onChange: function () {}, 24 | onShareUrl: function () {}, 25 | onShareFile: function () {}, 26 | onRequestCoverUpload: function () {}, 27 | onMount: 28 | function (mounted) { 29 | ed = mounted 30 | done() 31 | }, 32 | widgetPath: '../node_modules/', 33 | } 34 | ) 35 | }) 36 | afterEach(function (done) { 37 | ed._store.on('plugin.widget.iframe.unmount', function () { 38 | done() 39 | }) 40 | unmountApp(mount) 41 | mount.parentNode.removeChild(mount) 42 | }) 43 | 44 | describe('Content mounting and merging', function () { 45 | it('has expected pm document', function () { 46 | const content = ed.pm.editor.state.doc.content.content 47 | expect(content.length).to.equal(3) 48 | expect(content[0].textContent).to.equal('') 49 | expect(content[0].type.name).to.equal('paragraph') 50 | expect(content[1].textContent).to.equal('') 51 | expect(content[1].type.name).to.equal('media') 52 | expect(content[2].textContent).to.equal('Text') 53 | expect(content[2].type.name).to.equal('paragraph') 54 | }) 55 | 56 | it('has mounted widget', function () { 57 | const iframe = ed.pm.editor.content.querySelector('iframe') 58 | expect(iframe).to.exist 59 | expect(iframe.src).to.contain('/node_modules/@the-grid/ced/editor/index.html') 60 | }) 61 | 62 | it('calls detach and removes message listener', function () { 63 | // tested in afterEach 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /test/plugins/widget.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import {mountApp, unmountApp} from '../../src/ed' 3 | 4 | 5 | describe('PluginWidget', function () { 6 | let mount, ed 7 | const fixture = [ 8 | {type: 'h1', html: '

Title

', metadata: {starred: true}}, 9 | { id: '0001', 10 | type: 'placeholder', 11 | metadata: {starred: true}, 12 | }, 13 | {type: 'text', html: '

Text

', metadata: {starred: true}}, 14 | { id: '0000', 15 | type: 'placeholder', 16 | metadata: {starred: true}, 17 | }, 18 | {type: 'text', html: '

Text

', metadata: {starred: true}}, 19 | ] 20 | 21 | beforeEach(function (done) { 22 | mount = document.createElement('div') 23 | document.body.appendChild(mount) 24 | mountApp(mount, { 25 | initialContent: fixture, 26 | onChange: function () {}, 27 | onShareUrl: function () {}, 28 | onShareFile: function () {}, 29 | onRequestCoverUpload: function () {}, 30 | onMount: function (mounted) { 31 | ed = mounted 32 | done() 33 | }, 34 | } 35 | ) 36 | ed.updateProgress('0001', {status: 'Status'}) 37 | ed.updateProgress('0000', {status: 'Status'}) 38 | }) 39 | afterEach(function () { 40 | unmountApp(mount) 41 | mount.parentNode.removeChild(mount) 42 | }) 43 | 44 | describe('Content mounting and merging', function () { 45 | it('has expected pm document', function () { 46 | const content = ed.pm.editor.state.doc.content.content 47 | expect(content.length).to.equal(5) 48 | expect(content[0].textContent).to.equal('Title') 49 | expect(content[0].type.name).to.equal('heading') 50 | expect(content[1].textContent).to.equal('') 51 | expect(content[1].type.name).to.equal('media') 52 | expect(content[1].attrs.id).to.equal('0001') 53 | expect(content[1].attrs.type).to.equal('placeholder') 54 | expect(content[2].textContent).to.equal('Text') 55 | expect(content[2].type.name).to.equal('paragraph') 56 | expect(content[3].textContent).to.equal('') 57 | expect(content[3].type.name).to.equal('media') 58 | expect(content[3].attrs.id).to.equal('0000') 59 | expect(content[3].attrs.type).to.equal('placeholder') 60 | expect(content[4].textContent).to.equal('Text') 61 | expect(content[4].type.name).to.equal('paragraph') 62 | }) 63 | 64 | it('has mounted widget', function () { 65 | const widget = ed.pm.editor.content.querySelector('.Placeholder') 66 | const status = widget.querySelector('.Placeholder-status') 67 | expect(widget).to.exist 68 | expect(status).to.exist 69 | expect(status.textContent).to.equal('Status') 70 | }) 71 | 72 | it('updates placeholder widget status via updateProgress', function (done) { 73 | ed._store.on('media.update.id', function () { 74 | const status = ed.pm.editor.content.querySelector('.Placeholder-status') 75 | expect(status.textContent).to.equal('Status changed') 76 | done() 77 | }) 78 | ed.updateProgress('0001', {status: 'Status changed'}) 79 | }) 80 | 81 | it('updates placeholder widget progress via updateProgress', function (done) { 82 | const progress = ed.pm.editor.content.querySelector('.Progress') 83 | expect(progress).to.not.exist 84 | ed._store.on('media.update.id', function () { 85 | const progress = ed.pm.editor.content.querySelector('.Progress') 86 | expect(progress).to.exist 87 | done() 88 | }) 89 | ed.updateProgress('0001', {progress: 50}) 90 | }) 91 | 92 | it('updates placeholder widget failed true via updateProgress', function (done) { 93 | const el = ed.pm.editor.content.querySelector('.Placeholder') 94 | ed._store.on('media.update.id', function () { 95 | expect(el.classList.contains('Placeholder-error')).to.be.true 96 | done() 97 | }) 98 | expect(el.classList.contains('Placeholder-error')).to.be.false 99 | ed.updateProgress('0001', {failed: true}) 100 | }) 101 | 102 | it('changes widget type via setContent', function () { 103 | ed.setContent([ 104 | { id: '0000', 105 | type: 'image', 106 | }, 107 | ]) 108 | 109 | // PM doc 110 | const content = ed.pm.editor.state.doc.content.content 111 | expect(content[3].textContent).to.equal('') 112 | expect(content[3].type.name).to.equal('media') 113 | expect(content[3].attrs.id).to.equal('0000') 114 | expect(content[3].attrs.type).to.equal('image') 115 | 116 | // React widget 117 | const el = ed.pm.editor.content.querySelector('.WidgetView-image') 118 | expect(el).to.exist 119 | }) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /test/schema/block-meta.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import Flatten from 'html-flatten' 3 | import _ from '../../src/util/lodash' 4 | 5 | import BlockMeta from '../../src/schema/block-meta' 6 | 7 | 8 | describe('BlockMeta', function () { 9 | it('has expected types', function () { 10 | expect(BlockMeta).to.have.all.keys([ 11 | 'image', 'video', 'quote', 'article', 'cta', 'default', 12 | ]) 13 | }) 14 | 15 | describe('Type image', function () { 16 | const image = 17 | { type: 'image', 18 | metadata: 19 | { title: 'Title', 20 | description: 'Description yö', 21 | }, 22 | cover: 23 | { src: 'http://....jpg', 24 | }, 25 | } 26 | 27 | it('gives expected html out', function () { 28 | const html = BlockMeta.image.makeHtml(image) 29 | expect(html).to.equal( 30 | 'Description yö' 31 | ) 32 | }) 33 | it('gives html that survives html-flatten', function (done) { 34 | survivesHtmlFlatten(image, done) 35 | }) 36 | }) 37 | 38 | describe('Type article', function () { 39 | const article = 40 | { type: 'article', 41 | metadata: 42 | { title: 'Title yö', 43 | description: 'Description', 44 | }, 45 | cover: 46 | { src: 'http://....jpg', 47 | }, 48 | } 49 | 50 | it('gives expected html out', function () { 51 | const html = BlockMeta.article.makeHtml(article) 52 | expect(html).to.equal( 53 | '
' + 54 | '' + 55 | '

Title yö

' + 56 | '

Description

' + 57 | '
' 58 | ) 59 | }) 60 | it('gives html that survives html-flatten', function (done) { 61 | survivesHtmlFlatten(article, done) 62 | }) 63 | }) 64 | 65 | describe('Type cta', function () { 66 | const cta = 67 | { type: 'cta', 68 | label: 'label yö', 69 | url: 'http://fff', 70 | } 71 | 72 | it('gives expected html out', function () { 73 | const html = BlockMeta.cta.makeHtml(cta) 74 | expect(html).to.equal( 75 | 'label yö' 76 | ) 77 | }) 78 | it('gives html that survives html-flatten', function (done) { 79 | survivesHtmlFlatten(cta, done) 80 | }) 81 | }) 82 | 83 | describe('Type quote', function () { 84 | const quote = 85 | { type: 'quote', 86 | metadata: { description: 'quö' }, 87 | } 88 | 89 | it('gives expected html out', function () { 90 | const html = BlockMeta.quote.makeHtml(quote) 91 | expect(html).to.equal( 92 | '
quö
' 93 | ) 94 | }) 95 | it('gives html that survives html-flatten', function (done) { 96 | survivesHtmlFlatten(quote, done) 97 | }) 98 | }) 99 | }) 100 | 101 | function survivesHtmlFlatten (block, done) { 102 | let blockToFlatten = _.cloneDeep(block) 103 | const {type} = block 104 | const makeHtml = BlockMeta[type].makeHtml 105 | blockToFlatten.html = makeHtml(block) 106 | let item = {content: [blockToFlatten]} 107 | const expected = _.cloneDeep(blockToFlatten) 108 | 109 | const f = new Flatten() 110 | f.flattenItem(item, function (err) { 111 | if (err) { 112 | return done(err) 113 | } 114 | let flattened = item.content[0] 115 | expect(flattened.type).to.equal(expected.type) 116 | expect(flattened.metadata).to.deep.equal(expected.metadata) 117 | expect(flattened.cover).to.deep.equal(expected.cover) 118 | expect(flattened.html).to.equal(expected.html) 119 | done() 120 | }) 121 | } 122 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack') 2 | var path = require('path') 3 | var CopyWebpackPlugin = require('copy-webpack-plugin') 4 | var packageWidgets = require('./package.json').widgets 5 | 6 | var __DEV = (process.env.DEV === 'true') 7 | var __DEMO = (process.env.DEMO === 'true') 8 | var __KARMA = (process.env.KARMA === 'true') 9 | 10 | 11 | var entry = {} 12 | var plugins = [] 13 | var devtool = 'source-map' 14 | var loaders = [ 15 | { 16 | test: /\.js$/, 17 | loader: 'babel-loader', 18 | include: [ 19 | path.resolve(__dirname, 'demo'), 20 | path.resolve(__dirname, 'src'), 21 | ], 22 | }, 23 | { test: /\.css$/, loader: 'style?singleton!raw' }, 24 | { test: /\.json$/, loader: 'json-loader' }, 25 | ] 26 | 27 | if (__DEV || __DEMO) { 28 | entry.demo = './demo/demo.js' 29 | } else { 30 | entry.build = './src/ed.js' 31 | } 32 | 33 | if (__KARMA) { 34 | entry = './test/index.js' 35 | devtool = 'inline-source-map' 36 | loaders[0].include.push(path.resolve(__dirname, 'test')) 37 | } 38 | 39 | if (__DEV && !__KARMA) { 40 | devtool = 'cheap-module-eval-source-map' 41 | entry.test = './test/index.js' 42 | loaders.push({ 43 | test: /\.js$/, 44 | loader: 'mocha-loader!babel-loader', 45 | include: [ 46 | path.resolve(__dirname, 'test'), 47 | ], 48 | }) 49 | } 50 | 51 | // Build for publish 52 | if (!__DEV && !__KARMA) { 53 | // Needed for React min http://facebook.github.io/react/downloads.html#npm 54 | plugins.push( 55 | new webpack.DefinePlugin({ 56 | 'process.env': { 57 | 'NODE_ENV': '"production"', 58 | }, 59 | }) 60 | ) 61 | plugins.push(new webpack.optimize.UglifyJsPlugin()) 62 | var copyPatterns = [] 63 | 64 | if (__DEMO) { 65 | copyPatterns.push({ 66 | from: 'index.html', 67 | to: 'index.html', 68 | }) 69 | } else { 70 | copyPatterns.push({ 71 | from: 'targets/web.html', 72 | to: 'index.html', 73 | }) 74 | } 75 | // Copy iframe widgets to dist, whitelisted files and directories only 76 | Object.keys(packageWidgets).forEach(function (key) { 77 | var widget = packageWidgets[key] 78 | widget.include.forEach(function (include) { 79 | copyPatterns.push({ 80 | from: 'node_modules/' + key + include, 81 | to: 'node_modules/' + key + include, 82 | }) 83 | }) 84 | }) 85 | plugins.push(new CopyWebpackPlugin(copyPatterns)) 86 | } 87 | 88 | 89 | module.exports = { 90 | entry: entry, 91 | plugins: plugins, 92 | output: { 93 | path: './dist/', 94 | publicPath: '/webpack/', 95 | filename: '[name].js', 96 | sourceMapFilename: '[name].map', 97 | library: 'TheGridEd', 98 | }, 99 | debug: __DEV, 100 | devtool: devtool, 101 | module: { 102 | loaders: loaders, 103 | }, 104 | resolve: { 105 | extensions: ['', '.js', '.json', '.css'], 106 | }, 107 | } 108 | --------------------------------------------------------------------------------