├── client └── src │ ├── blocks │ ├── paragraph │ │ ├── style.scss │ │ └── index.js │ ├── lede │ │ ├── style.scss │ │ └── index.js │ ├── well │ │ ├── style.scss │ │ └── index.js │ ├── rows │ │ ├── style.scss │ │ └── index.js │ ├── button │ │ ├── style.scss │ │ └── index.js │ ├── gallery │ │ ├── index.js │ │ └── style.scss │ ├── image2 │ │ ├── style.scss │ │ └── index.js │ ├── image │ │ ├── index.js │ │ └── style.scss │ ├── html │ │ └── index.js │ ├── index.js │ ├── heading │ │ └── index.js │ ├── list │ │ └── index.js │ └── embed │ │ └── index.js │ ├── components │ ├── image │ │ ├── style.scss │ │ └── index.js │ ├── blocks │ │ ├── image │ │ │ └── style.scss │ │ └── gallery │ │ │ └── index.js │ ├── index.js │ ├── rich-text │ │ └── index.js │ ├── sidebar │ │ ├── index.js │ │ └── header.js │ ├── layout │ │ └── index.js │ ├── changes-monitor │ │ └── index.js │ ├── header │ │ └── index.js │ ├── visual-editor │ │ ├── index.js │ │ ├── block-inspector-button.js │ │ └── style.scss │ ├── personalisation │ │ └── index.js │ └── with-fetch │ │ └── index.js │ ├── hooks │ ├── index.js │ └── personalisation.js │ ├── moment.js │ ├── store │ ├── index.js │ ├── actions.js │ ├── effects.js │ ├── selectors.js │ ├── reducer.js │ └── middlewares.js │ ├── api-request.js │ ├── config.js │ ├── globals.js │ ├── style.scss │ ├── bundle.js │ ├── buttons.scss │ └── code-editor-config.js ├── postcss.config.js ├── .babelrc ├── setup.json ├── src ├── Blocks │ ├── CodeBlock.php │ ├── ListBlock.php │ ├── QuoteBlock.php │ ├── TableBlock.php │ ├── ParagraphBlock.php │ ├── PullQuoteBlock.php │ ├── SeparatorBlock.php │ ├── HTMLBlock.php │ ├── BaseBlock.php │ ├── EmbedBlock.php │ ├── ImageGallery.php │ ├── HeadingBlock.php │ ├── ImageBlock.php │ └── Image2Block.php ├── Forms │ └── GutenbergEditorField.php ├── Extensions │ └── GutenbergContentFields.php ├── FieldTypes │ └── DBGutenbergText.php └── Controllers │ └── APIController.php ├── .github └── dependabot.yml ├── _config ├── routes.yml └── config.yml ├── examples ├── bundle.js ├── blocks │ └── feature-panel │ │ ├── style.scss │ │ └── index.js └── FeaturePanelBlockExample.php ├── _config.php ├── composer.json ├── package.json ├── webpack.config.js ├── templates └── InlineGallery.ss └── setup.php /client/src/blocks/paragraph/style.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/components/image/style.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /client/src/hooks/index.js: -------------------------------------------------------------------------------- 1 | import './personalisation'; 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@wordpress/default", "stage-3"] 3 | } 4 | -------------------------------------------------------------------------------- /setup.json: -------------------------------------------------------------------------------- 1 | { 2 | "default-vendor": "vendor", 3 | "default-module": "module" 4 | } 5 | -------------------------------------------------------------------------------- /client/src/components/blocks/image/style.scss: -------------------------------------------------------------------------------- 1 | .gutenbergeditor-image { 2 | img { 3 | max-width: 100%; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/Blocks/CodeBlock.php: -------------------------------------------------------------------------------- 1 | { 6 | [ 7 | FeaturePanel, 8 | ].forEach(block => { 9 | registerBlockType(block.name, block.settings); 10 | }) 11 | }); 12 | -------------------------------------------------------------------------------- /src/Blocks/HTMLBlock.php: -------------------------------------------------------------------------------- 1 | ' . $content . ''; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/store/index.js: -------------------------------------------------------------------------------- 1 | import { registerStore } from "@wordpress/data"; 2 | 3 | import reducer from "./reducer"; 4 | import * as selectors from "./selectors"; 5 | import * as actions from "./actions"; 6 | import applyMiddlewares from './middlewares'; 7 | 8 | const store = registerStore("standalone-gutenberg", { 9 | reducer, 10 | actions, 11 | selectors 12 | }); 13 | 14 | applyMiddlewares(store); 15 | 16 | export default store; 17 | -------------------------------------------------------------------------------- /client/src/blocks/button/style.scss: -------------------------------------------------------------------------------- 1 | .wp-block-button__link { 2 | background-color: #32373c; 3 | border: none; 4 | border-radius: 23px; 5 | box-shadow: none; 6 | color: #fff; 7 | display: inline-block; 8 | font-size: 18px; 9 | line-height: 24px!important; 10 | margin: 0; 11 | padding: 11px 24px; 12 | text-align: center; 13 | text-decoration: none; 14 | white-space: normal; 15 | word-break: break-all; 16 | } 17 | -------------------------------------------------------------------------------- /src/Blocks/BaseBlock.php: -------------------------------------------------------------------------------- 1 | =5.6.0 7 | * 8 | * For full copyright and license information, please view the 9 | * LICENSE.md file that was distributed with this source code. 10 | * 11 | * @package MadeHQ\Gutenberg 12 | * @author Lee Bradley 13 | * @copyright 2018 Made Media 14 | * @license https://opensource.org/licenses/BSD-3-Clause BSD-3-Clause 15 | * @link https://github.com/MadeHQ/silverstripe-gutenberg-editor 16 | */ 17 | -------------------------------------------------------------------------------- /client/src/store/actions.js: -------------------------------------------------------------------------------- 1 | export function toggleSidebar() { 2 | return { 3 | type: 'TOGGLE_SIDEBAR' 4 | }; 5 | } 6 | 7 | export function setActivePanel(panel) { 8 | return { 9 | type: 'SET_ACTIVE_PANEL', 10 | panel 11 | }; 12 | } 13 | 14 | export function openGeneralSidebar(name) { 15 | return { 16 | type: 'OPEN_GENERAL_SIDEBAR', 17 | name, 18 | }; 19 | } 20 | 21 | export function closeGeneralSidebar() { 22 | return { 23 | type: 'CLOSE_GENERAL_SIDEBAR', 24 | }; 25 | } 26 | 27 | 28 | export function notifyOfChange() { 29 | return { 30 | type: 'NOTIFY_OF_CHANGE', 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /client/src/components/rich-text/index.js: -------------------------------------------------------------------------------- 1 | import { RichText as BaseRichText } from '@wordpress/blocks/rich-text'; 2 | import { withSafeTimeout } from '@wordpress/components'; 3 | import { forEach, merge } from 'lodash'; 4 | 5 | export class RichText extends BaseRichText { 6 | removeFormat( format ) { 7 | this.editor.focus(); 8 | this.editor.formatter.remove( format ); 9 | this.onChange(); 10 | } 11 | 12 | applyFormat( format, args, node ) { 13 | this.editor.focus(); 14 | this.editor.formatter.apply( format, args, node ); 15 | this.onChange(); 16 | } 17 | } 18 | 19 | export default withSafeTimeout(RichText); 20 | -------------------------------------------------------------------------------- /client/src/store/effects.js: -------------------------------------------------------------------------------- 1 | import { select } from '@wordpress/data'; 2 | 3 | export default { 4 | NOTIFY_OF_CHANGE(action, store) { 5 | const { 6 | getCurrentPost, 7 | getPostEdits, 8 | getEditedPostContent 9 | } = select('core/editor'); 10 | 11 | const { getState, dispatch } = store; 12 | 13 | const state = getState(); 14 | const edits = getPostEdits( state ); 15 | const toSend = { 16 | ...edits, 17 | content: getEditedPostContent( state ), 18 | }; 19 | 20 | window.dispatchEvent(new CustomEvent('gutenberg:content', { 21 | detail: toSend.content 22 | })); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /_config/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Name: silverstripegutenberg 3 | After: 4 | - '#corefieldtypes' 5 | --- 6 | 7 | # Configure Admin Extensions: 8 | 9 | SilverStripe\Admin\LeftAndMain: 10 | extra_requirements_css: 11 | - "mademedia/silverstripe-gutenberg-editor: client/dist/style.css" 12 | extra_requirements_javascript: 13 | - "mademedia/silverstripe-gutenberg-editor: client/dist/globals.js" 14 | - "mademedia/silverstripe-gutenberg-editor: client/dist/bundle.js" 15 | 16 | SilverStripe\Core\Injector\Injector: 17 | GutenbergText: 18 | class: MadeHQ\Gutenberg\FieldTypes\DBGutenbergText 19 | MadeHQ\Gutenberg\Controllers\APIController: 20 | properties: 21 | ThumbnailGenerator: '%$SilverStripe\AssetAdmin\Model\ThumbnailGenerator.assetadmin' 22 | -------------------------------------------------------------------------------- /examples/blocks/feature-panel/style.scss: -------------------------------------------------------------------------------- 1 | // This style is specific to this block, however, we also include 2 | // the the sass files from our project so that our block can look 3 | // somewhat like what it will look like on the front end 4 | 5 | // @import '../../../../../sources/stylesheets/variables'; 6 | // @import '../../../../../sources/stylesheets/functions'; 7 | // @import '../../../../../sources/stylesheets/mixins'; 8 | // @import '../../../../../sources/stylesheets/module/features'; 9 | 10 | // @import '../../../../../sources/stylesheets/utilities/visibility'; 11 | // @import '../../../../../sources/stylesheets/utilities/layout'; 12 | // @import '../../../../../sources/stylesheets/utilities/tones'; 13 | // @import '../../../../../sources/stylesheets/utilities/text'; 14 | -------------------------------------------------------------------------------- /src/Forms/GutenbergEditorField.php: -------------------------------------------------------------------------------- 1 | 'gutenberg-api/oembed', 17 | ]; 18 | 19 | $this->extend('updateGutenbergData', $gutenbergData); 20 | 21 | $attributes = array_merge(parent::getAttributes(), [ 22 | 'data-gutenberg' => Convert::array2json($gutenbergData), 23 | ]); 24 | 25 | $this->extend('updateAttributes', $attributes); 26 | 27 | return $attributes; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/src/blocks/gallery/index.js: -------------------------------------------------------------------------------- 1 | import { withInstanceId } from '@wordpress/components'; 2 | 3 | import { __ } from '@wordpress/i18n'; 4 | 5 | import Gallery from '../../components/blocks/gallery'; 6 | 7 | import './style.scss'; 8 | 9 | export const name = 'madehq/image-gallery'; 10 | 11 | export const settings = { 12 | title: __( 'Gallery' ), 13 | 14 | description: __( 'This will allow you to create a gallery with multiple images' ), 15 | 16 | icon: 'format-gallery', 17 | 18 | category: 'common', 19 | 20 | keywords: [ __( 'image' ) ], 21 | 22 | edit: withInstanceId(Gallery), 23 | 24 | attributes: { 25 | items: { 26 | type: 'array', 27 | } 28 | }, 29 | 30 | save: function(data) { 31 | return ''; 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /examples/FeaturePanelBlockExample.php: -------------------------------------------------------------------------------- 1 | Theme = array_key_exists('theme', $attributes) && $attributes['theme'] ? $attributes['theme'] : 'black'; 24 | 25 | return $this->renderWith(sprintf('Blocks/FeaturePanel_%s', $panel->Type), $panel); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Extensions/GutenbergContentFields.php: -------------------------------------------------------------------------------- 1 | get(Injector::class, 'HTMLText'); 19 | if ($injectorConfig && isset($injectorConfig['class']) && $injectorConfig['class'] === DBGutenbergText::class) { 20 | $fields->replaceField('Content', GutenbergEditorField::create('Content')); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/store/selectors.js: -------------------------------------------------------------------------------- 1 | import { get, map } from 'lodash'; 2 | import createSelector from 'rememo'; 3 | import { serialize } from '@wordpress/blocks'; 4 | 5 | export function getPreferences(state) { 6 | return state.preferences; 7 | } 8 | 9 | export function getPreference(state, preferenceKey, defaultValue) { 10 | const preferences = getPreferences(state); 11 | const value = preferences[preferenceKey]; 12 | return value === undefined ? defaultValue : value; 13 | } 14 | 15 | export function isSidebarOpened(state) { 16 | return getPreference(state, "sidebar"); 17 | } 18 | 19 | export function getActivePanel(state) { 20 | return state.panel; 21 | } 22 | 23 | export function isFeatureActive(state, feature) { 24 | return !!getPreference(state, 'feature', [])[feature]; 25 | } 26 | 27 | export function getActiveGeneralSidebarName(state) { 28 | return getPreference(state, 'activeGeneralSidebar', null); 29 | } 30 | -------------------------------------------------------------------------------- /client/src/blocks/gallery/style.scss: -------------------------------------------------------------------------------- 1 | .gutenberg__editor .gutenberg-gallery { 2 | align-items: flex-end; 3 | display: flex; 4 | overflow-x: scroll; 5 | padding-top: 10px; 6 | 7 | .gutenberg-gallery__item { 8 | display: inline-block; 9 | margin: 0 2% 0; 10 | max-width: 30%; 11 | min-width: 30%; 12 | position: relative; 13 | } 14 | 15 | .gutenbergeditor-image fieldset.full-preview__fields { 16 | display: block; 17 | position: static; 18 | } 19 | 20 | .uploadfield-item__view-btn { 21 | background-color: rgb(231, 235, 240); 22 | bottom: auto; 23 | left: -10px; 24 | right: auto; 25 | top: -10px; 26 | } 27 | 28 | .gutenberg-gallery__item-remove { 29 | background-color: #e7ebf0; 30 | border-radius: 100%; 31 | cursor: pointer; 32 | height: 30px; 33 | position: absolute; 34 | right: -10px; 35 | top: -10px; 36 | width: 30px; 37 | z-index: 1; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client/src/store/reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | export function preferences(state = { sidebar: false }, action) { 4 | switch (action.type) { 5 | case 'TOGGLE_SIDEBAR': 6 | return { 7 | ...state, 8 | sidebar: !state.sidebar 9 | }; 10 | } 11 | 12 | return state; 13 | } 14 | 15 | export function panel(state = 'block', action) { 16 | switch (action.type) { 17 | case 'SET_ACTIVE_PANEL': 18 | return action.panel; 19 | } 20 | 21 | return state; 22 | } 23 | 24 | export function features(state = { fixedToolbar: false }, action) { 25 | if (action.type === 'TOGGLE_FEATURE') { 26 | return { 27 | ...state, 28 | [action.feature]: !state[action.feature], 29 | }; 30 | } 31 | 32 | return state; 33 | } 34 | 35 | export default combineReducers({ 36 | preferences, 37 | panel, 38 | features, 39 | }); 40 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mademedia/silverstripe-gutenberg-editor", 3 | "type": "silverstripe-vendormodule", 4 | "description": "A block based editor for Silverstripe.", 5 | "homepage": "https://github.com/MadeHQ/silverstripe-gutenberg-editor", 6 | "keywords": ["Silverstripe","Editor"], 7 | "license": "BSD-3-Clause", 8 | "authors": [ 9 | { 10 | "name": "Lee Bradley", 11 | "role": "Developer", 12 | "email": "lee.bradley@mademedia.co.uk", 13 | "homepage": "https://made.media" 14 | } 15 | ], 16 | "require": { 17 | "php": ">=5.6.0", 18 | "silverstripe/framework": "^4@dev", 19 | "embed/embed": "^3.3" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "MadeHQ\\Gutenberg\\": "src/" 24 | } 25 | }, 26 | "extra": { 27 | "branch-alias": { 28 | "dev-master": "1.0.x-dev" 29 | }, 30 | "expose": [ 31 | "client/dist" 32 | ] 33 | }, 34 | "minimum-stability": "dev", 35 | "prefer-stable": true 36 | } 37 | -------------------------------------------------------------------------------- /client/src/components/sidebar/index.js: -------------------------------------------------------------------------------- 1 | import { withSelect } from "@wordpress/data"; 2 | import { Panel, PanelBody } from "@wordpress/components"; 3 | import { 4 | BlockInspector, 5 | PostTaxonomiesCheck, 6 | PostTaxonomies as PostTaxonomiesForm 7 | } from "@wordpress/editor"; 8 | 9 | import SidebarHeader from "./header"; 10 | 11 | function Sidebar({ panel }) { 12 | return ( 13 |
14 | 15 | 16 | {panel === "template" && ( 17 | 18 | 19 | 20 | 21 | 22 | )} 23 | {panel === "block" && ( 24 | 25 | 26 | 27 | )} 28 | 29 |
30 | ); 31 | } 32 | 33 | export default withSelect(select => ({ 34 | panel: select("standalone-gutenberg").getActivePanel() 35 | }))(Sidebar); 36 | -------------------------------------------------------------------------------- /client/src/components/layout/index.js: -------------------------------------------------------------------------------- 1 | import { Popover } from '@wordpress/components'; 2 | import { EditorNotices, PreserveScrollInReorder } from '@wordpress/editor'; 3 | import { __ } from '@wordpress/i18n'; 4 | import { withSelect } from '@wordpress/data'; 5 | 6 | import Header from '../header'; 7 | import VisualEditor from '../visual-editor'; 8 | import Sidebar from '../sidebar'; 9 | 10 | function Layout({showSidebar, pageContent}) { 11 | return ( 12 |
13 |
14 |
20 | 21 | 22 | 23 |
24 | {showSidebar && } 25 | 26 |
27 | ); 28 | } 29 | 30 | export default withSelect(select => ({ 31 | showSidebar: select('standalone-gutenberg').isSidebarOpened(), 32 | }))(Layout); 33 | -------------------------------------------------------------------------------- /client/src/blocks/image2/style.scss: -------------------------------------------------------------------------------- 1 | .full-preview-holder { 2 | .full-preview-list { 3 | display: flex; 4 | flex-wrap: nowrap; 5 | overflow-x: auto; 6 | -webkit-overflow-scrolling: touch; 7 | -ms-overflow-style: -ms-autohiding-scrollbar; 8 | } 9 | 10 | .full-preview { 11 | flex: 0 0 auto; 12 | margin-left: 10px; 13 | width: 90%; 14 | position: relative; 15 | 16 | &:first-child { 17 | margin-left: 0; 18 | } 19 | 20 | img { 21 | width: 100%; 22 | height: auto; 23 | } 24 | } 25 | 26 | .uploadfield-holder { 27 | margin-top: 10px; 28 | } 29 | 30 | .full-preview__fields { 31 | position: absolute; 32 | bottom: 0; 33 | left: 0; 34 | width: 100%; 35 | padding: 10px; 36 | box-sizing: border-box; 37 | background: rgba(255, 255, 255, 0.5); 38 | opacity: 0.9; 39 | 40 | div:first-child { 41 | margin-bottom: 5px; 42 | } 43 | } 44 | 45 | .full-preview:hover { 46 | .full-preview__fields { 47 | opacity: 1; 48 | } 49 | } 50 | } 51 | 52 | .full-preview-holder.has-one { 53 | .full-preview { 54 | width: 100%; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /client/src/store/middlewares.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import { flowRight } from 'lodash'; 5 | import refx from 'refx'; 6 | 7 | /** 8 | * Internal dependencies 9 | */ 10 | import effects from './effects'; 11 | 12 | /** 13 | * Applies the custom middlewares used specifically in the editor module. 14 | * 15 | * @param {Object} store Store Object. 16 | * 17 | * @return {Object} Update Store Object. 18 | */ 19 | function applyMiddlewares(store) { 20 | const middlewares = [ 21 | refx(effects), 22 | ]; 23 | 24 | let enhancedDispatch = () => { 25 | throw new Error( 26 | 'Dispatching while constructing your middleware is not allowed. ' + 27 | 'Other middleware would not be applied to this dispatch.' 28 | ); 29 | }; 30 | 31 | let chain = []; 32 | 33 | const middlewareAPI = { 34 | getState: store.getState, 35 | dispatch: (...args) => enhancedDispatch(...args), 36 | }; 37 | 38 | chain = middlewares.map(middleware => middleware(middlewareAPI)); 39 | enhancedDispatch = flowRight(...chain)(store.dispatch); 40 | 41 | store.dispatch = enhancedDispatch; 42 | 43 | return store; 44 | } 45 | 46 | export default applyMiddlewares; 47 | -------------------------------------------------------------------------------- /client/src/components/changes-monitor/index.js: -------------------------------------------------------------------------------- 1 | import { Component, compose } from '@wordpress/element'; 2 | import { withSelect, withDispatch } from '@wordpress/data'; 3 | 4 | export class ChangesMonitor extends Component { 5 | constructor(props) { 6 | super(props); 7 | 8 | this.pendingNotification = null; 9 | } 10 | 11 | componentDidUpdate() { 12 | if (this.pendingNotification) { 13 | clearTimeout(this.pendingNotification); 14 | } 15 | 16 | this.pendingNotification = setTimeout( 17 | () => this.props.notifyOfChange(), 18 | 500 19 | ); 20 | } 21 | 22 | componentWillUnmount() { 23 | if (this.pendingNotification) { 24 | clearTimeout(this.pendingNotification); 25 | } 26 | } 27 | 28 | render() { 29 | return null; 30 | } 31 | } 32 | 33 | export default compose([ 34 | withSelect(select => ({ 35 | content: select('core/editor').getEditedPostContent(), 36 | typing: select('core/editor').isTyping(), 37 | edits: select('core/editor').getPostEdits(), 38 | })), 39 | 40 | withDispatch(dispatch => ({ 41 | notifyOfChange: dispatch('standalone-gutenberg').notifyOfChange, 42 | })), 43 | ])(ChangesMonitor); 44 | -------------------------------------------------------------------------------- /client/src/blocks/image/index.js: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { withInstanceId } from '@wordpress/components'; 3 | 4 | import ImageBlock from '../../components/blocks/image'; 5 | 6 | // import './style.scss'; 7 | 8 | const schema = { 9 | content: { 10 | type: 'object' 11 | }, 12 | }; 13 | 14 | const supports = { 15 | className: false, 16 | }; 17 | 18 | export const name = 'madehq/image-selector'; 19 | 20 | export const settings = { 21 | title: __( 'Image' ), 22 | 23 | description: __( 'This will allow you to pull an image into content' ), 24 | 25 | icon: 'format-image', 26 | 27 | category: 'common', 28 | 29 | keywords: [ __( 'text' ) ], 30 | 31 | edit: withInstanceId(ImageBlock), 32 | 33 | attributes: { 34 | fileId: { 35 | type: 'string', 36 | selector: 'input[type="hidden"]', 37 | default: false, 38 | }, 39 | title: { 40 | type: 'string', 41 | }, 42 | altText: { 43 | type: 'string', 44 | }, 45 | height: { 46 | type: 'string' 47 | }, 48 | width: { 49 | type: 'string' 50 | }, 51 | url: { 52 | type: 'string', 53 | }, 54 | }, 55 | 56 | save: function(data) { 57 | return `${data.attributes.altText}`; 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "standalone-gutenberg", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "ajv": "^6.12.6", 7 | "gutenberg": "github:Wordpress/gutenberg#4ba122af127bb83d679d6c019a5551279a02dbfe", 8 | "moment": "^2.21.0", 9 | "tinymce": "^6.0.2", 10 | "whatwg-fetch": "^3.6.2" 11 | }, 12 | "scripts": { 13 | "build": "cross-env BABEL_ENV=default NODE_ENV=production webpack", 14 | "start": "cross-env BABEL_ENV=default webpack-dev-server --open", 15 | "watch": "cross-env BABEL_ENV=default webpack --watch --progress" 16 | }, 17 | "devDependencies": { 18 | "@wordpress/babel-preset-default": "^6.10.0", 19 | "@wordpress/browserslist-config": "^2.1.0", 20 | "autoprefixer": "^9.8.8", 21 | "babel-core": "^6.26.3", 22 | "babel-loader": "^7.1.2", 23 | "babel-plugin-transform-async-generator-functions": "^6.24.1", 24 | "babel-preset-stage-3": "^6.24.1", 25 | "cross-env": "^7.0.3", 26 | "extract-text-webpack-plugin": "^3.0.2", 27 | "node-sass": "^7.0.0", 28 | "pegjs": "^0.10.0", 29 | "pegjs-loader": "^0.5.6", 30 | "postcss-loader": "^2.0.9", 31 | "prettier": "^2.6.2", 32 | "raw-loader": "^0.5.1", 33 | "sass-loader": "^7.3.1", 34 | "string-replace-loader": "^2.1.1", 35 | "webpack": "^3.12.0", 36 | "webpack-dev-server": "^2.0.0" 37 | }, 38 | "browserslist": [ 39 | "extends @wordpress/browserslist-config" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /client/src/blocks/image/style.scss: -------------------------------------------------------------------------------- 1 | .gutenberg__editor .gutenbergeditor-image { 2 | position: relative; 3 | 4 | input.entwine-uploadfield { 5 | display: none; 6 | } 7 | 8 | .uploadfield-item { 9 | border: none; 10 | height: auto; 11 | position: static; 12 | } 13 | .uploadfield-item:last-child { 14 | height: auto; 15 | } 16 | .uploadfield-item__thumbnail, 17 | .uploadfield-item__details { 18 | display: none; 19 | } 20 | 21 | .uploadfield-item__view-btn { 22 | background-color: rgba(231, 235, 240, 0.6); 23 | border-radius: 100%; 24 | bottom: 5%; 25 | position: absolute; 26 | right: 5%; 27 | 28 | &::before { 29 | top: -2px; 30 | } 31 | } 32 | 33 | /** 34 | * As the block can be removed there is no need for the image to be removed 35 | */ 36 | .uploadfield-item__remove-btn { 37 | display: none; 38 | } 39 | 40 | .full-preview img { 41 | max-width: 100%; 42 | } 43 | 44 | fieldset.full-preview__fields { 45 | background-color: rgba(255, 255, 255, 0.8); 46 | display: none; 47 | height: 100%; 48 | left: 0; 49 | padding: 10%; 50 | position: absolute; 51 | top: 0; 52 | width: 100%; 53 | } 54 | 55 | &:hover fieldset.full-preview__fields { 56 | display: block; 57 | } 58 | 59 | &.gutenbergeditor-image__error { 60 | background-color: #FDD; 61 | border: 2px solid #C33; 62 | border-radius: 5px; 63 | padding: 0 9px; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /client/src/components/header/index.js: -------------------------------------------------------------------------------- 1 | import { compose } from "@wordpress/element"; 2 | import { withSelect, withDispatch } from "@wordpress/data"; 3 | import { IconButton } from "@wordpress/components"; 4 | import { 5 | EditorHistoryRedo, 6 | EditorHistoryUndo, 7 | Inserter, 8 | MultiBlocksSwitcher, 9 | NavigableToolbar 10 | } from "@wordpress/editor"; 11 | import { __ } from "@wordpress/i18n"; 12 | 13 | function Header(props) { 14 | return ( 15 |
21 | 25 | 26 | 27 | 28 | 29 | 30 |
31 | 37 |
38 |
39 | ); 40 | } 41 | 42 | export default compose( 43 | withSelect(select => ({ 44 | isSidebarOpened: select("standalone-gutenberg").isSidebarOpened() 45 | })), 46 | withDispatch(dispatch => ({ 47 | toggleSidebar: dispatch("standalone-gutenberg").toggleSidebar 48 | })) 49 | )(Header); 50 | -------------------------------------------------------------------------------- /client/src/components/visual-editor/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { 5 | BlockList, 6 | CopyHandler, 7 | WritingFlow, 8 | ObserveTyping, 9 | EditorGlobalKeyboardShortcuts, 10 | BlockSelectionClearer, 11 | MultiSelectScrollIntoView, 12 | } from '@wordpress/editor'; 13 | import { Fragment, compose } from '@wordpress/element'; 14 | import { withSelect } from '@wordpress/data'; 15 | import { withViewportMatch } from '@wordpress/viewport'; 16 | 17 | /** 18 | * Internal dependencies 19 | */ 20 | import './style.scss'; 21 | import BlockInspectorButton from './block-inspector-button'; 22 | 23 | function VisualEditorBlockMenu( { children, onClose } ) { 24 | return ( 25 | 26 | { children } 27 | 28 | ); 29 | } 30 | 31 | function VisualEditor( { hasFixedToolbar, isLargeViewport } ) { 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | export default compose([ 47 | withSelect(select => ({ 48 | hasFixedToolbar: select('standalone-gutenberg').isFeatureActive('fixedToolbar'), 49 | })), 50 | 51 | withViewportMatch({ 52 | isLargeViewport: 'medium', 53 | }), 54 | ])(VisualEditor); 55 | -------------------------------------------------------------------------------- /client/src/blocks/well/index.js: -------------------------------------------------------------------------------- 1 | import { InnerBlocks } from '@wordpress/blocks'; 2 | 3 | import { createBlock } from '@wordpress/blocks/api'; 4 | 5 | import './style.scss'; 6 | 7 | export const name = 'madehq/well'; 8 | 9 | export const settings = { 10 | title: 'Well', 11 | 12 | description: 'Insert a well of content', 13 | 14 | icon: 'button', 15 | 16 | category: 'common', 17 | 18 | edit({ isSelected }) { 19 | return [ 20 |
21 | 22 |
23 | ]; 24 | }, 25 | 26 | attributes: {}, 27 | 28 | transforms: { 29 | from: [ 30 | { 31 | type: 'block', 32 | blocks: [ 'core/paragraph' ], 33 | transform: ( { content } ) => { 34 | return createBlock( 'madehq/well', { 35 | nodeName: 'P', 36 | content, 37 | } ); 38 | }, 39 | }, 40 | { 41 | type: 'pattern', 42 | regExp: /^(#{2,6})\s/, 43 | transform: ( { content } ) => { 44 | return createBlock( 'madehq/well', { 45 | nodeName: 'P', 46 | content, 47 | } ); 48 | }, 49 | }, 50 | ], 51 | to: [ 52 | { 53 | type: 'block', 54 | blocks: [ 'core/paragraph' ], 55 | transform: ( { content } ) => { 56 | return createBlock( 'core/paragraph', { 57 | content, 58 | } ); 59 | }, 60 | }, 61 | { 62 | type: 'block', 63 | blocks: [ 'core/heading' ], 64 | transform: ( { content } ) => { 65 | return createBlock( 'core/heading', { 66 | content, 67 | } ); 68 | }, 69 | }, 70 | ], 71 | }, 72 | 73 | save( { attributes } ) { 74 | return ( 75 |
76 | 77 |
78 | ); 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /src/Blocks/EmbedBlock.php: -------------------------------------------------------------------------------- 1 | get('width')); 35 | $height = sprintf('height="%s"', static::config()->get('height')); 36 | 37 | $wrapperClass = static::config()->get('wrapperClass'); 38 | 39 | if (!array_key_exists('html', $attributes)) { 40 | return sprintf("
%s
", $wrapperClass, $content); 41 | } 42 | 43 | $markup = $attributes['html']; 44 | 45 | preg_match('/src\s*=\s*"(.+?)"/', $markup, $matches); 46 | 47 | if (count($matches) === 2) { 48 | $url = $matches[1]; 49 | 50 | if (stripos($url, 'youtube') !== false) { 51 | $url = Controller::join_links($url, '?rel=0&fs=0&showinfo=0'); 52 | } 53 | 54 | $markup = str_replace($matches[1], $url, $markup); 55 | } 56 | 57 | // Replace width & height 58 | $markup = preg_replace(['/width="(\w+)"/', '/height="(\w+)"/'], [$width, $height], $markup); 59 | 60 | return sprintf("
%s
", $wrapperClass, $markup); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/src/components/sidebar/header.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import { withSelect, withDispatch } from "@wordpress/data"; 5 | import { compose } from "@wordpress/element"; 6 | import { __, _n, sprintf } from "@wordpress/i18n"; 7 | import { IconButton } from "@wordpress/components"; 8 | import { query } from "@wordpress/data"; 9 | 10 | const SidebarHeader = ({ panel, onSetPanel, onToggleSidebar, count }) => { 11 | // Do not display "0 Blocks". 12 | count = count === 0 ? 1 : count; 13 | const closeSidebar = () => onToggleSidebar(undefined, false); 14 | 15 | return ( 16 |
17 | 26 | 31 |
32 | ); 33 | }; 34 | 35 | export default compose( 36 | withSelect(select => ({ 37 | count: select("core/editor").getSelectedBlockCount(), 38 | panel: select("standalone-gutenberg").getActivePanel() 39 | })), 40 | withDispatch(dispatch => ({ 41 | onSetPanel: dispatch("standalone-gutenberg").setActivePanel, 42 | onToggleSidebar: dispatch("standalone-gutenberg").toggleSidebar 43 | })) 44 | )(SidebarHeader); 45 | 46 | /* 55 | */ 56 | -------------------------------------------------------------------------------- /client/src/api-request.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Thin jQuery.ajax wrapper for WP REST API requests. 4 | * 5 | * Currently only applies to requests that do not use the `wp-api.js` Backbone 6 | * client library, though this may change. Serves several purposes: 7 | * 8 | * - Allows overriding these requests as needed by customized WP installations. 9 | * - Sends the REST API nonce as a request header. 10 | * - Allows specifying only an endpoint namespace/path instead of a full URL. 11 | * 12 | * @link https://github.com/WordPress/WordPress/blob/master/wp-includes/js/api-request.js 13 | * @since 4.9.0 14 | */ 15 | 16 | ( function( $ ) { 17 | function apiRequest( options ) { 18 | options = apiRequest.buildAjaxOptions( options ); 19 | return apiRequest.transport( options ); 20 | } 21 | 22 | apiRequest.buildAjaxOptions = function( options ) { 23 | var url = options.url; 24 | var path = options.path; 25 | 26 | if (typeof options.namespace === 'string' && typeof options.endpoint === 'string') { 27 | var endpointTrimmed = options.endpoint.replace( /^\//, '' ); 28 | 29 | path = options.namespace.replace( /^\/|\/$/g, '' ); 30 | 31 | if ( endpointTrimmed ) { 32 | path += '/' + endpointTrimmed; 33 | } 34 | } 35 | 36 | if (!url && path) { 37 | url = path; 38 | } 39 | 40 | url = url.replace('wp/v2', 'gutenberg-api'); 41 | 42 | // Do not mutate the original options object. 43 | options = $.extend( {}, options, { 44 | url: url 45 | } ); 46 | 47 | delete options.path; 48 | delete options.namespace; 49 | delete options.endpoint; 50 | 51 | return options; 52 | }; 53 | 54 | apiRequest.transport = $.ajax; 55 | 56 | /** @namespace wp */ 57 | window.wp = window.wp || {}; 58 | window.wp.apiRequest = apiRequest; 59 | } )( jQuery ); 60 | -------------------------------------------------------------------------------- /client/src/components/visual-editor/block-inspector-button.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import { flow, noop } from 'lodash'; 5 | 6 | /** 7 | * WordPress dependencies 8 | */ 9 | import { __ } from '@wordpress/i18n'; 10 | import { IconButton, withSpokenMessages } from '@wordpress/components'; 11 | import { withSelect, withDispatch } from '@wordpress/data'; 12 | import { compose } from '@wordpress/element'; 13 | 14 | export function BlockInspectorButton( { 15 | areAdvancedSettingsOpened, 16 | closeSidebar, 17 | openEditorSidebar, 18 | onClick = noop, 19 | small = false, 20 | speak, 21 | } ) { 22 | const speakMessage = () => { 23 | if ( areAdvancedSettingsOpened ) { 24 | speak( __( 'Additional settings are now available in the Editor advanced settings sidebar' ) ); 25 | } else { 26 | speak( __( 'Advanced settings closed' ) ); 27 | } 28 | }; 29 | 30 | const label = areAdvancedSettingsOpened ? __( 'Hide Advanced Settings' ) : __( 'Show Advanced Settings' ); 31 | 32 | return ( 33 | 39 | { ! small && label } 40 | 41 | ); 42 | } 43 | 44 | export default compose( 45 | withSelect(select => ({ 46 | areAdvancedSettingsOpened: select('standalone-gutenberg').getActiveGeneralSidebarName() === 'standalone-gutenberg', 47 | })), 48 | 49 | withDispatch(dispatch => ({ 50 | openEditorSidebar: () => dispatch('standalone-gutenberg').openGeneralSidebar('standalone-gutenberg'), 51 | closeSidebar: dispatch('standalone-gutenberg').closeGeneralSidebar, 52 | })), 53 | 54 | withSpokenMessages, 55 | )(BlockInspectorButton); 56 | -------------------------------------------------------------------------------- /client/src/blocks/html/index.js: -------------------------------------------------------------------------------- 1 | import * as html from '@wordpress/blocks/library/html'; 2 | 3 | import { BlockControls } from '@wordpress/blocks'; 4 | import { __ } from '@wordpress/i18n'; 5 | import { withState, SandBox, CodeEditor } from '@wordpress/components'; 6 | 7 | export const name = html.name; 8 | 9 | export const settings = { 10 | ...html.settings, 11 | 12 | edit: withState( { 13 | preview: false, 14 | } )( ( { attributes, setAttributes, setState, isSelected, toggleSelection, preview } ) => ( 15 |
16 | { isSelected && ( 17 | 18 |
19 | 25 | 31 |
32 |
33 | ) } 34 | { preview ? ( 35 | 36 | ) : ( 37 | setAttributes( { content } ) } 42 | /> 43 | ) } 44 |
45 | )), 46 | }; 47 | -------------------------------------------------------------------------------- /src/Blocks/ImageGallery.php: -------------------------------------------------------------------------------- 1 | push([ 55 | 'Image' => $file, 56 | 'Caption' => array_key_exists('caption', $item) ? $item['caption'] : '', 57 | 'Credit' => array_key_exists('credit', $item) ? $item['credit'] : '', 58 | ]); 59 | } 60 | 61 | if (!$images->count()) { 62 | return false; 63 | } 64 | 65 | return $this->renderWith('InlineGallery', [ 66 | 'Images' => $images, 67 | 'Width' => static::config()->get('width'), 68 | 'Height' => static::config()->get('height'), 69 | 'FullWidth' => static::config()->get('full_width'), 70 | 'FullHeight' => static::config()->get('full_height'), 71 | ]); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /client/src/blocks/index.js: -------------------------------------------------------------------------------- 1 | import '../hooks'; 2 | 3 | import { registerBlockType, setDefaultBlockName, unregisterBlockType } from '@wordpress/blocks'; 4 | 5 | import * as paragraph from './paragraph'; 6 | import * as lede from './lede'; 7 | import * as well from './well'; 8 | import * as embed from './embed'; 9 | import * as list from './list'; 10 | import * as heading from './heading'; 11 | import * as image from './image'; 12 | import * as gallery from './gallery'; 13 | // import * as review from './review'; 14 | import * as button from './button'; 15 | import * as image2 from './image2'; 16 | // import * as rows from './rows'; 17 | import * as html from './html'; 18 | 19 | // import * as paragraph from '@wordpress/blocks/library/paragraph'; 20 | // import * as heading from '@wordpress/blocks/library/heading'; 21 | // import * as subhead from '@wordpress/blocks/library/subhead'; 22 | // import * as list from '@wordpress/blocks/library/list'; 23 | import * as quote from '@wordpress/blocks/library/quote'; 24 | import * as pullquote from '@wordpress/blocks/library/pullquote'; 25 | import * as code from '@wordpress/blocks/library/code'; 26 | // import * as embed from '@wordpress/blocks/library/embed'; 27 | // import * as html from '@wordpress/blocks/library/html'; 28 | import * as separator from '@wordpress/blocks/library/separator'; 29 | import * as table from '@wordpress/blocks/library/table'; 30 | // import * as freeform from '@wordpress/blocks/library/freeform'; 31 | // import * as columns from '@wordpress/blocks/library/columns'; 32 | 33 | const blocks = [ 34 | paragraph, lede, well, embed, list, 35 | heading, quote, 36 | image, gallery, image2, 37 | // review, 38 | code, html, separator, table, 39 | // freeform, 40 | button, 41 | // columns, 42 | // rows, 43 | ]; 44 | 45 | export const registerBlocks = () => { 46 | blocks.forEach(({ name, settings }) => { 47 | registerBlockType(name, settings); 48 | }); 49 | 50 | setDefaultBlockName(paragraph.name); 51 | // setUnknownTypeHandlerName(freeform.name); 52 | }; 53 | 54 | export const unregisterBlocks = () => { 55 | blocks.forEach(({ name, settings }) => { 56 | unregisterBlockType(name); 57 | }); 58 | } 59 | -------------------------------------------------------------------------------- /src/Blocks/HeadingBlock.php: -------------------------------------------------------------------------------- 1 | (.*)(<\/h[1-6]>)/i', function ($matches) { 16 | // Return the original text if there already is an ID 17 | if (stripos($matches[0], 'id=') !== false) { 18 | return $matches[0]; 19 | } 20 | 21 | // Try to generate a slug 22 | $slug = $this->slugify($matches[3]); 23 | 24 | // Return the original text if we don't have slug 25 | if (!$slug) { 26 | return $matches[0]; 27 | } 28 | 29 | // Compile the stuff 30 | return $matches[1] . $matches[2] . ' id="' . $slug . '">' . $matches[3] . $matches[4]; 31 | }, $content); 32 | } 33 | 34 | /** 35 | * Generates a slug using the heading - should remove any excess crap 36 | * which {@link SilverStripe\View\Parsers\URLSegmentFilter} doesn't. 37 | * Shamelessly stolen from {@link https://stackoverflow.com/a/2955878} 38 | * 39 | * @param string 40 | * @return string 41 | */ 42 | public static function slugify($text) 43 | { 44 | // remove tags 45 | $text = strip_tags($text); 46 | 47 | // replace non letter or digits by - 48 | $text = preg_replace('~[^\pL\d]+~u', '-', $text); 49 | 50 | // transliterate 51 | $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text); 52 | 53 | // remove unwanted characters 54 | $text = preg_replace('~[^-\w]+~', '', $text); 55 | 56 | // trim 57 | $text = trim($text, '-'); 58 | 59 | // remove duplicate - 60 | $text = preg_replace('~-+~', '-', $text); 61 | 62 | // lowercase 63 | $text = strtolower($text); 64 | 65 | if (empty($text)) { 66 | return false; 67 | } 68 | 69 | return $text; 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /client/src/config.js: -------------------------------------------------------------------------------- 1 | import { get, merge, isEmpty } from 'lodash'; 2 | 3 | const defaultConfig = { 4 | blocks: { 5 | paragraph: { 6 | textAlignment: false, 7 | dropCap: false, 8 | fontSize: false, 9 | backgroundColor: false, 10 | textColor: false, 11 | blockAlignment: false, 12 | personalisation: false, 13 | }, 14 | lede: { 15 | dropCap: false, 16 | }, 17 | embed: { 18 | blockAlignment: false, 19 | caption: true, 20 | personalisation: false, 21 | }, 22 | list: { 23 | personalisation: false, 24 | }, 25 | heading: { 26 | className: false, 27 | anchor: true, 28 | textAlignment: false, 29 | personalisation: false, 30 | }, 31 | pullquote: { 32 | personalisation: false, 33 | }, 34 | quote: { 35 | personalisation: false, 36 | }, 37 | code: { 38 | personalisation: false, 39 | }, 40 | html: { 41 | personalisation: false, 42 | }, 43 | table: { 44 | personalisation: false, 45 | }, 46 | separator: { 47 | personalisation: false, 48 | }, 49 | }, 50 | oembed: null, 51 | personalisation: [], 52 | }; 53 | 54 | let configCache = null; 55 | 56 | function getConfig() { 57 | if (!configCache) { 58 | configCache = defaultConfig; 59 | } 60 | 61 | return configCache; 62 | } 63 | 64 | export function setConfig(config) { 65 | if (isEmpty(config)) { 66 | return false; 67 | } 68 | 69 | configCache = merge({}, defaultConfig, config || {}); 70 | } 71 | 72 | export function isBlockFeatureEnabled(block, feature) { 73 | const config = getConfig(); 74 | 75 | block = block.replace('core/', ''); 76 | 77 | let blockConfig = get(config.blocks, block, false); 78 | 79 | if (!blockConfig) { 80 | return false; 81 | } 82 | 83 | return get(blockConfig, feature, true); 84 | } 85 | 86 | export function getConfigValue(name, fallback = null) { 87 | return get(getConfig(), name, fallback); 88 | } 89 | 90 | export default getConfig; 91 | -------------------------------------------------------------------------------- /client/src/components/personalisation/index.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { __ } from '@wordpress/i18n'; 4 | import { pull } from 'lodash'; 5 | import { getConfigValue, default as getConfig } from '../../config'; 6 | 7 | class Personalisation extends Component { 8 | constructor(props) { 9 | super(props); 10 | 11 | this.state = { 12 | values: [], 13 | options: [], 14 | }; 15 | 16 | this.onChange = this.onChange.bind(this); 17 | this.checkbox = this.checkbox.bind(this); 18 | } 19 | 20 | componentDidMount() { 21 | this.setState({ 22 | values: this.props.values || [], 23 | options: getConfigValue('personalisation', []), 24 | }); 25 | } 26 | 27 | onChange(event) { 28 | let values = this.state.values; 29 | 30 | if (event.target.checked) { 31 | values.push( event.target.value ); 32 | } else { 33 | values = pull( values, event.target.value ); 34 | } 35 | 36 | this.setState({ 37 | values: values, 38 | }); 39 | 40 | console.log( 'personalisation', values ); 41 | 42 | return this.props.onChange(values); 43 | } 44 | 45 | checkbox(option, index) { 46 | const { label, value } = option; 47 | 48 | const id = `personalisation-${value}`; 49 | const isChecked = this.state.values.indexOf(value) !== -1; 50 | 51 | return ( 52 |
53 | 61 | 62 | 65 |
66 | ); 67 | } 68 | 69 | render() { 70 | return this.state.options.map( this.checkbox ); 71 | } 72 | } 73 | 74 | Personalisation.propTypes = { 75 | values: PropTypes.array, 76 | onChange: PropTypes.func, 77 | }; 78 | 79 | export default Personalisation; 80 | -------------------------------------------------------------------------------- /src/Blocks/ImageBlock.php: -------------------------------------------------------------------------------- 1 | exists()) { 29 | return false; 30 | } 31 | 32 | $alt = array_key_exists('altText', $attributes) ? $attributes['altText'] : ''; 33 | $title = array_key_exists('title', $attributes) ? $attributes['title'] : ''; 34 | $url = array_key_exists('url', $attributes) ? $attributes['url'] : ''; 35 | $width = array_key_exists('width', $attributes) ? $attributes['width'] : null; 36 | $height = array_key_exists('height', $attributes) ? $attributes['height'] : null; 37 | 38 | if (!$width) { 39 | $width = (int) static::config()->get('width'); 40 | } 41 | 42 | if (!$height) { 43 | $height = (int) static::config()->get('height'); 44 | } 45 | 46 | if ($width && $height) { 47 | $image = sprintf('%s', $file->URL($width, $height, 'fill'), $alt, $title); 48 | } else if ($width) { 49 | $image = sprintf('%s', $file->resizeByWidth($width, 'fill'), $alt, $title); 50 | } else if ($height) { 51 | $image = sprintf('%s', $file->resizeByHeight($height, 'fill'), $alt, $title); 52 | } else { 53 | $image = sprintf('%s', $file->URL(800, 450), $alt, $title); 54 | } 55 | 56 | if ($url && is_string($url) && strlen($url)) { 57 | $markup = sprintf('%s', $url, $image); 58 | } else { 59 | $markup = $image; 60 | } 61 | 62 | return sprintf('
%s
', $markup); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /client/src/globals.js: -------------------------------------------------------------------------------- 1 | import { createElement, Component } from "@wordpress/element"; 2 | import 'whatwg-fetch'; 3 | import { isString } from "lodash"; 4 | import { apiRequest } from './api-request'; 5 | import './api-request'; 6 | import { __ } from '@wordpress/i18n'; 7 | 8 | window.wp = window.wp || {}; 9 | 10 | // Necessary for the pragma config 11 | // Component is used by the Dashicon component 12 | window.wp.element = { createElement, Component }; 13 | 14 | // Necessary for wp.date 15 | window._wpDateSettings = { 16 | l10n: { 17 | locale: "fr_FR", 18 | months: [ 19 | "janvier", 20 | "février", 21 | "mars", 22 | "avril", 23 | "mai", 24 | "juin", 25 | "juillet", 26 | "août", 27 | "septembre", 28 | "octobre", 29 | "novembre", 30 | "décembre" 31 | ], 32 | monthsShort: [ 33 | "Jan", 34 | "Fév", 35 | "Mar", 36 | "Avr", 37 | "Mai", 38 | "Juin", 39 | "Juil", 40 | "Août", 41 | "Sep", 42 | "Oct", 43 | "Nov", 44 | "Déc" 45 | ], 46 | weekdays: [ 47 | "dimanche", 48 | "lundi", 49 | "mardi", 50 | "mercredi", 51 | "jeudi", 52 | "vendredi", 53 | "samedi" 54 | ], 55 | weekdaysShort: ["dim", "lun", "mar", "mer", "jeu", "ven", "sam"], 56 | meridiem: { am: " ", pm: " ", AM: " ", PM: " " }, 57 | relative: { future: "%s à partir de maintenant", past: "Il y a %s" } 58 | }, 59 | formats: { 60 | time: "G \\h i \\m\\i\\n", 61 | date: "j F Y", 62 | datetime: "j F Y G \\h i \\m\\i\\n" 63 | }, 64 | timezone: { offset: 1, string: "Europe/Paris" } 65 | }; 66 | 67 | window.cloudinaryImage = (url, width = 636, height = 358, crop = 'fill', effect = null, quality = 'auto', gravity = 'auto') => { 68 | if (!url) { 69 | return undefined; 70 | } 71 | 72 | // Build up the options 73 | const options = [ 74 | `h_${height}`, 75 | `w_${width}`, 76 | `c_${crop}`, 77 | `q_${quality}`, 78 | ]; 79 | if (gravity) { 80 | options.push(`g_${gravity}`); 81 | } 82 | 83 | return url.replace('/upload/', '/upload/' + options + '/').replace('http:', 'https:'); 84 | }; 85 | 86 | // User settings used to persist store caches 87 | window.userSettings = { uid: "dummy" }; 88 | 89 | // API globals 90 | window.wpApiSettings = { 91 | schema: {} 92 | }; 93 | 94 | window.wp.api = { 95 | getPostTypeRoute() { 96 | return "none"; 97 | } 98 | }; 99 | 100 | window.wp.__ = __; 101 | -------------------------------------------------------------------------------- /client/src/components/with-fetch/index.js: -------------------------------------------------------------------------------- 1 | import { Component, getWrapperDisplayName } from '@wordpress/element'; 2 | 3 | import { reduce, keys } from 'lodash'; 4 | 5 | const dataCache = {}; 6 | 7 | function withFetch( mapPropsToData = {} ) { 8 | return ( OriginalComponent ) => { 9 | class WrappedComponent extends Component { 10 | constructor() { 11 | super( ...arguments ); 12 | 13 | this.state = { 14 | data: {}, 15 | }; 16 | 17 | this.applyMapping = this.applyMapping.bind(this); 18 | this.fetchData = this.fetchData.bind(this); 19 | } 20 | 21 | componentWillMount() { 22 | this.applyMapping( this.props ); 23 | } 24 | 25 | componentWillReceiveProps( nextProps ) { 26 | this.applyMapping( nextProps ); 27 | } 28 | 29 | applyMapping(props) { 30 | let nextProps = mapPropsToData(props); 31 | 32 | nextProps = reduce( nextProps, ( result, path, propName ) => { 33 | const promise = this.fetchData(path).then(data => { 34 | return { 35 | prop: propName, 36 | data: data, 37 | }; 38 | }); 39 | 40 | result.push(promise); 41 | 42 | return result; 43 | }, []); 44 | 45 | Promise.all( nextProps ).then(response => { 46 | const data = response.reduce((carry, item) => { 47 | carry[item.prop] = item.data; 48 | 49 | return carry; 50 | }, {}); 51 | 52 | this.setState({ data: data, }); 53 | }); 54 | } 55 | 56 | fetchData(path) { 57 | if (dataCache[path]) { 58 | return dataCache[path]; 59 | } 60 | 61 | dataCache[path] = fetch(path, { credentials: 'same-origin' }) 62 | .then(response => response.json()); 63 | 64 | return dataCache[path]; 65 | } 66 | 67 | render() { 68 | return ( 69 | 73 | ); 74 | } 75 | } 76 | 77 | WrappedComponent.displayName = getWrapperDisplayName( WrappedComponent, 'fetch' ); 78 | 79 | return WrappedComponent; 80 | }; 81 | } 82 | 83 | export default withFetch; 84 | -------------------------------------------------------------------------------- /client/src/components/blocks/gallery/index.js: -------------------------------------------------------------------------------- 1 | import { Component } from '@wordpress/element'; 2 | import { TextControl } from '@wordpress/components'; 3 | import ReactDOM from 'react-dom'; 4 | import Image from '../image'; 5 | 6 | class Gallery extends Component { 7 | constructor(props) { 8 | super(props); 9 | 10 | this.addItem = this.addItem.bind(this); 11 | this.renderGalleryItem = this.renderGalleryItem.bind(this); 12 | this.removeItem = this.removeItem.bind(this); 13 | 14 | this.state = { 15 | items: props.attributes.items || [], 16 | }; 17 | } 18 | 19 | componentDidMount() { 20 | if (!this.state.items.length) { 21 | this.addItem(); 22 | } 23 | } 24 | 25 | /** 26 | * Append a new item to the gallery 27 | */ 28 | addItem() { 29 | const items = this.state.items.slice(0); 30 | items.push({}); 31 | this.setState({items}); 32 | } 33 | 34 | /** 35 | * Removes the item based on the index provided 36 | */ 37 | removeItem(index) { 38 | const items = this.state.items.slice(0); 39 | items.splice(index, 1); 40 | this.setState({items}); 41 | } 42 | 43 | renderGalleryItem(item, index) { 44 | const instanceId = this.props.instanceId; 45 | let setAttributes = (newData) => { 46 | this.updateItem(newData, index); 47 | }; 48 | setAttributes = setAttributes.bind(this); 49 | 50 | return ( 51 |
52 |
64 | ); 65 | } 66 | 67 | updateItem(newData, index) { 68 | const items = this.state.items.slice(0); 69 | items[index] = Object.assign(items[index], newData); 70 | this.props.setAttributes({items}); 71 | } 72 | 73 | renderAddItem() { 74 | return ( 75 |
76 | Add 77 |
78 | ); 79 | } 80 | 81 | render() { 82 | return ( 83 |
84 | {this.state.items.map(this.renderGalleryItem)} 85 | {this.renderAddItem()} 86 |
87 | ); 88 | } 89 | 90 | 91 | } 92 | 93 | export default Gallery; 94 | -------------------------------------------------------------------------------- /client/src/blocks/rows/index.js: -------------------------------------------------------------------------------- 1 | import { times } from 'lodash'; 2 | import classnames from 'classnames'; 3 | import memoize from 'memize'; 4 | 5 | import { __, sprintf } from '@wordpress/i18n'; 6 | import { RangeControl } from '@wordpress/components'; 7 | 8 | import './style.scss'; 9 | 10 | import { 11 | InspectorControls, 12 | BlockControls, 13 | BlockAlignmentToolbar, 14 | InnerBlocks, 15 | } from '@wordpress/blocks'; 16 | 17 | 18 | const getRowLayouts = memoize( ( rows ) => { 19 | return times( rows, ( n ) => ( { 20 | name: `row-${ n + 1 }`, 21 | label: sprintf( __( 'Row %d' ), n + 1 ), 22 | icon: 'rows', 23 | } ) ); 24 | } ); 25 | 26 | export const name = 'core/rows'; 27 | 28 | export const settings = { 29 | title: sprintf( 30 | __( '%1$s (%2$s)' ), 31 | __( 'Rows' ), 32 | __( 'Experimental' ) 33 | ), 34 | 35 | icon: 'rows', 36 | 37 | category: 'layout', 38 | 39 | attributes: { 40 | rows: { 41 | type: 'number', 42 | default: 1, 43 | }, 44 | 45 | align: { 46 | type: 'string', 47 | }, 48 | }, 49 | 50 | description: __( 'A multi-row layout of content.' ), 51 | 52 | getEditWrapperProps( attributes ) { 53 | const { align } = attributes; 54 | 55 | return { 'data-align': align }; 56 | }, 57 | 58 | edit( { attributes, setAttributes, className, focus } ) { 59 | const { align, rows } = attributes; 60 | const classes = classnames( className, `has-${ rows }-rows` ); 61 | 62 | return [ 63 | ...focus ? [ 64 | 65 | { 69 | setAttributes( { align: nextAlign } ); 70 | } } 71 | /> 72 | , 73 | 74 | { 78 | setAttributes( { 79 | rows: nextRows, 80 | } ); 81 | } } 82 | min={ 2 } 83 | max={ 6 } 84 | /> 85 | , 86 | ] : [], 87 |
88 | 89 |
, 90 | ]; 91 | }, 92 | 93 | save( { attributes } ) { 94 | const { rows } = attributes; 95 | 96 | return ( 97 |
98 | 99 |
100 | ); 101 | }, 102 | }; 103 | -------------------------------------------------------------------------------- /src/Blocks/Image2Block.php: -------------------------------------------------------------------------------- 1 | getTemplateData($attributes); 40 | return $template->renderWith('InlineGallery'); 41 | } 42 | 43 | private function getTemplateData($attributes) 44 | { 45 | $widthRatio = static::config()->get('max_width') / static::config()->get('max_height'); 46 | $heightRatio = static::config()->get('max_height') / static::config()->get('max_width'); 47 | $ratio = $heightRatio < $widthRatio ? $heightRatio : $widthRatio; 48 | $images = array_reduce($attributes['images'], function ($carry, $imageData) use ($ratio) { 49 | $image = File::get_by_id(File::class, $imageData['id']); 50 | if (is_object($image) && $image->exists()) { 51 | $width = $image->Width; 52 | $height = $image->Height; 53 | 54 | // If no width/height then the file has probably been removed 55 | if ($width && $height) { 56 | if ($width > static::config()->get('max_width')) { 57 | $height = $width * $ratio; 58 | $width = static::config()->get('max_width'); 59 | } 60 | 61 | if ($height > static::config()->get('max_height')) { 62 | $width = $height * $ratio; 63 | $height = static::config()->get('max_height'); 64 | } 65 | 66 | $carry->push(ArrayData::create([ 67 | 'Image' => $image, 68 | 'Caption' => array_key_exists('caption', $imageData) && $imageData['caption'] ? $imageData['caption'] : $image->Caption, 69 | 'Credit' => array_key_exists('credit', $imageData) && $imageData['credit'] ? $imageData['credit'] : $image->Credit, 70 | 'Gravity' => array_key_exists('gravity', $imageData) && $imageData['gravity'] ? $imageData['gravity'] : 'auto', 71 | 'Width' => (int) $width, 72 | 'Height' => (int) $height, 73 | ])); 74 | } 75 | } 76 | return $carry; 77 | }, ArrayList::create()); 78 | 79 | return ArrayData::create([ 80 | 'Images' => $images, 81 | 'Width' => static::config()->get('width'), 82 | 'Height' => static::config()->get('height'), 83 | 'FullWidth' => static::config()->get('max_width'), 84 | 'FullHeight' => static::config()->get('max_height'), 85 | ]); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /client/src/components/visual-editor/style.scss: -------------------------------------------------------------------------------- 1 | .edit-post-visual-editor { 2 | position: relative; 3 | padding: 50px 0; 4 | 5 | &, 6 | & p { 7 | font-family: $editor-font; 8 | font-size: $editor-font-size; 9 | line-height: $editor-line-height; 10 | } 11 | 12 | & ul, 13 | & ol { 14 | margin: 0; 15 | padding: 0; 16 | } 17 | 18 | & ul:not(.wp-block-gallery) { 19 | list-style-type: disc; 20 | } 21 | 22 | & ol { 23 | list-style-type: decimal; 24 | } 25 | 26 | & .button { 27 | font-family: $default-font; 28 | } 29 | } 30 | 31 | .edit-post-visual-editor .editor-writing-flow__click-redirect { 32 | // Collapse to minimum height of 50px, to fully occupy editor bottom pad. 33 | height: 50px; 34 | width: $content-width; 35 | // Offset for: Visual editor padding, block (default appender) margin. 36 | margin: #{ -1 * $block-spacing } auto -50px; 37 | } 38 | 39 | .edit-post-visual-editor .editor-block-list__block { 40 | margin-left: auto; 41 | margin-right: auto; 42 | max-width: $content-width; 43 | 44 | @include break-small() { 45 | .editor-block-list__block-edit { 46 | margin-left: -$block-side-ui-padding; 47 | margin-right: -$block-side-ui-padding; 48 | } 49 | 50 | &[data-align="full"] .editor-block-contextual-toolbar, 51 | &[data-align="wide"] .editor-block-contextual-toolbar { 52 | max-width: $content-width + 2; // 1px border left and right 53 | margin-left: auto; 54 | margin-right: auto; 55 | } 56 | } 57 | 58 | &[data-align="wide"] { 59 | max-width: 1100px; 60 | } 61 | 62 | &[data-align="full"] { 63 | max-width: none; 64 | } 65 | } 66 | 67 | // This is a focus style shown for blocks that need an indicator even when in an isEditing state 68 | // like for example an image block that receives arrowkey focus. 69 | .edit-post-visual-editor .editor-block-list__block:not( .is-selected ) { 70 | .editor-block-list__block-edit { 71 | box-shadow: 0 0 0 0 $white, 0 0 0 0 $dark-gray-900; 72 | transition: .1s box-shadow .05s; 73 | } 74 | 75 | &:focus .editor-block-list__block-edit { 76 | box-shadow: 0 0 0 1px $white, 0 0 0 3px $dark-gray-900; 77 | } 78 | } 79 | 80 | .edit-post-visual-editor .editor-post-title { 81 | margin-left: auto; 82 | margin-right: auto; 83 | max-width: $content-width + ( 2 * $block-side-ui-padding ); 84 | 85 | .editor-post-permalink { 86 | left: $block-padding; 87 | right: $block-padding; 88 | } 89 | 90 | @include break-small() { 91 | padding: 5px #{ $block-side-ui-padding - 1px }; // subtract 1px border, because this is an outline 92 | 93 | .editor-post-permalink { 94 | left: $block-side-ui-padding; 95 | right: $block-side-ui-padding; 96 | } 97 | } 98 | } 99 | 100 | .edit-post-visual-editor .editor-default-block-appender { 101 | // Default to centered and content-width, like blocks 102 | max-width: $content-width; 103 | margin-left: auto; 104 | margin-right: auto; 105 | position: relative; 106 | 107 | &[data-root-uid=""] .editor-default-block-appender__content:hover { 108 | // Outline on root-level default block appender is redundant with the 109 | // WritingFlow click redirector. 110 | outline: 1px solid transparent; 111 | } 112 | 113 | @include break-small() { 114 | .editor-default-block-appender__content { 115 | padding: 0 $block-padding; 116 | } 117 | } 118 | } 119 | 120 | .edit-post-visual-editor .editor-block-list__layout > .editor-block-list__insertion-point { 121 | max-width: $content-width; 122 | position: relative; 123 | } 124 | -------------------------------------------------------------------------------- /client/src/blocks/button/index.js: -------------------------------------------------------------------------------- 1 | import * as button from '@wordpress/blocks/library/button'; 2 | 3 | import { __ } from '@wordpress/i18n'; 4 | 5 | import { 6 | ToggleControl, 7 | PanelColor, 8 | Dashicon, 9 | IconButton, 10 | withFallbackStyles, 11 | } from '@wordpress/components'; 12 | 13 | import { 14 | UrlInput, 15 | BlockControls, 16 | BlockAlignmentToolbar, 17 | RichText, 18 | InspectorControls, 19 | ColorPalette, 20 | } from '@wordpress/blocks'; 21 | 22 | import ContrastChecker from '@wordpress/blocks/contrast-checker'; 23 | 24 | import './style.scss'; 25 | 26 | const { getComputedStyle } = window; 27 | 28 | const ContrastCheckerWithFallbackStyles = withFallbackStyles( ( node, ownProps ) => { 29 | const { textColor, backgroundColor } = ownProps; 30 | //avoid the use of querySelector if textColor color is known and verify if node is available. 31 | const textNode = ! textColor && node ? node.querySelector( '[contenteditable="true"]' ) : null; 32 | return { 33 | fallbackBackgroundColor: backgroundColor || ! node ? undefined : getComputedStyle( node ).backgroundColor, 34 | fallbackTextColor: textColor || ! textNode ? undefined : getComputedStyle( textNode ).color, 35 | }; 36 | } )( ContrastChecker ); 37 | 38 | class ButtonBlock extends button.settings.edit { 39 | render() { 40 | const { 41 | attributes, 42 | setAttributes, 43 | isSelected, 44 | className, 45 | onFocus, 46 | } = this.props; 47 | 48 | const { 49 | text, 50 | url, 51 | title, 52 | align, 53 | color, 54 | textColor, 55 | clear, 56 | } = attributes; 57 | 58 | return [ 59 | 60 | setAttributes( { text: value } ) } 65 | formattingControls={ [] } 66 | className="wp-block-button__link" 67 | style={ { 68 | backgroundColor: color, 69 | color: textColor, 70 | } } 71 | isSelected={ isSelected } 72 | keepPlaceholderOnFocus={ true } 73 | /> 74 | , 75 | isSelected && ( 76 |
event.preventDefault() }> 80 | 81 | setAttributes( { url: value } ) } 84 | /> 85 | 86 | 87 | ), 88 | ]; 89 | } 90 | } 91 | 92 | export const name = button.name; 93 | 94 | export const settings = { 95 | ...button.settings, 96 | 97 | edit: ButtonBlock, 98 | 99 | save( { attributes } ) { 100 | const { url, text, title, align, color, textColor } = attributes; 101 | 102 | const buttonStyle = { 103 | backgroundColor: color, 104 | color: textColor, 105 | align: align, 106 | }; 107 | 108 | const linkClass = 'wp-block-button__link'; 109 | 110 | return ( 111 | 112 | { text } 113 | 114 | ); 115 | }, 116 | }; 117 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | const path = require("path"); 5 | const webpack = require("webpack"); 6 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 7 | 8 | // Main CSS loader for everything but blocks.. 9 | const cssExtractTextPlugin = new ExtractTextPlugin({ 10 | filename: "client/dist/style.css" 11 | }); 12 | 13 | // Configuration for the ExtractTextPlugin. 14 | const extractConfig = { 15 | use: [ 16 | { loader: "raw-loader" }, 17 | { 18 | loader: "postcss-loader", 19 | options: { 20 | plugins: [require("autoprefixer")] 21 | } 22 | }, 23 | { 24 | loader: "sass-loader", 25 | query: { 26 | includePaths: [ 27 | path.resolve( 28 | __dirname, 29 | "node_modules/gutenberg/edit-post/assets/stylesheets" 30 | ) 31 | ], 32 | data: 33 | '@import "colors"; @import "admin-schemes"; @import "breakpoints"; @import "variables"; @import "mixins"; @import "animations";@import "z-index";', 34 | outputStyle: 35 | "production" === process.env.NODE_ENV ? "compressed" : "nested" 36 | } 37 | } 38 | ] 39 | }; 40 | 41 | const externals = { 42 | moment: 'moment', 43 | Injector: 'Injector', 44 | schemaFieldValues: 'schemaFieldValues', 45 | jQuery: 'jQuery' 46 | }; 47 | 48 | const wpDependencies = [ 49 | "i18n", 50 | "components", 51 | "element", 52 | "blocks", 53 | "utils", 54 | "date", 55 | "data", 56 | "editor", 57 | "viewport" 58 | ]; 59 | 60 | const alias = { 61 | "original-moment": path.resolve(__dirname, "node_modules/moment"), 62 | "moment": path.resolve(__dirname, "client/src/moment.js"), 63 | "InsertMediaModal": path.resolve(__dirname, "../../silverstripe/asset-admin/client/src/containers/InsertMediaModal/InsertMediaModal") 64 | }; 65 | 66 | wpDependencies.forEach(wpDependency => { 67 | alias["@wordpress/" + wpDependency] = path.resolve( 68 | __dirname, 69 | "node_modules/gutenberg/" + wpDependency 70 | ); 71 | }); 72 | 73 | const config = { 74 | entry: { 75 | bundle: "./client/src/bundle.js", 76 | globals: "./client/src/globals.js" 77 | }, 78 | externals, 79 | resolve: { alias }, 80 | output: { 81 | filename: "client/dist/[name].js" 82 | }, 83 | module: { 84 | rules: [ 85 | { 86 | test: /\.pegjs/, 87 | use: "pegjs-loader" 88 | }, 89 | { 90 | test: /\.js$/, 91 | include: wpDependencies 92 | .map(dependency => 93 | path.resolve(__dirname, "node_modules/gutenberg", dependency) 94 | ) 95 | .concat([path.resolve(__dirname, "client/src")]), 96 | use: "babel-loader" 97 | }, 98 | { 99 | test: /\.s?css$/, 100 | use: cssExtractTextPlugin.extract(extractConfig) 101 | }, 102 | { 103 | test: /\.js$/, 104 | loader: 'string-replace-loader', 105 | options: { 106 | search: 'wp-admin/load-scripts.php', 107 | replace: 'resources/mademedia/silverstripe-gutenberg-editor/client/dist/code-mirror.js', 108 | } 109 | }, 110 | { 111 | test: /\.js$/, 112 | loader: 'string-replace-loader', 113 | options: { 114 | search: 'wp-admin/load-styles.php', 115 | replace: 'resources/mademedia/silverstripe-gutenberg-editor/client/dist/code-mirror.css', 116 | } 117 | } 118 | ] 119 | }, 120 | plugins: [ 121 | new webpack.DefinePlugin({ 122 | "process.env.NODE_ENV": JSON.stringify( 123 | process.env.NODE_ENV || "development" 124 | ) 125 | }), 126 | cssExtractTextPlugin, 127 | new webpack.LoaderOptionsPlugin({ 128 | minimize: process.env.NODE_ENV === "production", 129 | debug: process.env.NODE_ENV !== "production" 130 | }) 131 | ], 132 | stats: { 133 | children: false 134 | }, 135 | devServer: { 136 | contentBase: "./public" 137 | } 138 | }; 139 | 140 | switch (process.env.NODE_ENV) { 141 | case "production": 142 | config.plugins.push(new webpack.optimize.UglifyJsPlugin()); 143 | break; 144 | 145 | default: 146 | config.devtool = "source-map"; 147 | } 148 | 149 | module.exports = config; 150 | -------------------------------------------------------------------------------- /client/src/hooks/personalisation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import { assign, pull, map, get } from 'lodash'; 5 | import classnames from 'classnames'; 6 | 7 | /** 8 | * WordPress dependencies 9 | */ 10 | import { getWrapperDisplayName, Fragment } from '@wordpress/element'; 11 | import { addFilter } from '@wordpress/hooks'; 12 | import { PanelBody } from '@wordpress/components'; 13 | import { __ } from '@wordpress/i18n'; 14 | /** 15 | * Internal dependencies 16 | */ 17 | import InspectorControls from '@wordpress/blocks/inspector-controls'; 18 | 19 | import { isBlockFeatureEnabled, getConfigValue, default as getConfig } from '../config'; 20 | import { Personalisation } from '../components'; 21 | 22 | /** 23 | * Filters registered block settings, extending attributes with anchor using ID 24 | * of the first node. 25 | * 26 | * @param {Object} settings Original block settings. 27 | * 28 | * @return {Object} Filtered block settings. 29 | */ 30 | export function addAttribute( settings ) { 31 | if (isBlockFeatureEnabled(settings.name, 'personalisation')) { 32 | settings.attributes = assign(settings.attributes, { 33 | personalisation: { 34 | type: 'array', 35 | default: [], 36 | } 37 | }); 38 | } 39 | 40 | return settings; 41 | } 42 | 43 | export function withInspectorControl( BlockEdit ) { 44 | const WrappedBlockEdit = ( props ) => { 45 | const personalisationEnabled = isBlockFeatureEnabled(props.name, 'personalisation') && props.isSelected; 46 | const configValues = getConfigValue('personalisation', []); 47 | 48 | let values = props.attributes.personalisation || []; 49 | 50 | const onChange = (event) => { 51 | let newValues = values.slice(); 52 | 53 | if (event.target.checked) { 54 | newValues.push( event.target.value ); 55 | } else { 56 | newValues = pull(newValues, event.target.value); 57 | } 58 | 59 | props.setAttributes( { personalisation: newValues } ); 60 | }; 61 | 62 | const checkboxes = configValues.map((option, index) => { 63 | const { label, value } = option; 64 | 65 | const id = `personalisation-${value}`; 66 | 67 | const isChecked = values.some(item => { 68 | return String(item).toLowerCase() === String(value).toLowerCase(); 69 | }); 70 | 71 | return ( 72 |
73 | 81 | 82 | 85 |
86 | ); 87 | }); 88 | 89 | if (personalisationEnabled) { 90 | return ( 91 | 92 | 93 | 94 | 95 | { checkboxes } 96 | 97 | 98 | 99 | ); 100 | } 101 | 102 | return ; 103 | }; 104 | 105 | WrappedBlockEdit.displayName = getWrapperDisplayName( BlockEdit, 'personalisation' ); 106 | 107 | return WrappedBlockEdit; 108 | } 109 | 110 | export function addSaveProps( extraProps, blockType, attributes ) { 111 | return extraProps; 112 | } 113 | 114 | addFilter( 'blocks.registerBlockType', 'silverstripe/personalisation/attribute', addAttribute ); 115 | addFilter( 'blocks.BlockEdit', 'silverstripe/personalisation/inspector-control', withInspectorControl ); 116 | addFilter( 'blocks.getSaveContent.extraProps', 'silverstripe/personalisation/save-props', addSaveProps ); 117 | -------------------------------------------------------------------------------- /templates/InlineGallery.ss: -------------------------------------------------------------------------------- 1 | 99 | -------------------------------------------------------------------------------- /client/src/style.scss: -------------------------------------------------------------------------------- 1 | $content-width: 700px; 2 | 3 | @mixin visually-hidden { 4 | border: 0 !important; 5 | clip: rect(0 0 0 0) !important; 6 | height: 1px !important; 7 | margin: -1px !important; 8 | overflow: hidden !important; 9 | padding: 0 !important; 10 | position: absolute !important; 11 | width: 1px !important; 12 | } 13 | 14 | div.gutenbergeditor--loaded { 15 | .form__field-label, 16 | .form__field-holder { 17 | @include visually-hidden; 18 | } 19 | } 20 | 21 | .gutenberg__editor { 22 | width: 100%; 23 | min-height: 500px; 24 | background: $white; 25 | position: relative; 26 | color: #444; 27 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; 28 | font-size: 13px; 29 | line-height: 1.4em; 30 | margin-top: -1.5385rem; 31 | 32 | @media (max-width: 992px) { 33 | margin-left: -1.5385rem; 34 | margin-right: -1.5385rem; 35 | width: auto; 36 | } 37 | 38 | .a11y-speak-region { 39 | left: -1px; 40 | top: -1px; 41 | } 42 | 43 | svg { 44 | fill: currentColor; 45 | outline: none; 46 | } 47 | 48 | * { 49 | box-sizing: border-box; 50 | } 51 | 52 | ul:not(.wp-block-gallery) { 53 | list-style-type: disc; 54 | } 55 | 56 | ol:not(.wp-block-gallery) { 57 | list-style-type: decimal; 58 | } 59 | 60 | ul, 61 | ol { 62 | margin: 0; 63 | padding: 0; 64 | } 65 | 66 | select { 67 | font-size: $default-font-size; 68 | color: $dark-gray-500; 69 | } 70 | } 71 | 72 | .edit-post-header { 73 | height: $header-height; 74 | padding: $item-spacing; 75 | border-bottom: 1px solid $light-gray-500; 76 | background: $white; 77 | display: flex; 78 | flex-direction: row; 79 | align-items: stretch; 80 | justify-content: space-between; 81 | z-index: z-index('.editor-block-settings-menu__popover'); 82 | left: 0; 83 | right: 0; 84 | 85 | // mobile edgecase for toolbar 86 | top: -1.5385rem; 87 | position: sticky; 88 | } 89 | 90 | .edit-post-header-toolbar { 91 | display: inline-flex; 92 | align-items: center; 93 | } 94 | 95 | .edit-post-visual-editor { 96 | .editor-block-list__block { 97 | outline: 0 none; 98 | border: 1px solid transparent; 99 | width: 100%; 100 | max-width: $content-width; 101 | 102 | &:hover { 103 | border-color: $light-gray-200; 104 | } 105 | 106 | &.is-selected { 107 | border-color: $light-gray-400; 108 | } 109 | } 110 | 111 | .editor-writing-flow__click-redirect { 112 | margin-left: auto; 113 | margin-right: auto; 114 | max-width: $content-width; 115 | width: 100%; 116 | } 117 | } 118 | 119 | .edit-post-sidebar { 120 | position: fixed; 121 | z-index: 1049; 122 | top: 0; 123 | right: 0; 124 | bottom: 0; 125 | width: $sidebar-width; 126 | border-left: 1px solid $light-gray-500; 127 | background: $light-gray-300; 128 | color: $dark-gray-500; 129 | min-height: 500px; 130 | overflow: auto; 131 | 132 | .blocks-url-input input { 133 | border: 1px solid rgb(238, 238, 238); 134 | } 135 | 136 | .blocks-url-input__suggestions { 137 | width: 100%; 138 | } 139 | } 140 | 141 | .blocks-rich-text__tinymce:not(.mce-content-body) { 142 | pointer-events: none; 143 | } 144 | 145 | // Other 146 | .screen-reader-text { 147 | border: 0; 148 | clip: rect(1px, 1px, 1px, 1px); 149 | -webkit-clip-path: inset(50%); 150 | clip-path: inset(50%); 151 | height: 1px; 152 | margin: -1px; 153 | overflow: hidden; 154 | padding: 0; 155 | position: absolute; 156 | width: 1px; 157 | word-wrap: normal !important; /* many screen reader and browser combinations announce broken words as they would appear visually */ 158 | } 159 | 160 | // Playlist block 161 | .playlist-details { 162 | margin-left: -16px; 163 | 164 | .playlist-details__items, 165 | .playlist-details__media { 166 | padding-left: 16px; 167 | display: inline-block; 168 | } 169 | 170 | .playlist-details__items { 171 | width: 70%; 172 | } 173 | 174 | .playlist-details__media { 175 | width: 30%; 176 | vertical-align: top; 177 | 178 | img { 179 | width: 100%; 180 | height: auto; 181 | } 182 | } 183 | } 184 | 185 | // set Custom html block preview iframe width to bigger than 0px 186 | .editor-block-list__block-edit .wp-block-html iframe { 187 | width: 100%; 188 | border: 0; 189 | } 190 | 191 | .components-popover__content .editor-block-settings-menu__control:nth-child(4) { 192 | display: none; 193 | } 194 | 195 | .blocks-format-toolbar__link-modal { 196 | z-index: 999; 197 | } 198 | -------------------------------------------------------------------------------- /client/src/blocks/heading/index.js: -------------------------------------------------------------------------------- 1 | import { __, sprintf } from '@wordpress/i18n'; 2 | import * as heading from '@wordpress/blocks/library/heading'; 3 | import { isBlockFeatureEnabled } from '../../config'; 4 | 5 | import { 6 | BlockControls, 7 | InspectorControls, 8 | AlignmentToolbar, 9 | } from '@wordpress/blocks'; 10 | 11 | import { PanelBody, Toolbar } from '@wordpress/components'; 12 | import { createBlock } from '@wordpress/blocks/api'; 13 | 14 | import { RichText } from '../../components'; 15 | 16 | import { isUndefined } from 'lodash'; 17 | 18 | export const name = 'core/heading'; 19 | 20 | const transforms = heading.settings.transforms; 21 | 22 | transforms.to.push({ 23 | type: 'block', 24 | blocks: [ 'madehq/lede-copy' ], 25 | transform: ( { content } ) => { 26 | return createBlock( 'madehq/lede-copy', { 27 | content, 28 | } ); 29 | }, 30 | }); 31 | 32 | export const settings = { 33 | ...heading.settings, 34 | 35 | transforms, 36 | 37 | edit( { attributes, setAttributes, isSelected, mergeBlocks, insertBlocksAfter, onReplace, className, onFocus, allowNodeChange, onSplit } ) { 38 | const { 39 | align, 40 | content, 41 | nodeName, 42 | placeholder, 43 | } = attributes; 44 | 45 | const textAlignmentEnabled = isBlockFeatureEnabled(name, 'textAlignment'); 46 | 47 | if (isUndefined(allowNodeChange)) { 48 | allowNodeChange = true; 49 | } 50 | 51 | if (isUndefined(onSplit)) { 52 | onSplit = !insertBlocksAfter ? undefined : ( before, after, ...blocks ) => { 53 | setAttributes( { content: before } ); 54 | 55 | insertBlocksAfter( [ 56 | ...blocks, 57 | createBlock( 'core/paragraph', { content: after } ), 58 | ] ); 59 | }; 60 | } 61 | 62 | return [ 63 | isSelected && allowNodeChange && ( 64 | ( { 68 | icon: 'heading', 69 | title: sprintf( __( 'Heading %s' ), level ), 70 | isActive: 'H' + level === nodeName, 71 | onClick: () => setAttributes( { nodeName: 'H' + level } ), 72 | subscript: level, 73 | } ) ) 74 | } 75 | /> 76 | ), 77 | isSelected && ( 78 | 79 | { allowNodeChange && ( 80 |
81 |

{ __( 'Heading Settings' ) }

82 | 83 |

{ __( 'Level' ) }

84 | 85 | ( { 88 | icon: 'heading', 89 | title: sprintf( __( 'Heading %s' ), level ), 90 | isActive: 'H' + level === nodeName, 91 | onClick: () => setAttributes( { nodeName: 'H' + level } ), 92 | subscript: level, 93 | } ) ) 94 | } 95 | /> 96 |
97 | ) } 98 | 99 | { textAlignmentEnabled && ( 100 | 101 | { 104 | setAttributes( { align: nextAlign } ); 105 | } } 106 | /> 107 | 108 | )} 109 |
110 | ), 111 | setAttributes( { content: value } ) } 117 | onMerge={ mergeBlocks } 118 | onSplit={ onSplit } 119 | onRemove={ () => onReplace( [] ) } 120 | style={ { textAlign: align } } 121 | className={ className } 122 | placeholder={ placeholder || __( 'Write heading…' ) } 123 | isSelected={ isSelected } 124 | onFocus={ onFocus } 125 | keepPlaceholderOnFocus={ true } 126 | multiline={ false } 127 | />, 128 | ]; 129 | }, 130 | 131 | supports: { 132 | className: isBlockFeatureEnabled(name, 'className'), 133 | anchor: isBlockFeatureEnabled(name, 'anchor'), 134 | }, 135 | } 136 | -------------------------------------------------------------------------------- /client/src/blocks/list/index.js: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import * as list from '@wordpress/blocks/library/list'; 3 | import { isBlockFeatureEnabled } from '../../config'; 4 | 5 | import { 6 | BlockControls, 7 | InspectorControls, 8 | } from '@wordpress/blocks'; 9 | 10 | import { PanelBody } from '@wordpress/components'; 11 | import { createBlock } from '@wordpress/blocks/api'; 12 | import { Personalisation, RichText } from '../../components'; 13 | 14 | class ListBlock extends list.settings.edit { 15 | render() { 16 | const { 17 | attributes, 18 | isSelected, 19 | insertBlocksAfter, 20 | setAttributes, 21 | mergeBlocks, 22 | onReplace, 23 | className, 24 | onFocus, 25 | } = this.props; 26 | 27 | const { nodeName, values, personalisation } = attributes; 28 | 29 | const personalisationEnabled = isBlockFeatureEnabled('list', 'personalisation'); 30 | 31 | const sidebarEnabled = ( 32 | personalisationEnabled 33 | ); 34 | 35 | return [ 36 | isSelected && ( 37 | 64 | ), 65 | 66 | isSelected && sidebarEnabled && ( 67 | 68 | { personalisationEnabled && ( 69 | 70 | setAttributes( { personalisation: values } ) } 73 | /> 74 | 75 | )} 76 | 77 | ), 78 | 79 | { 95 | if ( ! blocks.length ) { 96 | blocks.push( createBlock( 'core/paragraph' ) ); 97 | } 98 | 99 | if ( after.length ) { 100 | blocks.push( createBlock( 'core/list', { 101 | nodeName, 102 | values: after, 103 | } ) ); 104 | } 105 | 106 | setAttributes( { values: before } ); 107 | insertBlocksAfter( blocks ); 108 | } : 109 | undefined 110 | } 111 | onRemove={ () => onReplace( [] ) } 112 | isSelected={ isSelected } 113 | />, 114 | ]; 115 | } 116 | } 117 | 118 | export const name = 'core/list'; 119 | 120 | export const settings = { 121 | ...list.settings, 122 | 123 | edit: ListBlock, 124 | 125 | attributes: { 126 | nodeName: { 127 | type: 'string', 128 | source: 'property', 129 | selector: 'ol,ul', 130 | property: 'nodeName', 131 | default: 'UL', 132 | }, 133 | values: { 134 | type: 'array', 135 | source: 'children', 136 | selector: 'ol,ul', 137 | default: [], 138 | }, 139 | personalisation: { 140 | type: 'array', 141 | }, 142 | }, 143 | } 144 | -------------------------------------------------------------------------------- /client/src/blocks/lede/index.js: -------------------------------------------------------------------------------- 1 | import { isBlockFeatureEnabled } from '../../config'; 2 | 3 | import classnames from 'classnames'; 4 | import * as paragraph from '@wordpress/blocks/library/paragraph'; 5 | import { __ } from '@wordpress/i18n'; 6 | 7 | import { InspectorControls } from '@wordpress/blocks'; 8 | 9 | import { createBlock } from '@wordpress/blocks/api'; 10 | 11 | import { blockAutocompleter, userAutocompleter } from '@wordpress/blocks/autocompleters'; 12 | 13 | import { PanelBody, ToggleControl, Autocomplete } from '@wordpress/components'; 14 | 15 | import { RichText } from '../../components'; 16 | 17 | import './style.scss'; 18 | 19 | class LedeBlock extends paragraph.settings.edit { 20 | constructor() { 21 | super( ...arguments ); 22 | } 23 | 24 | render() { 25 | const { 26 | attributes, 27 | setAttributes, 28 | insertBlocksAfter, 29 | isSelected, 30 | mergeBlocks, 31 | onReplace, 32 | } = this.props; 33 | 34 | const { 35 | content, 36 | dropCap, 37 | } = attributes; 38 | 39 | const dropCapEnabled = isBlockFeatureEnabled('lede', 'dropCap'); 40 | 41 | return [ 42 | dropCapEnabled && isSelected && ( 43 | 44 | 45 | 50 | 51 | 52 | ), 53 | 54 |
55 | 59 | { ( { isExpanded, listBoxId, activeId } ) => ( 60 | { 67 | setAttributes( { 68 | content: nextContent, 69 | } ); 70 | } } 71 | onSplit={ insertBlocksAfter ? 72 | ( before, after, ...blocks ) => { 73 | setAttributes( { content: before } ); 74 | insertBlocksAfter( [ 75 | ...blocks, 76 | createBlock( 'core/paragraph', { content: after } ), 77 | ] ); 78 | } : 79 | undefined 80 | } 81 | onMerge={ mergeBlocks } 82 | onReplace={ this.onReplace } 83 | onRemove={ () => onReplace( [] ) } 84 | placeholder={ __( 'Insert lede…' ) } 85 | aria-autocomplete="list" 86 | aria-expanded={ isExpanded } 87 | aria-owns={ listBoxId } 88 | aria-activedescendant={ activeId } 89 | isSelected={ isSelected } 90 | /> 91 | ) } 92 | 93 |
, 94 | ]; 95 | } 96 | } 97 | 98 | const schema = { 99 | content: { 100 | type: 'array', 101 | source: 'children', 102 | selector: 'p', 103 | default: [], 104 | }, 105 | dropCap: { 106 | type: 'boolean', 107 | default: false, 108 | }, 109 | }; 110 | 111 | export const name = 'madehq/lede-copy'; 112 | 113 | export const settings = { 114 | ...paragraph.settings, 115 | 116 | title: 'Lede Copy', 117 | 118 | description: 'Insert lede copy', 119 | 120 | edit: LedeBlock, 121 | 122 | attributes: schema, 123 | 124 | transforms: { 125 | from: [ 126 | { 127 | type: 'block', 128 | blocks: [ 'core/paragraph' ], 129 | transform: ( { content } ) => { 130 | return createBlock( 'madehq/lede-copy', { 131 | content, 132 | } ); 133 | }, 134 | }, 135 | { 136 | type: 'pattern', 137 | regExp: /^(#{2,6})\s/, 138 | transform: ( { content } ) => { 139 | return createBlock( 'madehq/lede-copy', { 140 | nodeName: 'P', 141 | content, 142 | } ); 143 | }, 144 | }, 145 | ], 146 | to: [ 147 | { 148 | type: 'block', 149 | blocks: [ 'core/paragraph' ], 150 | transform: ( { content } ) => { 151 | return createBlock( 'core/paragraph', { 152 | content, 153 | } ); 154 | }, 155 | }, 156 | { 157 | type: 'block', 158 | blocks: [ 'core/heading' ], 159 | transform: ( { content } ) => { 160 | return createBlock( 'core/heading', { 161 | content, 162 | } ); 163 | }, 164 | }, 165 | ], 166 | }, 167 | 168 | save( { attributes } ) { 169 | const { content, dropCap, } = attributes; 170 | 171 | const className = classnames('o-lede', { 172 | 'o-dropcap': dropCap, 173 | }); 174 | 175 | return

{ content }

; 176 | } 177 | }; 178 | -------------------------------------------------------------------------------- /client/src/bundle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import * as components from '@wordpress/components'; 5 | import * as blocks from '@wordpress/blocks'; 6 | import * as element from '@wordpress/element'; 7 | 8 | import * as button from '@wordpress/blocks/library/button'; 9 | import ContrastChecker from '@wordpress/blocks/contrast-checker'; 10 | 11 | import { ImageControl, withFetch } from './components'; 12 | 13 | window.wp = window.wp || {}; 14 | window.wp.components = { 15 | ...components, 16 | ImageControl, 17 | withFetch, 18 | }; 19 | 20 | window.wp.blocks = blocks; 21 | window.wp.element = element; 22 | window.wp.library = { 23 | button, 24 | }; 25 | window.wp.extraBlocks = { 26 | ContrastChecker, 27 | }; 28 | 29 | import { isArray, isString, debounce, isEqual, extend, has, isObject, filter, includes, without, delay, find, isNull } from "lodash"; 30 | 31 | window._ = { 32 | isEqual: isEqual, 33 | isObject: isObject, 34 | filter: filter, 35 | contains: includes, 36 | without: without, 37 | delay: delay, 38 | find: find, 39 | isNull: isNull, 40 | isArray: isArray, 41 | }; 42 | 43 | import './code-editor-config'; 44 | 45 | import './store'; 46 | 47 | import { EditorProvider } from "@wordpress/editor"; 48 | import Layout from "./components/layout"; 49 | import { subscribe, select } from "@wordpress/data"; 50 | import { rawHandler, serialize } from "@wordpress/blocks"; 51 | 52 | import { setConfig, getConfigValue, isBlockFeatureEnabled } from './config'; 53 | 54 | window.wp.ssConfig = { 55 | getConfigValue, 56 | setConfig, 57 | isBlockFeatureEnabled, 58 | }; 59 | 60 | import { registerBlocks } from "./blocks"; 61 | 62 | import "./style.scss"; 63 | 64 | const gutenbergConfigSetEvent = new Event('GutenbergConfigSet'); 65 | 66 | let blocksRegistered = false; 67 | 68 | jQuery.entwine('ss', ($) => { 69 | $('.js-injector-boot textarea.gutenbergeditor').entwine({ 70 | Component: null, 71 | 72 | Container: null, 73 | 74 | Field: null, 75 | 76 | Config: null, 77 | 78 | onmatch() { 79 | this._super(); 80 | 81 | // Grab the entire field 82 | const field = this.parents('div.gutenbergeditor'); 83 | 84 | // Set the global config 85 | setConfig(this.data('gutenberg')); 86 | 87 | // Trigger event for other block registers 88 | document.dispatchEvent(gutenbergConfigSetEvent); 89 | 90 | // Store field for future 91 | this.setField(field); 92 | 93 | // Register blocks if not registered 94 | if (!blocksRegistered) { 95 | registerBlocks(); 96 | 97 | blocksRegistered = true; 98 | } 99 | 100 | // Start the instance of gutenberg 101 | this.startGutenberg(); 102 | }, 103 | 104 | onunmatch() { 105 | this._super(); 106 | 107 | // Stop the instance of gutenberg 108 | this.stopGutenberg(); 109 | }, 110 | 111 | startGutenberg() { 112 | // Grab current value 113 | let originalValue = this.val().trim(); 114 | 115 | // Check if we have wordpress content to ensure that 116 | // we can provide raw content as editable content 117 | if (originalValue.length && originalValue.indexOf('wp:') === -1) { 118 | // Turn raw html into blocks 119 | let blocks = rawHandler({ 120 | HTML: originalValue, 121 | mode: 'BLOCKS', 122 | }); 123 | 124 | // Turn into text for usage! 125 | originalValue = serialize(blocks).trim(); 126 | } 127 | 128 | // Content to object which wordpress expects since 129 | // we only store the raw value 130 | let currentContent = originalValue || ''; 131 | 132 | // Listen for changes 133 | subscribe(debounce(() => { 134 | const content = select('core/editor').getEditedPostContent().trim(); 135 | 136 | if (isEqual(currentContent, content)) { 137 | return false; 138 | } 139 | 140 | currentContent = content; 141 | 142 | // Using the textarea... 143 | this 144 | // ...update the value 145 | .val(content) 146 | // ...and trigger the change 147 | .trigger('change'); 148 | }, 100)); 149 | 150 | // Grab the holder & container 151 | let container = this.getField().siblings('.gutenberg__editor'); 152 | let newContainer = null; 153 | 154 | // If no container is present, make a container 155 | if (!container.length) { 156 | newContainer = document.createElement('div'); 157 | newContainer.setAttribute('class', 'gutenberg__editor'); 158 | 159 | container = newContainer; 160 | } 161 | 162 | // @todo rework entwine so that react has control of holder 163 | ReactDOM.render( 164 | 165 | 166 | , 167 | container 168 | ); 169 | 170 | this.setContainer(container); 171 | 172 | if (newContainer) { 173 | this.getField().append(container); 174 | } 175 | 176 | // Tell field we're ready to hide everything 177 | this.getField().addClass('gutenbergeditor--loaded'); 178 | }, 179 | 180 | stopGutenberg() { 181 | // Remove gutenberg 182 | ReactDOM.unmountComponentAtNode( 183 | this.getContainer() 184 | ); 185 | } 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /src/FieldTypes/DBGutenbergText.php: -------------------------------------------------------------------------------- 1 | ParagraphBlock::class, 27 | 'embed' => EmbedBlock::class, 28 | 'list' => ListBlock::class, 29 | 'pullquote' => PullQuoteBlock::class, 30 | 'heading' => HeadingBlock::class, 31 | 'separator' => SeparatorBlock::class, 32 | 'quote' => QuoteBlock::class, 33 | 'code' => CodeBlock::class, 34 | 'table' => TableBlock::class, 35 | 'html' => HTMLBlock::class, 36 | 'madehq/image-selector' => ImageBlock::class, 37 | 'madehq/image-gallery' => ImageGallery::class, 38 | 'madehq/image' => Image2Block::class, 39 | ]; 40 | 41 | private static $casting = [ 42 | 'RAW' => 'HTMLText', 43 | 'forTemplate' => 'HTMLText', 44 | ]; 45 | 46 | /** 47 | * @return HTMLText 48 | */ 49 | public function RAW() 50 | { 51 | return $this->value; 52 | } 53 | 54 | /** 55 | * Plain text version 56 | * 57 | * @return string Plain text 58 | */ 59 | public function Plain() 60 | { 61 | return html_entity_decode( 62 | preg_replace( 63 | array('/\s{2,}/', '/[\t\n]/'), 64 | ' ', 65 | strip_tags($this->forTemplate()) 66 | ), 67 | ENT_QUOTES 68 | ); 69 | } 70 | 71 | /** 72 | * @return DBHTMLText 73 | */ 74 | public function forTemplate() 75 | { 76 | // Store locally 77 | $value = $this->value; 78 | 79 | // Don't try to parse content if it's not Gutenberg 80 | if (stripos($value, 'wp:') === false) { 81 | return DBHTMLText::create()->setValue($value); 82 | } 83 | 84 | // Add some functionality to make this extendebale 85 | $this->extend('onBeforeBlockParse', $value); 86 | 87 | // Call the processor 88 | $content = $this->processBlocks($value); 89 | 90 | // Add some functionality to make this extendebale 91 | $this->extend('onAfterBlockParse', $content); 92 | 93 | return $content; 94 | } 95 | 96 | /** 97 | * Shamelessly taken from Wordpress. It's a mess, but it works. 98 | * 99 | * @link https://github.com/WordPress/gutenberg/blob/master/lib/blocks.php#L126 100 | * @param string $value 101 | * @return string 102 | */ 103 | protected function processBlocks($value = '') 104 | { 105 | $content = $value; 106 | 107 | $rendered_content = ''; 108 | 109 | $block_processors = static::config()->get('block_processors'); 110 | 111 | $matcher = '//s'; 112 | 113 | while (preg_match($matcher, $content, $block_match, PREG_OFFSET_CAPTURE)) { 114 | $opening_tag = $block_match[0][0]; 115 | $offset = $block_match[0][1]; 116 | $block_name = $block_match[1][0]; 117 | $is_self_closing = isset($block_match[4]); 118 | 119 | // Reset attributes JSON to prevent scope bleed from last iteration. 120 | $block_attributes_json = null; 121 | 122 | if (isset($block_match[3])) { 123 | $block_attributes_json = $block_match[3][0]; 124 | } 125 | 126 | $block_content = substr($content, strlen($opening_tag)); 127 | 128 | $content = substr($content, $offset + strlen($opening_tag)); 129 | 130 | if (!$is_self_closing) { 131 | $end_tag_pattern = '//s'; 132 | 133 | if (!preg_match($end_tag_pattern, $block_content, $block_match_end, PREG_OFFSET_CAPTURE)) { 134 | break; 135 | } 136 | 137 | $end_tag = $block_match_end[0][0]; 138 | $end_offset = $block_match_end[0][1]; 139 | 140 | $block_content = substr($block_content, 0, strpos($block_content, $end_tag)); 141 | 142 | $content = substr($content, $end_offset + strlen($end_tag)); 143 | } 144 | 145 | if (array_key_exists($block_name, $block_processors)) { 146 | // user_error(sprintf( 147 | // 'The block "%s" does not have a processor', $block_name 148 | // ), E_USER_WARNING); 149 | 150 | // continue; 151 | $block_type = singleton($block_processors[$block_name]); 152 | } else { 153 | $block_type = singleton(BaseBlock::class); 154 | } 155 | 156 | $attributes = array(); 157 | 158 | if (!empty($block_attributes_json)) { 159 | $decoded_attributes = json_decode( $block_attributes_json, true ); 160 | 161 | if (!is_null($decoded_attributes)) { 162 | $attributes = $decoded_attributes; 163 | } 164 | } 165 | 166 | $this->extend('beforeBlockRender', $block_content, $attributes); 167 | 168 | if (is_string($block_content)) { 169 | $block_content = $block_type->render($block_content, $attributes); 170 | 171 | $this->extend('afterBlockRender', $block_content, $attributes); 172 | 173 | $rendered_content .= $block_content; 174 | } 175 | 176 | $content = trim($content); 177 | } 178 | 179 | return $rendered_content; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /client/src/blocks/embed/index.js: -------------------------------------------------------------------------------- 1 | import { parse } from 'url'; 2 | import { includes, kebabCase, toLower } from 'lodash'; 3 | import { isBlockFeatureEnabled, getConfigValue } from '../../config'; 4 | 5 | import * as embed from '@wordpress/blocks/library/embed'; 6 | import { __, sprintf } from '@wordpress/i18n'; 7 | import { stringify } from 'querystring'; 8 | import classnames from 'classnames'; 9 | 10 | import { 11 | BlockControls, 12 | BlockAlignmentToolbar, 13 | RichText, 14 | InspectorControls, 15 | } from '@wordpress/blocks'; 16 | 17 | import { 18 | Spinner, 19 | Placeholder, 20 | Button, 21 | SandBox, 22 | PanelBody, 23 | } from '@wordpress/components'; 24 | 25 | // These embeds do not work in sandboxes 26 | const HOSTS_NO_PREVIEWS = [ 'facebook.com' ]; 27 | 28 | const title = 'Embed'; 29 | const icon = 'embed-generic'; 30 | 31 | class EmbedBlock extends embed.settings.edit { 32 | doServerSideRender(event) { 33 | if (event) { 34 | event.preventDefault(); 35 | } 36 | 37 | const { url } = this.props.attributes; 38 | const { setAttributes } = this.props; 39 | 40 | this.setState({ 41 | error: false, 42 | fetching: true, 43 | }); 44 | 45 | wp.apiRequest({ path: `${getConfigValue('oembed')}?${stringify({url})}` }) 46 | .then(obj => { 47 | if (this.unmounting) { 48 | return; 49 | } 50 | 51 | const { html, provider_name: providerName } = obj; 52 | const providerNameSlug = kebabCase(toLower(providerName)); 53 | let { type } = obj; 54 | 55 | if (includes(html, 'class="wp-embedded-content" data-secret')) { 56 | type = 'wp-embed'; 57 | } 58 | 59 | if (html) { 60 | this.setState({ 61 | html, type, providerNameSlug, 62 | }); 63 | 64 | setAttributes({ 65 | html, type, providerNameSlug, 66 | }); 67 | } else if ('photo' === type) { 68 | this.setState({ 69 | html: this.getPhotoHtml(obj), 70 | type, 71 | providerNameSlug 72 | }); 73 | 74 | setAttributes({ 75 | html: this.getPhotoHtml(obj), 76 | type, 77 | providerNameSlug 78 | }); 79 | } 80 | 81 | this.setState({ 82 | fetching: false, 83 | }); 84 | }, () => { 85 | this.setState({ 86 | fetching: false, 87 | error: true, 88 | }); 89 | }); 90 | } 91 | 92 | render() { 93 | const { html, type, error, fetching } = this.state; 94 | const { align, url, caption } = this.props.attributes; 95 | const { setAttributes, isSelected, className } = this.props; 96 | const updateAlignment = ( nextAlign ) => setAttributes( { align: nextAlign } ); 97 | 98 | const blockAlignmentEnabled = isBlockFeatureEnabled('embed', 'blockAlignment'); 99 | const captionEnabled = isBlockFeatureEnabled('embed', 'caption'); 100 | 101 | const controls = isSelected && blockAlignmentEnabled && ( 102 | 103 | 107 | 108 | ); 109 | 110 | if ( fetching ) { 111 | return [ 112 | controls, 113 |
114 | 115 |

{ __( 'Embedding…' ) }

116 |
, 117 | ]; 118 | } 119 | 120 | if ( ! html ) { 121 | const label = sprintf( __( '%s URL' ), title ); 122 | 123 | return [ 124 | controls, 125 | 126 |
127 | setAttributes( { url: event.target.value } ) } /> 134 | 139 | { error &&

{ __( 'Sorry, we could not embed that content.' ) }

} 140 |
141 |
, 142 | ]; 143 | } 144 | 145 | const parsedUrl = parse( url ); 146 | const cannotPreview = includes( HOSTS_NO_PREVIEWS, parsedUrl.host.replace( /^www\./, '' ) ); 147 | const iframeTitle = sprintf( __( 'Embedded content from %s' ), parsedUrl.host ); 148 | const embedWrapper = 'wp-embed' === type ? ( 149 |
153 | ) : ( 154 |
155 | 160 |
161 | ); 162 | 163 | return [ 164 | controls, 165 |
166 | { ( cannotPreview ) ? ( 167 | 168 |

{ url }

169 |

{ __( 'Previews for this are unavailable in the editor, sorry!' ) }

170 |
171 | ) : embedWrapper } 172 | 173 | { ( captionEnabled && caption && caption.length > 0 ) || isSelected ? ( 174 | setAttributes( { caption: value } ) } 179 | isSelected={ isSelected } 180 | inlineToolbar 181 | /> 182 | ) : null } 183 |
, 184 | ]; 185 | } 186 | } 187 | 188 | export const name = embed.name; 189 | 190 | export const settings = { 191 | ...embed.settings, 192 | 193 | edit: EmbedBlock, 194 | 195 | attributes: { 196 | url: { 197 | type: 'string', 198 | }, 199 | html: { 200 | type: 'string', 201 | }, 202 | caption: { 203 | type: 'array', 204 | source: 'children', 205 | selector: 'figcaption', 206 | default: [], 207 | }, 208 | align: { 209 | type: 'string', 210 | }, 211 | type: { 212 | type: 'string', 213 | }, 214 | providerNameSlug: { 215 | type: 'string', 216 | }, 217 | }, 218 | }; 219 | -------------------------------------------------------------------------------- /client/src/blocks/image2/index.js: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { withInstanceId, withState, PanelBody, BaseControl } from '@wordpress/components'; 3 | import { InspectorControls, BlockAlignmentToolbar } from '@wordpress/blocks'; 4 | import { ImageControl } from '../../components'; 5 | 6 | import { map, mapValues, extend, isEmpty } from 'lodash'; 7 | 8 | const cloudinaryImage = window.cloudinaryImage; 9 | 10 | import './style.scss'; 11 | 12 | const GRAVITY_VALUES = [ 13 | { value: 'auto', title: 'Auto'}, 14 | { value: 'center', title: 'Center'}, 15 | { value: 'face', title: 'Face'}, 16 | { value: 'face:auto', title: 'Face (or auto)'}, 17 | { value: 'faces', title: 'Faces'}, 18 | { value: 'faces:auto', title: 'Faces (or auto)'}, 19 | { value: 'body:face', title: 'Body or Face'}, 20 | { value: 'north', title: 'Top'}, 21 | { value: 'north_east', title: 'Top Right'}, 22 | { value: 'east', title: 'Right'}, 23 | { value: 'south_east', title: 'Bottom Right'}, 24 | { value: 'south', title: 'Bottom'}, 25 | { value: 'south_west', title: 'Bottom Left'}, 26 | { value: 'west', title: 'Left'}, 27 | { value: 'north_west', title: 'Top Left'}, 28 | ]; 29 | 30 | export const name = 'madehq/image'; 31 | 32 | export const settings = { 33 | title: __( 'Image 2' ), 34 | 35 | description: __( 'This will allow you to pull an image or gallery into content' ), 36 | 37 | icon: 'format-image', 38 | 39 | category: 'common', 40 | 41 | keywords: [ __( 'text' ) ], 42 | 43 | edit({ attributes, setAttributes, isSelected }) { 44 | const { images, captions, credits } = attributes; 45 | 46 | const updateImages = value => { 47 | let newImages = mapValues(value, (item, key) => { 48 | let image = images[key]; 49 | 50 | if (!image) { 51 | return item; 52 | } 53 | 54 | item.credit = image.credit; 55 | item.caption = image.caption; 56 | image.gravity = image.gravity || 'auto'; 57 | 58 | return item; 59 | }); 60 | 61 | setAttributes({ images: newImages }); 62 | }; 63 | 64 | const updateCredit = (key, value) => { 65 | let newImages = extend({}, images); 66 | 67 | newImages[key].credit = value; 68 | 69 | setAttributes({ images: newImages }); 70 | } 71 | 72 | const updateCaption = (key, value) => { 73 | let newImages = extend({}, images); 74 | 75 | newImages[key].caption = value; 76 | 77 | setAttributes({ images: newImages }); 78 | } 79 | 80 | const updateGravity = (key, value) => { 81 | let newImages = extend({}, images); 82 | 83 | newImages[key].gravity = value; 84 | 85 | setAttributes({ images: newImages }); 86 | } 87 | 88 | const renderImage = (image, key) => { 89 | return ( 90 |
91 | 92 | 93 |
94 |
95 | 102 | 103 | updateCredit(key, e.target.value) } 110 | /> 111 |
112 | 113 |
114 | 121 | 122 | updateCaption(key, e.target.value) } 129 | /> 130 |
131 | 132 |
133 | 140 | 141 | 149 |
150 |
151 |
152 | ); 153 | }; 154 | 155 | const classes = ['full-preview-holder']; 156 | 157 | if (images.length === 1) { 158 | classes.push('has-one'); 159 | } 160 | 161 | return [ 162 | isSelected && ( 163 | 164 | 165 | 170 | 171 | 172 | ), 173 | 174 |
175 | { !images || isEmpty(images) && ( 176 |

Please select an image…

177 | ) } 178 | 179 | { images && !isEmpty(images) && ( 180 |
181 |
182 | { map(images, renderImage) } 183 |
184 |
185 | ) } 186 |
187 | ]; 188 | }, 189 | 190 | attributes: { 191 | images: { 192 | type: 'object', 193 | default: {}, 194 | }, 195 | }, 196 | 197 | save({ attributes }) { 198 | return ( 199 |
200 | ); 201 | } 202 | }; 203 | -------------------------------------------------------------------------------- /client/src/buttons.scss: -------------------------------------------------------------------------------- 1 | // This is copied from wordpress buttons stulesheet 2 | 3 | .wp-core-ui .button, 4 | .wp-core-ui .button-primary, 5 | .wp-core-ui .button-secondary { 6 | display: inline-block; 7 | text-decoration: none; 8 | font-size: 13px; 9 | line-height: 26px; 10 | height: 28px; 11 | margin: 0; 12 | padding: 0 10px 1px; 13 | cursor: pointer; 14 | border-width: 1px; 15 | border-style: solid; 16 | -webkit-appearance: none; 17 | border-radius: 3px; 18 | white-space: nowrap; 19 | box-sizing: border-box; 20 | } 21 | 22 | /* Remove the dotted border on :focus and the extra padding in Firefox */ 23 | .wp-core-ui button::-moz-focus-inner, 24 | .wp-core-ui input[type="reset"]::-moz-focus-inner, 25 | .wp-core-ui input[type="button"]::-moz-focus-inner, 26 | .wp-core-ui input[type="submit"]::-moz-focus-inner { 27 | border-width: 0; 28 | border-style: none; 29 | padding: 0; 30 | } 31 | 32 | .wp-core-ui .button.button-large, 33 | .wp-core-ui .button-group.button-large .button { 34 | height: 30px; 35 | line-height: 28px; 36 | padding: 0 12px 2px; 37 | } 38 | 39 | .wp-core-ui .button.button-small, 40 | .wp-core-ui .button-group.button-small .button { 41 | height: 24px; 42 | line-height: 22px; 43 | padding: 0 8px 1px; 44 | font-size: 11px; 45 | } 46 | 47 | .wp-core-ui .button.button-hero, 48 | .wp-core-ui .button-group.button-hero .button { 49 | font-size: 14px; 50 | height: 46px; 51 | line-height: 44px; 52 | padding: 0 36px; 53 | } 54 | 55 | .wp-core-ui .button:active, 56 | .wp-core-ui .button:focus { 57 | outline: none; 58 | } 59 | 60 | .wp-core-ui .button.hidden { 61 | display: none; 62 | } 63 | 64 | /* Style Reset buttons as simple text links */ 65 | 66 | .wp-core-ui input[type="reset"], 67 | .wp-core-ui input[type="reset"]:hover, 68 | .wp-core-ui input[type="reset"]:active, 69 | .wp-core-ui input[type="reset"]:focus { 70 | background: none; 71 | border: none; 72 | box-shadow: none; 73 | padding: 0 2px 1px; 74 | width: auto; 75 | } 76 | 77 | /* ---------------------------------------------------------------------------- 78 | 2.0 - Default Button Style 79 | ---------------------------------------------------------------------------- */ 80 | 81 | .wp-core-ui .button, 82 | .wp-core-ui .button-secondary { 83 | color: #555; 84 | border-color: #cccccc; 85 | background: #f7f7f7; 86 | box-shadow: 0 1px 0 #cccccc; 87 | vertical-align: top; 88 | } 89 | 90 | .wp-core-ui p .button { 91 | vertical-align: baseline; 92 | } 93 | 94 | .wp-core-ui .button.hover, 95 | .wp-core-ui .button:hover, 96 | .wp-core-ui .button-secondary:hover, 97 | .wp-core-ui .button.focus, 98 | .wp-core-ui .button:focus, 99 | .wp-core-ui .button-secondary:focus { 100 | background: #fafafa; 101 | border-color: #999; 102 | color: #23282d; 103 | } 104 | 105 | .wp-core-ui .button.focus, 106 | .wp-core-ui .button:focus, 107 | .wp-core-ui .button-secondary:focus { 108 | border-color: #5b9dd9; 109 | box-shadow: 0 0 3px rgba(0, 115, 170, 0.8); 110 | } 111 | 112 | .wp-core-ui .button.active, 113 | .wp-core-ui .button.active:hover, 114 | .wp-core-ui .button:active, 115 | .wp-core-ui .button-secondary:active { 116 | background: #eee; 117 | border-color: #999; 118 | box-shadow: inset 0 2px 5px -3px rgba(0, 0, 0, 0.5); 119 | -webkit-transform: translateY(1px); 120 | transform: translateY(1px); 121 | } 122 | 123 | .wp-core-ui .button.active:focus { 124 | border-color: #5b9dd9; 125 | box-shadow: inset 0 2px 5px -3px rgba(0, 0, 0, 0.5), 126 | 0 0 3px rgba(0, 115, 170, 0.8); 127 | } 128 | 129 | .wp-core-ui .button[disabled], 130 | .wp-core-ui .button:disabled, 131 | .wp-core-ui .button.disabled, 132 | .wp-core-ui .button-secondary[disabled], 133 | .wp-core-ui .button-secondary:disabled, 134 | .wp-core-ui .button-secondary.disabled, 135 | .wp-core-ui .button-disabled { 136 | color: #a0a5aa !important; 137 | border-color: #ddd !important; 138 | background: #f7f7f7 !important; 139 | box-shadow: none !important; 140 | text-shadow: 0 1px 0 #fff !important; 141 | cursor: default; 142 | -webkit-transform: none !important; 143 | transform: none !important; 144 | } 145 | 146 | /* Buttons that look like links, for a cross of good semantics with the visual */ 147 | .wp-core-ui .button-link { 148 | margin: 0; 149 | padding: 0; 150 | box-shadow: none; 151 | border: 0; 152 | border-radius: 0; 153 | background: none; 154 | outline: none; 155 | cursor: pointer; 156 | text-align: left; 157 | /* Mimics the default link style in common.css */ 158 | color: #0073aa; 159 | text-decoration: underline; 160 | transition-property: border, background, color; 161 | transition-duration: 0.05s; 162 | transition-timing-function: ease-in-out; 163 | } 164 | 165 | .wp-core-ui .button-link:hover, 166 | .wp-core-ui .button-link:active { 167 | color: #00a0d2; 168 | } 169 | 170 | .wp-core-ui .button-link:focus { 171 | color: #124964; 172 | box-shadow: 0 0 0 1px #5b9dd9, 0 0 2px 1px rgba(30, 140, 190, 0.8); 173 | } 174 | 175 | .wp-core-ui .button-link-delete { 176 | color: #a00; 177 | } 178 | 179 | .wp-core-ui .button-link-delete:hover, 180 | .wp-core-ui .button-link-delete:focus { 181 | color: #dc3232; 182 | } 183 | 184 | .ie8 .wp-core-ui .button-link:focus { 185 | outline: #5b9dd9 solid 1px; 186 | } 187 | 188 | /* ---------------------------------------------------------------------------- 189 | 3.0 - Primary Button Style 190 | ---------------------------------------------------------------------------- */ 191 | 192 | .wp-core-ui .button-primary { 193 | background: #0085ba; 194 | border-color: #0073aa #006799 #006799; 195 | box-shadow: 0 1px 0 #006799; 196 | color: #fff; 197 | text-decoration: none; 198 | text-shadow: 0 -1px 1px #006799, 1px 0 1px #006799, 0 1px 1px #006799, 199 | -1px 0 1px #006799; 200 | } 201 | 202 | .wp-core-ui .button-primary.hover, 203 | .wp-core-ui .button-primary:hover, 204 | .wp-core-ui .button-primary.focus, 205 | .wp-core-ui .button-primary:focus { 206 | background: #008ec2; 207 | border-color: #006799; 208 | color: #fff; 209 | } 210 | 211 | .wp-core-ui .button-primary.focus, 212 | .wp-core-ui .button-primary:focus { 213 | box-shadow: 0 1px 0 #0073aa, 0 0 2px 1px #33b3db; 214 | } 215 | 216 | .wp-core-ui .button-primary.active, 217 | .wp-core-ui .button-primary.active:hover, 218 | .wp-core-ui .button-primary.active:focus, 219 | .wp-core-ui .button-primary:active { 220 | background: #0073aa; 221 | border-color: #006799; 222 | box-shadow: inset 0 2px 0 #006799; 223 | vertical-align: top; 224 | } 225 | 226 | .wp-core-ui .button-primary[disabled], 227 | .wp-core-ui .button-primary:disabled, 228 | .wp-core-ui .button-primary-disabled, 229 | .wp-core-ui .button-primary.disabled { 230 | color: #66c6e4 !important; 231 | background: #008ec2 !important; 232 | border-color: #007cb2 !important; 233 | box-shadow: none !important; 234 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.1) !important; 235 | cursor: default; 236 | } 237 | 238 | .wp-core-ui .button.button-primary.button-hero { 239 | box-shadow: 0 2px 0 #006799; 240 | } 241 | 242 | .wp-core-ui .button.button-primary.button-hero.active, 243 | .wp-core-ui .button.button-primary.button-hero.active:hover, 244 | .wp-core-ui .button.button-primary.button-hero.active:focus, 245 | .wp-core-ui .button.button-primary.button-hero:active { 246 | box-shadow: inset 0 3px 0 #006799; 247 | } 248 | 249 | /* ---------------------------------------------------------------------------- 250 | 4.0 - Button Groups 251 | ---------------------------------------------------------------------------- */ 252 | 253 | .wp-core-ui .button-group { 254 | position: relative; 255 | display: inline-block; 256 | white-space: nowrap; 257 | font-size: 0; 258 | vertical-align: middle; 259 | } 260 | 261 | .wp-core-ui .button-group > .button { 262 | display: inline-block; 263 | border-radius: 0; 264 | margin-right: -1px; 265 | z-index: 10; 266 | } 267 | 268 | .wp-core-ui .button-group > .button-primary { 269 | z-index: 100; 270 | } 271 | 272 | .wp-core-ui .button-group > .button:hover { 273 | z-index: 20; 274 | } 275 | 276 | .wp-core-ui .button-group > .button:first-child { 277 | border-radius: 3px 0 0 3px; 278 | } 279 | 280 | .wp-core-ui .button-group > .button:last-child { 281 | border-radius: 0 3px 3px 0; 282 | } 283 | 284 | .wp-core-ui .button-group > .button:focus { 285 | position: relative; 286 | z-index: 1; 287 | } 288 | 289 | /* ---------------------------------------------------------------------------- 290 | 5.0 - Responsive Button Styles 291 | ---------------------------------------------------------------------------- */ 292 | 293 | @media screen and (max-width: 782px) { 294 | .wp-core-ui .button, 295 | .wp-core-ui .button.button-large, 296 | .wp-core-ui .button.button-small, 297 | input#publish, 298 | input#save-post, 299 | a.preview { 300 | padding: 6px 14px; 301 | line-height: normal; 302 | font-size: 14px; 303 | vertical-align: middle; 304 | height: auto; 305 | margin-bottom: 4px; 306 | } 307 | 308 | #media-upload.wp-core-ui .button { 309 | padding: 0 10px 1px; 310 | height: 24px; 311 | line-height: 22px; 312 | font-size: 13px; 313 | } 314 | 315 | .media-frame.mode-grid .bulk-select .button { 316 | margin-bottom: 0; 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /src/Controllers/APIController.php: -------------------------------------------------------------------------------- 1 | 60, 46 | 'min_image_height' => 60, 47 | 'html' => [ 48 | 'max_images' => 10, 49 | 'external_images' => false, 50 | ], 51 | ]; 52 | 53 | /** 54 | * Used to get File data to be used in the File Selector 55 | * @param HTTPRequest $request 56 | * @return HTTPResponse 57 | */ 58 | public function filedata(HTTPRequest $request) 59 | { 60 | HTTP::set_cache_age(60); 61 | 62 | if (!$request->param('ID')) { 63 | return $this->output(); 64 | } 65 | if (!($file = File::get_by_id(File::class, $request->param('ID')))) { 66 | return $this->output(); 67 | } 68 | 69 | $originalWidth = $file->getWidth(); 70 | $originalHeight = $file->getHeight(); 71 | 72 | if ($file instanceof Image && !$originalWidth && !$originalHeight) { 73 | list($originalWidth, $originalHeight) = getimagesize($file->SecureURL); 74 | } 75 | 76 | list($previewWidth, $previewHeight) = static::calculateWidthHeight( 77 | $originalWidth, 78 | $originalHeight, 79 | static::config()->uninherited('max_preview_width') 80 | ); 81 | 82 | $smallWidth = UploadField::config()->uninherited('thumbnail_width'); 83 | $smallHeight = UploadField::config()->uninherited('thumbnail_height'); 84 | 85 | $largeWidth = AssetAdmin::config()->uninherited('thumbnail_width'); 86 | $largeHeight = AssetAdmin::config()->uninherited('thumbnail_height'); 87 | 88 | return $this->output([ 89 | 'id' => $file->ID, 90 | 'title' => $file->Title, 91 | 'exists' => true, 92 | 'type' => $file->Type, 93 | 'category' => File::get_app_category($file->Format), 94 | 'name' => $file->Name, 95 | 'url' => $file->SecureURL, 96 | 'largeThumbnail' => $this->getThumbnailGenerator()->generateThumbnailLink($file, $previewWidth, $previewHeight), 97 | 'smallThumbnail' => $this->getThumbnailGenerator()->generateThumbnailLink($file, $smallWidth, $smallHeight), 98 | 'thumbnail' => $this->getThumbnailGenerator()->generateThumbnailLink($file, $largeWidth, $largeHeight), 99 | 'width' => $originalWidth, 100 | 'height' => $originalHeight, 101 | 'previewWidth' => $previewWidth, 102 | 'previewHeight' => $previewHeight, 103 | ]); 104 | } 105 | 106 | private static function calculateWidthHeight(int $originalWidth = null, int $originalHeight = null, int $width) 107 | { 108 | if ($width > $originalWidth) { 109 | return [ 110 | $originalWidth, 111 | $originalHeight 112 | ]; 113 | } 114 | $widthRatio = $width / $originalWidth; 115 | $height = $originalHeight * $widthRatio; 116 | return [ 117 | (int)$width, 118 | (int)$height 119 | ]; 120 | } 121 | 122 | /** 123 | * @param HTTPRequest $request 124 | * @return HTTPResponse 125 | */ 126 | public function oembed(HTTPRequest $request) 127 | { 128 | HTTP::set_cache_age(600); 129 | 130 | // Grab the URL 131 | $url = $request->getVar('url'); 132 | 133 | if (is_null($url) || !strlen($url)) { 134 | return $this->output(null); 135 | } 136 | 137 | if (stripos($url, 'itunes.apple.com') !== false) { 138 | return $this->output( 139 | static::get_podcast_details($url) 140 | ); 141 | } 142 | 143 | try { 144 | // Embed options 145 | $options = array_merge( 146 | Embed::$default_config, static::$oembed_options 147 | ); 148 | 149 | // Useful if we ever wish to find out why fetch went wrong 150 | $dispatcher = new CurlDispatcher(); 151 | 152 | // Try to fetch data 153 | $webpage = Embed::create($url, $options, $dispatcher); 154 | 155 | // Get all providers 156 | $providers = $webpage->getProviders(); 157 | 158 | if (array_key_exists('oembed', $providers)) { 159 | $data = $providers['oembed']->getBag()->getAll(); 160 | } else { 161 | $data = null; 162 | } 163 | } catch (\Exception $exception) { 164 | // Don't care about errors 165 | $data = null; 166 | } 167 | 168 | return $this->output($data); 169 | } 170 | 171 | /** 172 | * @param string $url 173 | * @return array|null 174 | */ 175 | public static function get_podcast_details($url) 176 | { 177 | if (!preg_match('/id(\d+)/', $url, $matches)) { 178 | return null; 179 | } 180 | 181 | $feed = file_get_contents( 182 | 'https://itunes.apple.com/lookup?entity=podcast&id=' . $matches[1] 183 | ); 184 | 185 | $feed = json_decode(trim($feed)); 186 | 187 | if (!$feed || (int) $feed->resultCount !== 1) { 188 | return null; 189 | } 190 | 191 | $result = $feed->results[0]; 192 | 193 | $rss = file_get_contents((string) $result->feedUrl); 194 | 195 | $rss = simplexml_load_string($rss); 196 | 197 | $items = []; 198 | 199 | foreach ($rss->channel->item as $item) { 200 | array_push($items, [ 201 | 'title' => (string) $item->title, 202 | 'mp3' => (string) $item->enclosure->attributes()->url, 203 | ]); 204 | } 205 | 206 | if (!count($items)) { 207 | return null; 208 | } 209 | 210 | return [ 211 | 'title' => trim((string) $rss->channel->title), 212 | 'description' => (string) $rss->channel->description, 213 | 'artwork' => (string) $result->artworkUrl600, 214 | 'url' => (string) $result->collectionViewUrl, 215 | 'items' => $items, 216 | ]; 217 | } 218 | 219 | /** 220 | * @param HTTPRequest $request 221 | * @return HTTPResponse 222 | */ 223 | public function posts(HTTPRequest $request) 224 | { 225 | // Grab the URL 226 | $search = $request->getVar('search'); 227 | $perPage = $request->getVar('per_page'); 228 | 229 | // Do search 230 | $pages = DataObject::get('Page') 231 | ->filter([ 232 | 'Title:PartialMatch' => Convert::raw2sql($search), 233 | ]) 234 | ->limit($perPage) 235 | ->sort('LastEdited DESC'); 236 | 237 | // Create a structrue that Gutenberg expects 238 | $data = []; 239 | 240 | foreach ($pages as $page) { 241 | array_push($data, [ 242 | 'id' => $page->ID, 243 | 'title' => [ 244 | 'rendered' => $page->Title, 245 | ], 246 | 'link' => $page->Link(), 247 | ]); 248 | } 249 | 250 | return $this->output($data); 251 | } 252 | 253 | /** 254 | * @param HTTPRequest $request 255 | * @return HTTPResponse 256 | */ 257 | public function none() 258 | { 259 | return $this->output([]); 260 | } 261 | 262 | /** 263 | * @return HTTPResponse 264 | */ 265 | public function output(array $data = null) 266 | { 267 | // Body & status code 268 | $responseBody = Convert::array2json($data); 269 | $statusCode = is_null($data) ? 404 : 200; 270 | 271 | // Get a new response going 272 | $response = new HTTPResponse($responseBody, $statusCode); 273 | 274 | // Add some headers 275 | $response->addHeader('Content-Type', 'application/json; charset=utf-8'); 276 | $response->addHeader('Access-Control-Allow-Methods', 'GET'); 277 | $response->addHeader('Access-Control-Allow-Origin', '*'); 278 | 279 | // Return 280 | return $response; 281 | } 282 | 283 | public function getThumbnailGenerator() 284 | { 285 | return $this->thumbnailGenerator; 286 | } 287 | 288 | public function setThumbnailGenerator(ThumbnailGenerator $generator) 289 | { 290 | $this->thumbnailGenerator = $generator; 291 | return $this; 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /examples/blocks/feature-panel/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const cloudinaryImage = window.cloudinaryImage; 4 | 5 | const InspectorControls = window.wp.blocks.InspectorControls; 6 | 7 | const { withFetch } = window.wp.components; 8 | const { compose } = window.wp.element; 9 | 10 | import { map } from 'lodash'; 11 | 12 | const { 13 | BaseControl, 14 | SelectControl, 15 | ToggleControl, 16 | } = window.wp.components; 17 | 18 | const { find, isNull, isArray } = window._; 19 | 20 | import './style.scss'; 21 | 22 | export const name = 'site/feature-panel'; 23 | 24 | export const settings = { 25 | title: 'Feature Panel', 26 | 27 | description: 'Insert a feature panel', 28 | 29 | category: 'common', 30 | 31 | attributes: { 32 | id: { 33 | type: 'integer', 34 | default: 0, 35 | }, 36 | theme: { 37 | type: 'string', 38 | default: 'black', 39 | } 40 | }, 41 | 42 | save( { attributes } ) { 43 | const { id } = attributes; 44 | 45 | return ( 46 |
47 | ); 48 | }, 49 | 50 | edit: withFetch(() => ({ 51 | featurePanelData: '/gutenberg-api/feature-panels', 52 | }))( ({ attributes, setAttributes, isSelected, featurePanelData }) => { 53 | if (!featurePanelData) { 54 | return ( 55 |

Loading…

56 | ); 57 | } 58 | 59 | const { id, theme, } = attributes; 60 | 61 | const panelList = featurePanelData.panels.map(item => { 62 | return { 63 | value: item.id, label: item.title, 64 | }; 65 | }); 66 | 67 | const themeList = map(featurePanelData.themes, (label, value) => { 68 | return { 69 | value, label 70 | } 71 | }); 72 | 73 | panelList.unshift({ 74 | value: 0, label: 'Select a feature…', 75 | }); 76 | 77 | let panelObject = find(featurePanelData.panels, item => { 78 | return parseInt(item.id, 10) === parseInt(id, 10); 79 | }); 80 | 81 | const { 82 | prefix, 83 | title, 84 | subtitle, 85 | type, 86 | image, 87 | link, 88 | } = panelObject || {}; 89 | 90 | const classes = []; 91 | 92 | if (type !== 'default') { 93 | classes.push(`feature-panel-${type}`); 94 | } else { 95 | classes.push('feature-panel'); 96 | } 97 | 98 | classes.push(`feature-panel--${theme}`); 99 | 100 | let titleClass = 'feature-panel__title'; 101 | 102 | if (type === 'cutout') { 103 | titleClass = 'feature-panel-cutout__title'; 104 | } else if (type === 'circle') { 105 | titleClass = 'feature-panel-circle__title'; 106 | } 107 | 108 | if (title) { 109 | if (title.length >= 40) { 110 | titleClass = `${titleClass} ${titleClass}--long`; 111 | } 112 | 113 | if (title.length >= 20) { 114 | titleClass = `${titleClass} ${titleClass}--medium`; 115 | } 116 | } 117 | 118 | return [ 119 | isSelected && ( 120 | 121 | setAttributes( { id: parseInt(value, 10) } ) } 125 | options={ panelList } 126 | /> 127 | 128 | setAttributes( { theme } ) } 132 | options={ themeList } 133 | /> 134 | 135 | ), 136 | 137 |
138 | { (!id || !panelObject) && ( 139 |

Please select a feature…

140 | ) } 141 | 142 | { !!id && panelObject && type === 'default' && ( 143 |
144 | { image && image.length > 0 && ( 145 |
146 |
147 | 148 | 152 | 153 | 154 |
155 |
156 | ) } 157 | 158 |
159 | { prefix && prefix.length > 0 && ( 160 | { prefix } 161 | ) } 162 | 163 | { title && title.length > 0 && ( 164 |

170 | ) } 171 | 172 | { subtitle && subtitle.length > 0 && ( 173 | { subtitle } 174 | ) } 175 | 176 | { link && ( 177 | 178 | { link.text || "Find out more" } 179 | 180 | ) } 181 |

182 |
183 | ) } 184 | 185 | { !!id && panelObject && type === 'circle' && ( 186 |
187 |
188 | { image && image.length > 0 && ( 189 | 190 | ) } 191 | 192 |
193 | { prefix && prefix.length > 0 && ( 194 |

{ prefix }

195 | ) } 196 | 197 | { title && title.length > 0 && ( 198 |

204 | ) } 205 | 206 | { subtitle && subtitle.length > 0 && ( 207 |

{ subtitle }

208 | ) } 209 | 210 | { link && ( 211 | 212 | { link.text || "Find out more" } 213 | 214 | ) } 215 |

216 |
217 |
218 | ) } 219 | 220 | { !!id && panelObject && type === 'cutout' && ( 221 |
222 | { image && image.length > 0 && ( 223 |
224 |
225 | 226 | 230 | 231 | 232 |
233 |
234 | ) } 235 | 236 |
237 | { title && title.length > 0 && ( 238 |

244 | ) } 245 | 246 | { subtitle && subtitle.length > 0 && ( 247 | { subtitle } 248 | ) } 249 | 250 | { link && ( 251 | 252 | { link.text || "Find out more" } 253 | 254 | ) } 255 |

256 |
257 | ) } 258 |
259 | ]; 260 | }) 261 | }; 262 | -------------------------------------------------------------------------------- /client/src/blocks/paragraph/index.js: -------------------------------------------------------------------------------- 1 | import { isBlockFeatureEnabled } from '../../config'; 2 | 3 | import classnames from 'classnames'; 4 | import * as paragraph from '@wordpress/blocks/library/paragraph'; 5 | import { __ } from '@wordpress/i18n'; 6 | 7 | import { 8 | BlockControls, 9 | AlignmentToolbar, 10 | BlockAlignmentToolbar, 11 | InspectorControls, 12 | ColorPalette, 13 | } from '@wordpress/blocks'; 14 | 15 | import { RichText } from '../../components'; 16 | 17 | import { createBlock } from '@wordpress/blocks/api'; 18 | 19 | import ContrastChecker from '@wordpress/blocks/contrast-checker'; 20 | import { blockAutocompleter, userAutocompleter } from '@wordpress/blocks/autocompleters'; 21 | 22 | import { 23 | PanelBody, 24 | ToggleControl, 25 | RangeControl, 26 | PanelColor, 27 | Autocomplete, 28 | withFallbackStyles 29 | } from '@wordpress/components'; 30 | 31 | import './style.scss'; 32 | 33 | const { getComputedStyle } = window; 34 | 35 | const ContrastCheckerWithFallbackStyles = withFallbackStyles((node, ownProps) => { 36 | const { textColor, backgroundColor } = ownProps; 37 | //avoid the use of querySelector if both colors are known and verify if node is available. 38 | const editableNode = (!textColor || !backgroundColor) && node ? node.querySelector('[contenteditable="true"]') : null; 39 | //verify if editableNode is available, before using getComputedStyle. 40 | const computedStyles = editableNode ? getComputedStyle(editableNode) : null; 41 | return { 42 | fallbackBackgroundColor: (backgroundColor || !computedStyles) ? undefined : computedStyles.backgroundColor, 43 | fallbackTextColor: (textColor || !computedStyles) ? undefined : computedStyles.color, 44 | }; 45 | })(ContrastChecker); 46 | 47 | class ParagraphBlock extends paragraph.settings.edit { 48 | constructor() { 49 | super( ...arguments ); 50 | } 51 | 52 | render() { 53 | const { 54 | attributes, 55 | setAttributes, 56 | insertBlocksAfter, 57 | isSelected, 58 | mergeBlocks, 59 | onReplace, 60 | } = this.props; 61 | 62 | const { 63 | align, 64 | content, 65 | dropCap, 66 | placeholder, 67 | fontSize, 68 | backgroundColor, 69 | textColor, 70 | width, 71 | } = attributes; 72 | 73 | const textAlignmentEnabled = isBlockFeatureEnabled('paragraph', 'textAlignment'); 74 | const dropCapEnabled = isBlockFeatureEnabled('paragraph', 'dropCap'); 75 | const fontSizeEnabled = isBlockFeatureEnabled('paragraph', 'fontSize'); 76 | const backgroundColorEnabled = isBlockFeatureEnabled('paragraph', 'backgroundColor'); 77 | const textColorEnabled = isBlockFeatureEnabled('paragraph', 'textColor'); 78 | const blockAlignmentEnabled = isBlockFeatureEnabled('paragraph', 'blockAlignment'); 79 | 80 | const textSettingsEnabled = ( 81 | dropCapEnabled || fontSizeEnabled 82 | ); 83 | 84 | const inspectorEnabled = ( 85 | textSettingsEnabled || backgroundColorEnabled || 86 | textColorEnabled || blockAlignmentEnabled 87 | ); 88 | 89 | return [ 90 | textAlignmentEnabled && isSelected && ( 91 | 92 | { 95 | setAttributes( { align: nextAlign } ); 96 | } } 97 | /> 98 | 99 | ), 100 | 101 | inspectorEnabled && isSelected && ( 102 | 103 | 104 | { textSettingsEnabled && ( 105 | 106 | { dropCapEnabled && ( 107 | 112 | )} 113 | 114 | { fontSizeEnabled && ( 115 | setAttributes( { fontSize: value } ) } 119 | min={ 10 } 120 | max={ 200 } 121 | beforeIcon="editor-textcolor" 122 | allowReset 123 | /> 124 | )} 125 | 126 | )} 127 | 128 | { backgroundColorEnabled && ( 129 | 130 | setAttributes( { backgroundColor: colorValue } ) } 133 | /> 134 | 135 | )} 136 | 137 | { textColorEnabled && ( 138 | 139 | setAttributes( { textColor: colorValue } ) } 142 | /> 143 | 144 | )} 145 | 146 | { this.nodeRef && backgroundColorEnabled && textColorEnabled && ( 147 | = 18 } 152 | /> 153 | )} 154 | 155 | { blockAlignmentEnabled && ( 156 | 157 | setAttributes( { width: nextWidth } ) } 160 | /> 161 | 162 | )} 163 | 164 | ), 165 |
166 | 170 | { ( { isExpanded, listBoxId, activeId } ) => ( 171 | { 185 | setAttributes( { 186 | content: nextContent, 187 | } ); 188 | } } 189 | onSplit={ insertBlocksAfter ? 190 | ( before, after, ...blocks ) => { 191 | setAttributes( { content: before } ); 192 | insertBlocksAfter( [ 193 | ...blocks, 194 | createBlock( 'core/paragraph', { content: after } ), 195 | ] ); 196 | } : 197 | undefined 198 | } 199 | onMerge={ mergeBlocks } 200 | onReplace={ this.onReplace } 201 | onRemove={ () => onReplace( [] ) } 202 | placeholder={ placeholder || __( 'Add text or type / to add content' ) } 203 | aria-autocomplete="list" 204 | aria-expanded={ isExpanded } 205 | aria-owns={ listBoxId } 206 | aria-activedescendant={ activeId } 207 | isSelected={ isSelected } 208 | /> 209 | ) } 210 | 211 |
, 212 | ]; 213 | } 214 | } 215 | 216 | const schema = { 217 | content: { 218 | type: 'array', 219 | source: 'children', 220 | selector: 'p', 221 | default: [], 222 | }, 223 | align: { 224 | type: 'string', 225 | }, 226 | dropCap: { 227 | type: 'boolean', 228 | default: false, 229 | }, 230 | placeholder: { 231 | type: 'string', 232 | }, 233 | width: { 234 | type: 'string', 235 | }, 236 | textColor: { 237 | type: 'string', 238 | }, 239 | backgroundColor: { 240 | type: 'string', 241 | }, 242 | fontSize: { 243 | type: 'string', 244 | }, 245 | customFontSize: { 246 | type: 'number', 247 | }, 248 | }; 249 | 250 | export const name = paragraph.name; 251 | 252 | export const settings = { 253 | ...paragraph.settings, 254 | 255 | edit: ParagraphBlock, 256 | 257 | attributes: schema, 258 | 259 | save( { attributes } ) { 260 | const { 261 | width, 262 | align, 263 | content, 264 | dropCap, 265 | backgroundColor, 266 | textColor, 267 | fontSize, 268 | customFontSize, 269 | } = attributes; 270 | 271 | const className = classnames( { 272 | [ `align${ width }` ]: width, 273 | 'has-background': backgroundColor, 274 | 'has-drop-cap': dropCap, 275 | [ `is-${ fontSize }-text` ]: fontSize && FONT_SIZES[ fontSize ], 276 | } ); 277 | 278 | const styles = { 279 | backgroundColor: backgroundColor, 280 | color: textColor, 281 | fontSize: ! fontSize && customFontSize ? customFontSize : undefined, 282 | textAlign: align, 283 | }; 284 | 285 | return

{ content }

; 286 | } 287 | }; 288 | -------------------------------------------------------------------------------- /client/src/components/image/index.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | 3 | import { Component } from '@wordpress/element'; 4 | import { withInstanceId } from '@wordpress/components'; 5 | 6 | import { 7 | forEach, 8 | keys, 9 | has, 10 | map, 11 | isEmpty, 12 | omit, 13 | isArray, 14 | isObject, 15 | mapValues, 16 | values, 17 | } from 'lodash'; 18 | 19 | const cloudinaryImage = window.cloudinaryImage; 20 | 21 | import './style.scss'; 22 | 23 | const fileDataCache = {}; 24 | 25 | const loadFileData = fileId => { 26 | if ( fileId in fileDataCache ) { 27 | return fileDataCache[fileId]; 28 | } 29 | 30 | fileDataCache[fileId] = fetch(`/gutenberg-api/filedata/${fileId}`, { credentials: 'same-origin' }) 31 | .then(response => response.json()) 32 | .then(fileData => { 33 | return fileData; 34 | }); 35 | 36 | return fileDataCache[fileId]; 37 | }; 38 | 39 | class ImageControl extends Component { 40 | constructor(props) { 41 | super(props); 42 | 43 | const { instanceId } = props; 44 | let { value } = props; 45 | 46 | if (!value || isEmpty(value)) { 47 | value = {}; 48 | } 49 | 50 | if (value && has(value, 'id')) { 51 | const oldValue = value; 52 | value = {}; 53 | value[ oldValue.id ] = oldValue; 54 | } 55 | 56 | this.state = { 57 | instanceId: instanceId, 58 | files: value, 59 | error: null, 60 | loading: true, 61 | }; 62 | 63 | this.mutationObserver = null; 64 | this.mounted = false; 65 | 66 | this.addListeners = this.addListeners.bind(this); 67 | this.entwineField = this.entwineField.bind(this); 68 | this.getStateData = this.getStateData.bind(this); 69 | this.getSchemaData = this.getSchemaData.bind(this); 70 | } 71 | 72 | componentDidMount() { 73 | let stateFiles = this.state.files; 74 | 75 | const promises = map(stateFiles, fileData => { 76 | return loadFileData(fileData.id); 77 | }); 78 | 79 | Promise.all(promises) 80 | .then(files => { 81 | forEach(files, (file) => { 82 | stateFiles[file.id].data = file; 83 | }); 84 | 85 | this.setState({ 86 | files: stateFiles, 87 | loading: false, 88 | }); 89 | }) 90 | .finally(() => { 91 | this.addListeners(); 92 | this.entwineField(); 93 | }); 94 | } 95 | 96 | componentDidUpdate() { 97 | this.addListeners(); 98 | this.entwineField(); 99 | } 100 | 101 | componentWillUnmount() { 102 | this.mutationObserver && this.mutationObserver.disconnect(); 103 | } 104 | 105 | setState(state) { 106 | super.setState(state); 107 | 108 | if (!has(state, 'files')) { 109 | return false; 110 | } 111 | 112 | let files = mapValues(state.files, file => { 113 | return omit(file, 'data'); 114 | }); 115 | 116 | if (!this.props.multiFiles) { 117 | files = values(files)[0]; 118 | } 119 | 120 | this.props.onChange( files ); 121 | } 122 | 123 | addListeners() { 124 | if (this.mutationObserver || this.state.loading) { 125 | return false; 126 | } 127 | 128 | // select the target node 129 | const targetNode = ReactDOM.findDOMNode(this); 130 | 131 | // Create an observer instance linked to the callback function 132 | this.mutationObserver = new MutationObserver(mutationsList => { 133 | for (let mutation of mutationsList) { 134 | if (mutation.type !== 'childList') { 135 | continue; 136 | } 137 | 138 | if (mutation.target.className !== 'uploadfield') { 139 | continue; 140 | } 141 | 142 | let toRemove = [], toAdd = []; 143 | 144 | forEach(mutation.addedNodes, node => { 145 | if (!node.className) { 146 | return true; 147 | } 148 | 149 | if (node.className.indexOf('uploadfield-item--image') === -1) { 150 | return true; 151 | } 152 | 153 | toAdd.push(parseInt(node.querySelector('input[type=hidden]').value, 10)); 154 | }); 155 | 156 | forEach(mutation.removedNodes, node => { 157 | if (!node.className) { 158 | return true; 159 | } 160 | 161 | if (node.className.indexOf('uploadfield-item--image') === -1) { 162 | return true; 163 | } 164 | 165 | toRemove.push(parseInt(node.querySelector('input[type=hidden]').value, 10)); 166 | }); 167 | 168 | let stateFiles = []; 169 | 170 | forEach(this.state.files, (file, id) => { 171 | if (toRemove.indexOf(parseInt(id, 10)) !== -1) { 172 | return true; 173 | } 174 | 175 | stateFiles.push(id); 176 | }); 177 | 178 | toAdd.forEach(fileData => { 179 | stateFiles.push(fileData); 180 | }); 181 | 182 | stateFiles = stateFiles.filter(fileId => { 183 | return fileId; 184 | }) 185 | 186 | let promises = map(stateFiles, fileId => { 187 | return loadFileData(fileId); 188 | }); 189 | 190 | Promise.all(promises).then(files => { 191 | let toSave = {}; 192 | 193 | files.forEach(file => { 194 | let existing = this.state.files[file.id]; 195 | 196 | if (existing) { 197 | toSave[file.id] = existing; 198 | 199 | if (!has(existing, 'data')) { 200 | toSave[file.id].data = file; 201 | } 202 | } else { 203 | toSave[file.id] = { 204 | credit: '', 205 | caption: '', 206 | id: file.id, 207 | url: file.url, 208 | data: file, 209 | }; 210 | } 211 | }); 212 | 213 | this.setState({ files: toSave }); 214 | }); 215 | } 216 | }); 217 | 218 | // Start observing the target node for configured mutations 219 | // with pptions for the observer (which mutations to observe) 220 | this.mutationObserver.observe(targetNode, { 221 | childList: true, 222 | subtree: true, 223 | }); 224 | } 225 | 226 | entwineField() { 227 | if (this.mounted || this.state.loading) { 228 | return false; 229 | } 230 | 231 | jQuery.entwine.triggerMatching(); 232 | this.mounted = true; 233 | } 234 | 235 | render() { 236 | if (this.state.loading) { 237 | return ( 238 |
Loading 
239 | ); 240 | } 241 | 242 | if (this.state.error) { 243 | return ( 244 |
Error loading file data
245 | ); 246 | } 247 | 248 | const { instanceId, files } = this.state; 249 | const { isSelected, multiFiles } = this.props; 250 | 251 | return ( 252 |
256 |
262 |
263 |
264 | 265 | 274 |
275 |
276 |
277 | ); 278 | } 279 | 280 | getStateData() { 281 | const { instanceId } = this.state; 282 | 283 | const state = { 284 | name: `DynamicImage${instanceId}[File]`, 285 | id: `Form_EditForm_DynamicImage${instanceId}_File`, 286 | value: { 287 | Files: [] 288 | }, 289 | message: null, 290 | data: { 291 | files: [] 292 | }, 293 | } 294 | 295 | const fileKeys = keys(this.state.files).map(file => parseInt(file, 10)); 296 | const fileData = map(this.state.files, file => file.data); 297 | 298 | if (fileKeys.length) { 299 | state.value.Files = fileKeys; 300 | state.data.files = fileData; 301 | } 302 | 303 | return state; 304 | } 305 | 306 | getSchemaData() { 307 | const { instanceId } = this.state; 308 | 309 | return { 310 | name: `DynamicImage${instanceId}[File]`, 311 | id: `Form_EditForm_DynamicImage${instanceId}_File`, 312 | type: "file", 313 | schemaType: "Custom", 314 | component: "UploadField", 315 | holderId: `Form_EditForm_DynamicImage${instanceId}_File_Holder`, 316 | title: "File", 317 | source: null, 318 | extraClass: "entwine-uploadfield uploadfield", 319 | description: null, 320 | rightTitle: null, 321 | leftTitle: null, 322 | readOnly: false, 323 | disabled: false, 324 | customValidationMessage: '', 325 | validation: [], 326 | attributes: [], 327 | autoFocus: false, 328 | data: { 329 | createFileEndpoint: { 330 | url: `admin\/pages\/edit\/EditForm\/1\/field\/DynamicImage${instanceId}[File]\/upload`, 331 | method: "post", 332 | payloadFormat: "urlencoded" 333 | }, 334 | maxFiles: this.props.multiFiles ? 50 : 1, 335 | multi: true, 336 | parentid: 1, 337 | canUpload: true, 338 | canAttach: true 339 | } 340 | }; 341 | } 342 | } 343 | 344 | ImageControl.defaultProps = { 345 | multiFiles: true, 346 | value: [], 347 | }; 348 | 349 | export default withInstanceId(ImageControl); 350 | -------------------------------------------------------------------------------- /client/src/code-editor-config.js: -------------------------------------------------------------------------------- 1 | window._wpGutenbergCodeEditorSettings = { 2 | "codemirror": { 3 | "indentUnit": 4, "indentWithTabs": true, "inputStyle": "contenteditable", "lineNumbers": true, "lineWrapping": true, "styleActiveLine": true, "continueComments": true, "extraKeys": { 4 | "Ctrl-Space": "autocomplete", "Ctrl-/": "toggleComment", "Cmd-/": "toggleComment", "Alt-F": "findPersistent", "Ctrl-F": "findPersistent", "Cmd-F": "findPersistent" 5 | }, "direction": "ltr", "gutters": [ 6 | "CodeMirror-lint-markers" 7 | ], "mode": "htmlmixed", "lint": true, "autoCloseBrackets": true, "autoCloseTags": true, "matchTags": { 8 | "bothTags": true 9 | } 10 | }, 11 | "csslint": { 12 | "errors": true, "box-model": true, "display-property-grouping": true, "duplicate-properties": true, "known-properties": true, "outline-none": true 13 | }, 14 | "jshint": { 15 | "boss": true, "curly": true, "eqeqeq": true, "eqnull": true, "es3": true, "expr": true, "immed": true, "noarg": true, "nonbsp": true, "onevar": true, "quotmark": "single", "trailing": true, "undef": true, "unused": true, "browser": true, "globals": { 16 | "_": false, 17 | "Backbone": false, 18 | "jQuery": false, 19 | "JSON": false, 20 | "wp": false 21 | } 22 | }, 23 | "htmlhint": { 24 | "tagname-lowercase": true, "attr-lowercase": true, "attr-value-double-quotes": false, "doctype-first": false, "tag-pair": true, "spec-char-escape": true, "id-unique": true, "src-not-empty": true, "attr-no-duplication": true, "alt-require": true, "space-tab-mixed-disabled": "tab", "attr-unsafe-chars": true, "kses": { 25 | "address": { 26 | "class": true, "id": true, "style": true, "title": true, "role": true 27 | }, 28 | "a": { 29 | "href": true, "rel": true, "rev": true, "name": true, "target": true, "class": true, "id": true, "style": true, "title": true, "role": true 30 | }, 31 | "abbr": { 32 | "class": true, "id": true, "style": true, "title": true, "role": true 33 | }, 34 | "acronym": { 35 | "class": true, "id": true, "style": true, "title": true, "role": true 36 | }, 37 | "area": { 38 | "alt": true, "coords": true, "href": true, "nohref": true, "shape": true, "target": true, "class": true, "id": true, "style": true, "title": true, "role": true 39 | }, 40 | "article": { 41 | "align": true, "dir": true, "lang": true, "xml:lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 42 | }, 43 | "aside": { 44 | "align": true, "dir": true, "lang": true, "xml:lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 45 | }, 46 | "audio": { 47 | "autoplay": true, "controls": true, "loop": true, "muted": true, "preload": true, "src": true, "class": true, "id": true, "style": true, "title": true, "role": true 48 | }, 49 | "b": { 50 | "class": true, "id": true, "style": true, "title": true, "role": true 51 | }, 52 | "bdo": { 53 | "dir": true, "class": true, "id": true, "style": true, "title": true, "role": true 54 | }, 55 | "big": { 56 | "class": true, "id": true, "style": true, "title": true, "role": true 57 | }, 58 | "blockquote": { 59 | "cite": true, "lang": true, "xml:lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 60 | }, 61 | "br": { 62 | "class": true, "id": true, "style": true, "title": true, "role": true 63 | }, 64 | "button": { 65 | "disabled": true, "name": true, "type": true, "value": true, "class": true, "id": true, "style": true, "title": true, "role": true 66 | }, 67 | "caption": { 68 | "align": true, "class": true, "id": true, "style": true, "title": true, "role": true 69 | }, 70 | "cite": { 71 | "dir": true, "lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 72 | }, 73 | "code": { 74 | "class": true, "id": true, "style": true, "title": true, "role": true 75 | }, 76 | "col": { 77 | "align": true, "char": true, "charoff": true, "span": true, "dir": true, "valign": true, "width": true, "class": true, "id": true, "style": true, "title": true, "role": true 78 | }, 79 | "colgroup": { 80 | "align": true, "char": true, "charoff": true, "span": true, "valign": true, "width": true, "class": true, "id": true, "style": true, "title": true, "role": true 81 | }, 82 | "del": { 83 | "datetime": true, "class": true, "id": true, "style": true, "title": true, "role": true 84 | }, 85 | "dd": { 86 | "class": true, "id": true, "style": true, "title": true, "role": true 87 | }, 88 | "dfn": { 89 | "class": true, "id": true, "style": true, "title": true, "role": true 90 | }, 91 | "details": { 92 | "align": true, "dir": true, "lang": true, "open": true, "xml:lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 93 | }, 94 | "div": { 95 | "align": true, "dir": true, "lang": true, "xml:lang": true, "class": [], "id": true, "style": true, "title": true, "role": true, "itemscope": [], "itemtype": [] 96 | }, 97 | "dl": { 98 | "class": true, "id": true, "style": true, "title": true, "role": true 99 | }, 100 | "dt": { 101 | "class": true, "id": true, "style": true, "title": true, "role": true 102 | }, 103 | "em": { 104 | "class": true, "id": true, "style": true, "title": true, "role": true 105 | }, 106 | "fieldset": { 107 | "class": true, "id": true, "style": true, "title": true, "role": true 108 | }, 109 | "figure": { 110 | "align": true, "dir": true, "lang": true, "xml:lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 111 | }, 112 | "figcaption": { 113 | "align": true, "dir": true, "lang": true, "xml:lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 114 | }, 115 | "font": { 116 | "color": true, "face": true, "size": true, "class": true, "id": true, "style": true, "title": true, "role": true 117 | }, 118 | "footer": { 119 | "align": true, "dir": true, "lang": true, "xml:lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 120 | }, 121 | "form": { 122 | "action": true, "accept": true, "accept-charset": true, "enctype": true, "method": true, "name": true, "target": true, "class": true, "id": true, "style": true, "title": true, "role": true 123 | }, 124 | "h1": { 125 | "align": true, "class": true, "id": true, "style": true, "title": true, "role": true 126 | }, 127 | "h2": { 128 | "align": true, "class": true, "id": true, "style": true, "title": true, "role": true 129 | }, 130 | "h3": { 131 | "align": true, "class": [], "id": true, "style": true, "title": true, "role": true, "itemprop": [], "datetime": [] 132 | }, 133 | "h4": { 134 | "align": true, "class": true, "id": true, "style": true, "title": true, "role": true 135 | }, 136 | "h5": { 137 | "align": true, "class": true, "id": true, "style": true, "title": true, "role": true 138 | }, 139 | "h6": { 140 | "align": true, "class": true, "id": true, "style": true, "title": true, "role": true 141 | }, 142 | "header": { 143 | "align": true, "dir": true, "lang": true, "xml:lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 144 | }, 145 | "hgroup": { 146 | "align": true, "dir": true, "lang": true, "xml:lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 147 | }, 148 | "hr": { 149 | "align": true, "noshade": true, "size": true, "width": true, "class": true, "id": true, "style": true, "title": true, "role": true 150 | }, 151 | "i": { 152 | "class": true, "id": true, "style": true, "title": true, "role": true 153 | }, 154 | "img": { 155 | "alt": true, "align": true, "border": true, "height": true, "hspace": true, "longdesc": true, "vspace": true, "src": true, "usemap": true, "width": true, "class": [], "id": true, "style": true, "title": true, "role": true, "itemprop": [], "datetime": [] 156 | }, 157 | "ins": { 158 | "datetime": true, "cite": true, "class": true, "id": true, "style": true, "title": true, "role": true 159 | }, 160 | "kbd": { 161 | "class": true, "id": true, "style": true, "title": true, "role": true 162 | }, 163 | "label": { 164 | "for": true, "class": true, "id": true, "style": true, "title": true, "role": true 165 | }, 166 | "legend": { 167 | "align": true, "class": true, "id": true, "style": true, "title": true, "role": true 168 | }, 169 | "li": { 170 | "align": true, "value": true, "class": [], "id": true, "style": true, "title": true, "role": true, "itemprop": [], "datetime": [] 171 | }, 172 | "map": { 173 | "name": true, "class": true, "id": true, "style": true, "title": true, "role": true 174 | }, 175 | "mark": { 176 | "class": true, "id": true, "style": true, "title": true, "role": true 177 | }, 178 | "menu": { 179 | "type": true, "class": true, "id": true, "style": true, "title": true, "role": true 180 | }, 181 | "nav": { 182 | "align": true, "dir": true, "lang": true, "xml:lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 183 | }, 184 | "p": { 185 | "align": true, "dir": true, "lang": true, "xml:lang": true, "class": [], "id": true, "style": true, "title": true, "role": true, "itemprop": [], "datetime": [] 186 | }, 187 | "pre": { 188 | "width": true, "class": true, "id": true, "style": true, "title": true, "role": true 189 | }, 190 | "q": { 191 | "cite": true, "class": true, "id": true, "style": true, "title": true, "role": true 192 | }, 193 | "s": { 194 | "class": true, "id": true, "style": true, "title": true, "role": true 195 | }, 196 | "samp": { 197 | "class": true, "id": true, "style": true, "title": true, "role": true 198 | }, 199 | "span": { 200 | "dir": true, "align": true, "lang": true, "xml:lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 201 | }, 202 | "section": { 203 | "align": true, "dir": true, "lang": true, "xml:lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 204 | }, 205 | "small": { 206 | "class": true, "id": true, "style": true, "title": true, "role": true 207 | }, 208 | "strike": { 209 | "class": true, "id": true, "style": true, "title": true, "role": true 210 | }, 211 | "strong": { 212 | "class": true, "id": true, "style": true, "title": true, "role": true 213 | }, 214 | "sub": { 215 | "class": true, "id": true, "style": true, "title": true, "role": true 216 | }, 217 | "summary": { 218 | "align": true, "dir": true, "lang": true, "xml:lang": true, "class": true, "id": true, "style": true, "title": true, "role": true 219 | }, 220 | "sup": { 221 | "class": true, "id": true, "style": true, "title": true, "role": true 222 | }, 223 | "table": { 224 | "align": true, "bgcolor": true, "border": true, "cellpadding": true, "cellspacing": true, "dir": true, "rules": true, "summary": true, "width": true, "class": true, "id": true, "style": true, "title": true, "role": true 225 | }, 226 | "tbody": { 227 | "align": true, "char": true, "charoff": true, "valign": true, "class": true, "id": true, "style": true, "title": true, "role": true 228 | }, 229 | "td": { 230 | "abbr": true, "align": true, "axis": true, "bgcolor": true, "char": true, "charoff": true, "colspan": true, "dir": true, "headers": true, "height": true, "nowrap": true, "rowspan": true, "scope": true, "valign": true, "width": true, "class": true, "id": true, "style": true, "title": true, "role": true 231 | }, 232 | "textarea": { 233 | "cols": true, "rows": true, "disabled": true, "name": true, "readonly": true, "class": true, "id": true, "style": true, "title": true, "role": true 234 | }, 235 | "tfoot": { 236 | "align": true, "char": true, "charoff": true, "valign": true, "class": true, "id": true, "style": true, "title": true, "role": true 237 | }, 238 | "th": { 239 | "abbr": true, "align": true, "axis": true, "bgcolor": true, "char": true, "charoff": true, "colspan": true, "headers": true, "height": true, "nowrap": true, "rowspan": true, "scope": true, "valign": true, "width": true, "class": true, "id": true, "style": true, "title": true, "role": true 240 | }, 241 | "thead": { 242 | "align": true, "char": true, "charoff": true, "valign": true, "class": true, "id": true, "style": true, "title": true, "role": true 243 | }, 244 | "title": { 245 | "class": true, "id": true, "style": true, "title": true, "role": true 246 | }, 247 | "tr": { 248 | "align": true, "bgcolor": true, "char": true, "charoff": true, "valign": true, "class": true, "id": true, "style": true, "title": true, "role": true 249 | }, 250 | "track": { 251 | "default": true, "kind": true, "label": true, "src": true, "srclang": true, "class": true, "id": true, "style": true, "title": true, "role": true 252 | }, 253 | "tt": { 254 | "class": true, "id": true, "style": true, "title": true, "role": true 255 | }, 256 | "u": { 257 | "class": true, "id": true, "style": true, "title": true, "role": true 258 | }, 259 | "ul": { 260 | "type": true, "class": [], "id": true, "style": true, "title": true, "role": true, "itemprop": [], "datetime": [] 261 | }, 262 | "ol": { 263 | "start": true, "type": true, "reversed": true, "class": [], "id": true, "style": true, "title": true, "role": true, "itemprop": [], "datetime": [] 264 | }, 265 | "var": { 266 | "class": true, "id": true, "style": true, "title": true, "role": true 267 | }, 268 | "video": { 269 | "autoplay": true, "controls": true, "height": true, "loop": true, "muted": true, "poster": true, "preload": true, "src": true, "width": true, "class": true, "id": true, "style": true, "title": true, "role": true 270 | }, 271 | "time": { 272 | "class": [], "itemprop": [], "datetime": [] 273 | } 274 | } 275 | } 276 | }; 277 | -------------------------------------------------------------------------------- /setup.php: -------------------------------------------------------------------------------- 1 | =5.6.0 7 | // * 8 | // * For full copyright and license information, please view the 9 | // * LICENSE.md file that was distributed with this source code. 10 | // * 11 | // * @author Colin Tucker 12 | // * @copyright 2017 Praxis Interactive 13 | // * @license https://opensource.org/licenses/BSD-3-Clause BSD-3-Clause 14 | // * @link https://github.com/praxisnetau/silverstripe-module-starter 15 | // */ 16 | // 17 | // use Exception; 18 | // 19 | // /** 20 | // * Singleton class which handles the setup of the module. 21 | // * 22 | // * @author Colin Tucker 23 | // * @copyright 2017 Praxis Interactive 24 | // * @license https://opensource.org/licenses/BSD-3-Clause BSD-3-Clause 25 | // * @link https://github.com/praxisnetau/silverstripe-module-starter 26 | // */ 27 | // class Setup 28 | // { 29 | // /** 30 | // * Name of configuration file. 31 | // * 32 | // * @var string 33 | // * @const 34 | // */ 35 | // const CONFIG_FILE = 'setup.json'; 36 | // 37 | // /** 38 | // * Default configuration settings. 39 | // * 40 | // * Create a configuration file (setup.json) in the root folder to customise. 41 | // * 42 | // * @var array 43 | // */ 44 | // private static $default_config = [ 45 | // 'default-vendor' => 'vendor', 46 | // 'default-module' => 'module', 47 | // 'default-repo-name' => '{vendor}/{module}', 48 | // 'default-repo-url' => 'https://github.com/{vendor}/{module}', 49 | // 'default-module-name' => 'My SilverStripe Module', 50 | // 'default-namespace' => 'Vendor\\Module', 51 | // 'default-author' => 'My Name', 52 | // 'default-email' => 'name@example.com', 53 | // 'default-organisation' => 'My Organisation', 54 | // 'default-homepage' => 'https://www.example.com', 55 | // 'resource-path' => '/resources/{vendor}/{module}', 56 | // 'files' => [ 57 | // '_config.php', 58 | // '_config/config.yml', 59 | // 'admin/client/src/bundles/bundle.js', 60 | // 'admin/client/src/styles/_variables.scss', 61 | // 'admin/client/src/styles/bundle.scss', 62 | // 'client/src/bundles/bundle.js', 63 | // 'client/src/styles/_variables.scss', 64 | // 'client/src/styles/bundle.scss', 65 | // 'composer.json', 66 | // 'package.json', 67 | // 'webpack.config.js' 68 | // ] 69 | // ]; 70 | // 71 | // /** 72 | // * Holds the singleton instance. 73 | // * 74 | // * @var Setup 75 | // */ 76 | // private static $instance; 77 | // 78 | // /** 79 | // * Configuration settings. 80 | // * 81 | // * @var array 82 | // */ 83 | // private $config = []; 84 | // 85 | // /** 86 | // * Setup data. 87 | // * 88 | // * @var array 89 | // */ 90 | // private $data = []; 91 | // 92 | // /** 93 | // * Answers the singleton instance. 94 | // * 95 | // * @return Setup 96 | // */ 97 | // public static function inst() 98 | // { 99 | // if (!self::$instance) { 100 | // self::$instance = new Setup(); 101 | // } 102 | // 103 | // return self::$instance; 104 | // } 105 | // 106 | // /** 107 | // * Executes the setup routine. 108 | // * 109 | // * @return void 110 | // */ 111 | // public function run() 112 | // { 113 | // try { 114 | // 115 | // $this->checkFiles(); 116 | // 117 | // $this->gatherData(); 118 | // 119 | // $this->writeFiles(); 120 | // 121 | // } catch (Exception $e) { 122 | // 123 | // $this->outputError($e->getMessage()); 124 | // 125 | // } 126 | // 127 | // $this->output('Setup complete!'); 128 | // } 129 | // 130 | // /** 131 | // * Defines either the named config value, or the config array. 132 | // * 133 | // * @param string|array $arg1 134 | // * @param string $arg2 135 | // * 136 | // * @return $this 137 | // */ 138 | // public function setConfig($arg1, $arg2 = null) 139 | // { 140 | // if (is_array($arg1)) { 141 | // $this->config = $arg1; 142 | // } else { 143 | // $this->config[$arg1] = $arg2; 144 | // } 145 | // 146 | // return $this; 147 | // } 148 | // 149 | // /** 150 | // * Answers either the named config value, or the config array. 151 | // * 152 | // * @param string $name 153 | // * 154 | // * @return mixed 155 | // */ 156 | // public function getConfig($name = null) 157 | // { 158 | // if (!is_null($name)) { 159 | // return isset($this->config[$name]) ? $this->config[$name] : null; 160 | // } 161 | // 162 | // return $this->config; 163 | // } 164 | // 165 | // /** 166 | // * Loads configuration from the specified file. 167 | // * 168 | // * @return void 169 | // */ 170 | // public function loadConfig($file = self::CONFIG_FILE) 171 | // { 172 | // if (is_readable($file)) { 173 | // 174 | // $this->output('Loading config file...'); 175 | // 176 | // $json = file_get_contents($file); 177 | // 178 | // $config = json_decode($json, true); 179 | // 180 | // if (is_array($config)) { 181 | // $this->mergeConfig($config); 182 | // } 183 | // 184 | // } 185 | // } 186 | // 187 | // /** 188 | // * Merges the given array of configuration with the existing configuration. 189 | // * 190 | // * @param array $config 191 | // * 192 | // * @return $this 193 | // */ 194 | // public function mergeConfig($config = []) 195 | // { 196 | // $this->config = array_merge($this->config, $config); 197 | // 198 | // return $this; 199 | // } 200 | // 201 | // /** 202 | // * Checks that the files are all valid. 203 | // * 204 | // * @throws Exception 205 | // * 206 | // * @return void 207 | // */ 208 | // private function checkFiles() 209 | // { 210 | // $this->output('Checking files...'); 211 | // 212 | // foreach ($this->getConfig('files') as $path) { 213 | // 214 | // if (!is_readable($path)) { 215 | // throw new Exception(sprintf('file "%s" is not readable', $path)); 216 | // } 217 | // 218 | // if (!is_writable($path)) { 219 | // throw new Exception(sprintf('file "%s" is not writable', $path)); 220 | // } 221 | // 222 | // } 223 | // 224 | // $this->output('Files OK!'); 225 | // } 226 | // 227 | // /** 228 | // * Gathers data from the developer. 229 | // * 230 | // * @return void 231 | // */ 232 | // private function gatherData() 233 | // { 234 | // $this->output('Gathering setup data...'); 235 | // 236 | // $this->ask('vendor', 'Vendor', $this->getConfig('default-vendor')); 237 | // $this->ask('module', 'Module', $this->getConfig('default-module')); 238 | // $this->ask('repo-name', 'Repository name', $this->getConfig('default-repo-name')); 239 | // $this->ask('repo-url', 'Repository URL', $this->getConfig('default-repo-url')); 240 | // $this->ask('module-name', 'Module name', $this->getConfig('default-module-name')); 241 | // $this->ask('namespace', 'Namespace (PSR-4)', $this->getConfig('default-namespace')); 242 | // $this->ask('description', 'Description', $this->getConfig('default-description')); 243 | // $this->ask('author', 'Author', $this->getConfig('default-author')); 244 | // $this->ask('email', 'Email', $this->getConfig('default-email')); 245 | // $this->ask('organisation', 'Organisation', $this->getConfig('default-organisation')); 246 | // $this->ask('homepage', 'Homepage', $this->getConfig('default-homepage')); 247 | // $this->ask('keywords', 'Keywords (comma-separated)', $this->getConfig('default-keywords')); 248 | // 249 | // $this->cleanNamespace(); 250 | // 251 | // $this->processData(); 252 | // } 253 | // 254 | // /** 255 | // * Processes any special-case data values before writing to files. 256 | // * 257 | // * @return void 258 | // */ 259 | // private function processData() 260 | // { 261 | // $this->data['year'] = date('Y'); 262 | // 263 | // $this->data['keywords-json'] = $this->getKeywordsJSON(); 264 | // 265 | // $this->data['namespace-psr4'] = $this->getNamespacePSR4(); 266 | // 267 | // if ($this->data['description']) { 268 | // $this->data['description'] = rtrim($this->data['description'], '.') . '.'; 269 | // } 270 | // } 271 | // 272 | // /** 273 | // * Answers a JSON string containing the module keywords. 274 | // * 275 | // * @return string 276 | // */ 277 | // private function getKeywordsJSON() 278 | // { 279 | // $keywords = []; 280 | // 281 | // if ($this->data['keywords']) { 282 | // $keywords = preg_split('/[, ]+/', $this->data['keywords'], -1, PREG_SPLIT_NO_EMPTY); 283 | // } 284 | // 285 | // return json_encode($keywords); 286 | // } 287 | // 288 | // /** 289 | // * Answers a PSR-4 compatible version of the module namespace for the composer.json file. 290 | // * 291 | // * @return string 292 | // */ 293 | // private function getNamespacePSR4() 294 | // { 295 | // return str_replace('\\', '\\\\', $this->data['namespace']) . '\\\\'; 296 | // } 297 | // 298 | // /** 299 | // * Cleans up the entered namespace value. 300 | // * 301 | // * @return void 302 | // */ 303 | // private function cleanNamespace() 304 | // { 305 | // $this->data['namespace'] = trim(preg_replace("/\\{2,}/", "\\", $this->data['namespace']), '/\\'); 306 | // } 307 | // 308 | // /** 309 | // * Writes the setup data to the files. 310 | // * 311 | // * @throws Exception 312 | // * 313 | // * @return void 314 | // */ 315 | // private function writeFiles() 316 | // { 317 | // $this->output('Writing files...'); 318 | // 319 | // foreach ($this->getConfig('files') as $path) { 320 | // 321 | // // Output Status: 322 | // 323 | // $this->output(sprintf('Writing to "%s"...', $path)); 324 | // 325 | // // Read File Contents: 326 | // 327 | // $contents = file_get_contents($path); 328 | // 329 | // // Replace Tokens in Contents: 330 | // 331 | // $contents = $this->replace($contents); 332 | // 333 | // // Write File Contents: 334 | // 335 | // file_put_contents($path, $contents); 336 | // 337 | // } 338 | // } 339 | // 340 | // /** 341 | // * Asks a question to the developer. 342 | // * 343 | // * @param string $key 344 | // * @param string $question 345 | // * @param string $default 346 | // * 347 | // * @return void 348 | // */ 349 | // private function ask($key, $question, $default = null) 350 | // { 351 | // echo $question; 352 | // 353 | // $default = $this->replace($default); 354 | // 355 | // if ($default) { 356 | // echo sprintf(' (default "%s")', $default); 357 | // } 358 | // 359 | // echo ": "; 360 | // 361 | // $handle = fopen('php://stdin', 'r'); 362 | // 363 | // $value = trim(fgets($handle)); 364 | // 365 | // $this->data[$key] = $value ? $value : $default; 366 | // 367 | // fclose($handle); 368 | // } 369 | // 370 | // /** 371 | // * Outputs the given text to the console. 372 | // * 373 | // * @param string $text 374 | // * 375 | // * @return void 376 | // */ 377 | // private function output($text) 378 | // { 379 | // echo "> " . $text . "\n"; 380 | // } 381 | // 382 | // /** 383 | // * Outputs the given error text to the console. 384 | // * 385 | // * @param string $text 386 | // * 387 | // * @return void 388 | // */ 389 | // private function outputError($text) 390 | // { 391 | // echo "ERROR: {$text}\n"; 392 | // } 393 | // 394 | // /** 395 | // * Outputs the given header text to the console. 396 | // * 397 | // * @param string $text 398 | // * 399 | // * @return void 400 | // */ 401 | // private function outputHeader($text) 402 | // { 403 | // $this->outputLine(); 404 | // 405 | // echo strtoupper($text) . "\n"; 406 | // 407 | // $this->outputLine(); 408 | // } 409 | // 410 | // /** 411 | // * Outputs a line consisting with the given character and length to the console. 412 | // * 413 | // * @param string $char 414 | // * 415 | // * @return void 416 | // */ 417 | // private function outputLine($char = '=', $length = 80) 418 | // { 419 | // echo str_pad('', $length, $char) . "\n"; 420 | // } 421 | // 422 | // /** 423 | // * Replaces named tokens within the given text with values from the data and configuration. 424 | // * 425 | // * @param string $text 426 | // * 427 | // * @return string 428 | // */ 429 | // private function replace($text) 430 | // { 431 | // // Replace Config Tokens: 432 | // 433 | // foreach ($this->config as $key => $value) { 434 | // 435 | // if (is_scalar($value)) { 436 | // $text = str_ireplace("{{$key}}", $value, $text); 437 | // } 438 | // 439 | // } 440 | // 441 | // // Replace Data Tokens: 442 | // 443 | // foreach ($this->data as $key => $value) { 444 | // 445 | // if (is_scalar($value)) { 446 | // $text = str_ireplace("{{$key}}", $value, $text); 447 | // } 448 | // 449 | // } 450 | // 451 | // // Answer Text: 452 | // 453 | // return $text; 454 | // } 455 | // 456 | // /** 457 | // * Constructs the object upon instantiation (private, in accordance with singleton pattern). 458 | // */ 459 | // private function __construct() 460 | // { 461 | // $this->init(); 462 | // } 463 | // 464 | // /** 465 | // * Initialises the object from configuration. 466 | // * 467 | // * @return void 468 | // */ 469 | // private function init() 470 | // { 471 | // // Output Header String: 472 | // 473 | // $this->outputHeader('SilverStripe Module Setup'); 474 | // 475 | // // Define Default Config: 476 | // 477 | // $this->setConfig(self::$default_config); 478 | // 479 | // // Load Config File: 480 | // 481 | // $this->loadConfig(self::CONFIG_FILE); 482 | // } 483 | // } 484 | // 485 | // // Perform Setup: 486 | // 487 | // Setup::inst()->run(); 488 | --------------------------------------------------------------------------------