From 47bcf9a125d5ece14f103a72c20300833d11cdd3 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 2 Sep 2026 18:56:07 +0100 Subject: [PATCH 1/3] Correct imports for WP packages --- .config/webpack.config.dev.js | 62 ++++++++++++++++++++++------------ .config/webpack.config.prod.js | 16 +++++++++ 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/.config/webpack.config.dev.js b/.config/webpack.config.dev.js index fcb22c44..dc2e2974 100644 --- a/.config/webpack.config.dev.js +++ b/.config/webpack.config.dev.js @@ -11,6 +11,12 @@ addFilter( 'presets/stylesheet-loaders', ( rule ) => { return { test: /\.s?css$/, oneOf: [ + { resourceQuery: /raw/, type: 'asset/source' }, + { + resourceQuery: /inline/, + type: 'asset/source', + use: rule.use.slice( 2 ), // postcss-loader + sass-loader + }, { test: /\.css$/, use: useWithoutSass }, { test: /\.scss$/, use: rule.use }, ], @@ -52,25 +58,39 @@ cleanOnExit( [ filePath( 'build/development-asset-manifest.json' ), ] ); -module.exports = choosePort( 9090 ).then( port => presets.development( { - devServer: { - host: 'localhost', - port, - }, - entry: { - h2: filePath( 'src/index.js' ), - // Editor styles require the production build. - }, - output: { - filename: '[name].[hash].js', - chunkFilename: '[name].chunk.[hash].js', - }, - resolve: { - alias: { - 'juniper-images': filePath( 'src/assets/images' ), +module.exports = choosePort( 9090 ).then( port => { + const config = presets.development( { + devServer: { + host: 'localhost', + port, }, - }, - plugins: [ - new WatchContentFilesPlugin(), - ], -} ) ); + entry: { + h2: filePath( 'src/index.js' ), + // Editor styles require the production build. + }, + output: { + filename: '[name].[hash].js', + chunkFilename: '[name].chunk.[hash].js', + }, + resolve: { + alias: { + 'juniper-images': filePath( 'src/assets/images' ), + + // format-library's package exports omit its stylesheet. + '@wordpress/format-library/build-style': filePath( 'node_modules/@wordpress/format-library/build-style' ), + }, + }, + plugins: [ + new WatchContentFilesPlugin(), + ], + } ); + + // Allow non-fully-specified imports from packages like `diff` used by + // @wordpress/block-editor (webpack 5 strict ESM compatibility). + config.module.rules.unshift( { + test: /\.m?js/, + resolve: { fullySpecified: false }, + } ); + + return config; +} ); diff --git a/.config/webpack.config.prod.js b/.config/webpack.config.prod.js index 2ec45d73..72469fc4 100644 --- a/.config/webpack.config.prod.js +++ b/.config/webpack.config.prod.js @@ -12,6 +12,12 @@ addFilter( 'presets/stylesheet-loaders', ( rule ) => { return { test: /\.s?css$/, oneOf: [ + { resourceQuery: /raw/, type: 'asset/source' }, + { + resourceQuery: /inline/, + type: 'asset/source', + use: rule.use.slice( 2 ), // postcss-loader + sass-loader + }, { test: /\.css$/, use: useWithoutSass }, { test: /\.scss$/, use: rule.use }, ], @@ -72,6 +78,9 @@ module.exports = presets.production( { resolve: { alias: { 'juniper-images': filePath( 'src/assets/images' ), + + // format-library's package exports omit its stylesheet. + '@wordpress/format-library/build-style': filePath( 'node_modules/@wordpress/format-library/build-style' ), }, }, cache: { @@ -86,3 +95,10 @@ module.exports = presets.production( { module.exports.optimization.minimizer = module.exports.optimization.minimizer.filter( minimizer => { return minimizer.constructor.name !== 'CssMinimizerPlugin'; } ); + +// Allow non-fully-specified imports from packages like `diff` used by +// @wordpress/block-editor (webpack 5 strict ESM compatibility). +module.exports.module.rules.unshift( { + test: /\.m?js/, + resolve: { fullySpecified: false }, +} ); From 98a38e83820ba4054bed0e66b0e3731b0ac855fd Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 2 Sep 2026 18:58:29 +0100 Subject: [PATCH 2/3] Add Gutenberg editor for posts --- package.json | 22 +- src/App.css | 27 ++ src/App.js | 4 +- src/components/Editor/BlockEditor.css | 47 ++ src/components/Editor/BlockEditor.js | 572 +++++++++++++++++++++++++ src/components/Editor/BlockEditor.scss | 38 ++ src/components/Editor/LazyEditor.js | 2 + src/components/Editor/index.js | 18 +- src/components/Post/BlockEditor.js | 384 +++++++++++++++++ src/components/Post/LazyBlockEditor.js | 20 + src/components/Post/SelectDraft.js | 12 +- src/components/Post/Write.js | 109 ++++- src/index.js | 10 +- src/util.js | 10 + 14 files changed, 1245 insertions(+), 30 deletions(-) create mode 100644 src/components/Editor/BlockEditor.css create mode 100644 src/components/Editor/BlockEditor.js create mode 100644 src/components/Editor/BlockEditor.scss create mode 100644 src/components/Post/BlockEditor.js create mode 100644 src/components/Post/LazyBlockEditor.js diff --git a/package.json b/package.json index ba95a7be..090e3929 100644 --- a/package.json +++ b/package.json @@ -38,11 +38,21 @@ "webpack-dev-server": "^4.9.0" }, "dependencies": { + "@babel/runtime": "^7.29.2", "@humanmade/react-tasklist": "^0.0.4", "@humanmade/repress": "^0.5.0", "@iarna/word-count": "^1.0.3", "@sentry/browser": "^5.9.1", "@storybook/addon-actions": "^5.2.0", + "@wordpress/api-fetch": "^7.44.0", + "@wordpress/block-editor": "^15.17.0", + "@wordpress/block-library": "^9.44.0", + "@wordpress/blocks": "^15.17.0", + "@wordpress/components": "^32.6.0", + "@wordpress/compose": "^7.44.0", + "@wordpress/data": "^10.44.0", + "@wordpress/format-library": "5.44.0", + "@wordpress/icons": "^12.2.0", "emoji-mart": "^2.11.1", "interweave": "^11.1.0", "lodash": "^4.17.19", @@ -50,9 +60,10 @@ "mousetrap": "^1.6.2", "postcss": "^8.4.14", "prop-types": "^15.7.2", - "react": "^16.9.0", + "qs": "^6.15.0", + "react": "^18.3.1", "react-content-loader": "^2.0.2", - "react-dom": "^16.9.0", + "react-dom": "^18.3.1", "react-intl": "^2.4.0", "react-redux": "^5.0.6", "react-router-dom": "^4.3.1", @@ -88,5 +99,10 @@ { "path": "./build/h2.*.js" } - ] + ], + "pnpm": { + "overrides": { + "@wordpress/private-apis": "1.44.0" + } + } } diff --git a/src/App.css b/src/App.css index 3b6647da..321dcdb6 100644 --- a/src/App.css +++ b/src/App.css @@ -49,6 +49,33 @@ } } +/* The block editor takes over the main column at full height. */ +.App > .Outer > .Inner > .PostBlockEditor { + height: 100vh; + overflow: hidden; +} + +@media ( min-width: 800px ) { + .App > .Outer > .Inner > .PostBlockEditor { + position: sticky; + top: 0; + } +} + +@media screen and ( min-width: 600px ) and ( max-width: 782px ) { + body.admin-bar .App > .Outer > .Inner > .PostBlockEditor { + top: 46px; + height: calc( 100vh - 46px ); + } +} + +@media ( min-width: 783px ) { + body.admin-bar .App > .Outer > .Inner > .PostBlockEditor { + top: 32px; + height: calc( 100vh - 32px ); + } +} + .App a { border: none; } diff --git a/src/App.js b/src/App.js index d436039c..f286c3c7 100644 --- a/src/App.js +++ b/src/App.js @@ -160,9 +160,9 @@ class App extends Component { onShowSuper={ this.props.onShowSuperSidebar } /> -
+
{ this.renderSidebar() } -
+
{ this.state.isShowingWritePost ? ( this.onDidCreatePost( post ) } diff --git a/src/components/Editor/BlockEditor.css b/src/components/Editor/BlockEditor.css new file mode 100644 index 00000000..39c6ce3f --- /dev/null +++ b/src/components/Editor/BlockEditor.css @@ -0,0 +1,47 @@ +/* Editor chrome (header toolbar, sidebar) for the block editor. */ + +/* Document tools: inserter, undo, redo. */ +.BlockEditor-document-tools.components-accessible-toolbar { + display: flex; + align-items: center; + flex-shrink: 0; + height: 100%; + padding: 0 8px; + border: none; + border-right: 1px solid #d9d9d9; + border-radius: 0; +} + +.BlockEditor-document-tools .components-toolbar-group { + display: flex; + align-items: center; + gap: 4px; + padding: 0; + border: none; +} + +/* The block toolbar sits in the header rather than floating over the canvas. */ +.BlockEditor-toolbar .block-editor-block-contextual-toolbar { + position: static; + display: flex; + align-items: center; + flex: 1 1 auto; + width: auto; + min-width: 0; + height: 100%; + margin: 0; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.BlockEditor-toolbar .block-editor-block-toolbar { + align-items: center; + height: 100%; +} + +/* Sidebar panels are already bordered by the sidebar itself. */ +.BlockEditor-sidebar .components-panel { + border: none; +} diff --git a/src/components/Editor/BlockEditor.js b/src/components/Editor/BlockEditor.js new file mode 100644 index 00000000..0532304e --- /dev/null +++ b/src/components/Editor/BlockEditor.js @@ -0,0 +1,572 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { connect } from 'react-redux'; + +import apiFetch from '@wordpress/api-fetch'; +import { + BlockCanvas, + BlockEditorKeyboardShortcuts, + BlockEditorProvider, + BlockInspector, + BlockList, + BlockToolbar, + Inserter, + NavigableToolbar, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import blockEditorContentStyles from '@wordpress/block-editor/build-style/content.css?raw'; +import { registerCoreBlocks } from '@wordpress/block-library'; +import blockLibraryEditorStyles from '@wordpress/block-library/build-style/editor.css?raw'; +import blockLibraryStyles from '@wordpress/block-library/build-style/style.css?raw'; +import { getBlockType, setFreeformContentHandlerName, unregisterBlockType } from '@wordpress/blocks'; +import { + Button, + DropdownMenu, + MenuGroup, + MenuItem, + SlotFillProvider, + ToolbarButton, + ToolbarGroup, +} from '@wordpress/components'; +import componentsStyles from '@wordpress/components/build-style/style.css?raw'; +import { useKeyboardShortcut, useStateWithHistory } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { + check, + drawerRight, + moreVertical, + plus, + redo as redoIcon, + undo as undoIcon, +} from '@wordpress/icons'; + +import api from '../../api'; +import h2EditorStyle from '../../editor-style.scss?inline'; +import { media } from '../../types'; +import { decodeEntities } from '../../util'; + +import canvasStyles from './BlockEditor.scss?inline'; + +import '@wordpress/components/build-style/style.css'; +import '@wordpress/block-editor/build-style/style.css'; +import '@wordpress/format-library'; +import '@wordpress/format-library/build-style/style.css'; +import './BlockEditor.css'; + +/** + * Blocks which are never made available in H2. + * + * The Classic block needs TinyMCE from wp-admin, which isn't loaded here. Add + * further blocks to this list to disable them globally. + */ +const DISABLED_BLOCKS = [ + 'core/freeform', +]; + +/** + * Register the core blocks (once) and apply H2's global tweaks. + */ +function registerBlocks() { + if ( getBlockType( 'core/paragraph' ) ) { + return; + } + + registerCoreBlocks(); + + DISABLED_BLOCKS.forEach( name => { + if ( getBlockType( name ) ) { + unregisterBlockType( name ); + } + } ); + + // Content which isn't wrapped in block delimiters would normally be handed + // to the Classic block; use the Custom HTML block instead. + setFreeformContentHandlerName( 'core/html' ); +} + +/** + * Point Gutenberg's own API client at this site. + * + * Embeds, server-rendered blocks, and other data-driven blocks fetch through + * `@wordpress/api-fetch` rather than H2's API client. + */ +function configureApiFetch() { + if ( window.__h2BlockEditorApiFetch ) { + return; + } + window.__h2BlockEditorApiFetch = true; + + apiFetch.use( apiFetch.createRootURLMiddleware( window.wpApiSettings.root ) ); + apiFetch.use( apiFetch.createNonceMiddleware( window.wpApiSettings.nonce ) ); +} + +registerBlocks(); +configureApiFetch(); + +// Styles injected into the editor canvas (an iframe), in order. +const CANVAS_STYLES = [ + { css: componentsStyles }, + { css: blockEditorContentStyles }, + { css: blockLibraryStyles }, + { css: blockLibraryEditorStyles }, + { css: h2EditorStyle }, + { css: canvasStyles }, +]; + +const DEFAULT_SETTINGS = { + bodyPlaceholder: 'Start writing…', + + // H2 has no theme.json, so don't offer layout controls we can't render. + supportsLayout: false, + + __experimentalFeatures: { + typography: { + dropCap: false, + }, + }, +}; + +const EMPTY_ARRAY = []; +const EMPTY_OBJECT = {}; +const UNDO_KEYS = [ 'mod+z' ]; +const REDO_KEYS = [ 'mod+shift+z', 'ctrl+y' ]; +const PREFERENCE_PREFIX = 'h2-block-editor:'; + +const isAllowedType = ( allowedTypes, file ) => { + if ( ! allowedTypes || ! allowedTypes.length ) { + return true; + } + + return allowedTypes.some( type => ( + type.includes( '/' ) ? type === file.type : file.type.startsWith( `${ type }/` ) + ) ); +}; + +/** + * Convert a REST API attachment into the shape blocks expect from uploads. + * + * @param {object} data Attachment from the REST API. + * @returns {object} Media object. + */ +const transformAttachment = data => ( { + ...data, + alt: data.alt_text, + caption: data.caption ? data.caption.raw : '', + title: data.title ? data.title.raw : '', + url: data.source_url, +} ); + +/** + * Build the `mediaUpload` editor setting on top of H2's media uploads. + * + * @param {Function} upload Uploads a File, resolving with the attachment data. + * @returns {Function} Upload handler for the block editor. + */ +const createMediaUpload = upload => ( { allowedTypes, filesList, onError = () => {}, onFileChange } ) => { + const files = Array.from( filesList ).filter( file => { + if ( isAllowedType( allowedTypes, file ) ) { + return true; + } + + onError( { + code: 'MIME_TYPE_NOT_ALLOWED', + message: `${ file.name }: this file type isn't supported here.`, + file, + } ); + return false; + } ); + + if ( ! files.length ) { + return; + } + + // Show temporary previews while the uploads are in progress. + const results = files.map( file => ( { url: URL.createObjectURL( file ) } ) ); + onFileChange( [ ...results ] ); + + files.forEach( ( file, index ) => { + upload( file ) + .then( data => { + if ( ! data ) { + throw new Error( `Could not upload ${ file.name }.` ); + } + + URL.revokeObjectURL( results[ index ].url ); + results[ index ] = transformAttachment( data ); + onFileChange( results.filter( Boolean ) ); + } ) + .catch( error => { + URL.revokeObjectURL( results[ index ].url ); + results[ index ] = null; + onError( { + code: 'GENERAL', + message: error.message, + file, + } ); + onFileChange( results.filter( Boolean ) ); + } ); + } ); +}; + +/** + * Search the site for content to link to, for the link UI. + * + * @param {string} search Search term. + * @param {object} options Options. + * @param {number} options.perPage Number of results. + * @returns {Promise} Link suggestions. + */ +const fetchLinkSuggestions = ( search, { perPage = 20 } = {} ) => { + return api.get( '/wp/v2/search', { + search, + per_page: perPage, + _fields: 'id,title,type,subtype,url', + } ).then( results => results.map( result => ( { + id: result.id, + kind: 'post-type', + title: decodeEntities( result.title ) || '(no title)', + type: result.subtype || result.type, + url: result.url, + } ) ) ); +}; + +/** + * A boolean editor preference remembered in the browser, like wp-admin's + * per-user editor preferences. + * + * @param {string} name Preference name. + * @param {boolean} defaultValue Value when nothing is stored. + * @returns {Array} Current value and a setter, like `useState`. + */ +function useStoredPreference( name, defaultValue ) { + const key = PREFERENCE_PREFIX + name; + const [ value, setValue ] = useState( () => { + try { + const stored = window.localStorage.getItem( key ); + return stored === null ? defaultValue : JSON.parse( stored ); + } catch ( e ) { + return defaultValue; + } + } ); + + const update = useCallback( next => { + setValue( next ); + try { + window.localStorage.setItem( key, JSON.stringify( next ) ); + } catch ( e ) { + // Storage is unavailable; the choice just won't persist. + } + }, [ key ] ); + + return [ value, update ]; +} + +function HistoryShortcuts( { canRedo, canUndo, onRedo, onUndo } ) { + useKeyboardShortcut( UNDO_KEYS, event => { + event.preventDefault(); + if ( canUndo ) { + onUndo(); + } + }, { bindGlobal: true } ); + useKeyboardShortcut( REDO_KEYS, event => { + event.preventDefault(); + if ( canRedo ) { + onRedo(); + } + }, { bindGlobal: true } ); + + return null; +} + +function DocumentTools( { canRedo, canUndo, onRedo, onUndo } ) { + return ( + + + ( + + ) } + showInserterHelpPanel + /> + + + + + ); +} + +/** + * The "Options" menu, mirroring wp-admin's editor options. + * + * Only the toolbar mode is offered for now; distraction free, spotlight and + * fullscreen modes can slot in alongside it later. + * + * @param {object} props Component props. + * @param {boolean} props.hasFixedToolbar Whether the block toolbar is in the header. + * @param {Function} props.onToggleFixedToolbar Toggles the toolbar mode. + * @returns {React.ReactElement} Menu. + */ +function OptionsMenu( { hasFixedToolbar, onToggleFixedToolbar } ) { + return ( + + { () => ( + + + Top toolbar + + + ) } + + ); +} + +function Sidebar( { panels } ) { + const defaultTab = panels.length ? panels[0].name : 'block'; + const [ activeTab, setActiveTab ] = useState( defaultTab ); + const hasSelectedBlock = useSelect( + select => !! select( blockEditorStore ).getSelectedBlockClientId(), + [] + ); + + // Like Gutenberg, show block settings while a block is selected, and the + // document settings otherwise. + useEffect( () => { + setActiveTab( hasSelectedBlock ? 'block' : defaultTab ); + }, [ defaultTab, hasSelectedBlock ] ); + + const tabs = [ + ...panels, + { + name: 'block', + title: 'Block', + content: , + }, + ]; + const current = tabs.find( tab => tab.name === activeTab ) || tabs[0]; + + return ( + + ); +} + +/** + * A full-height Gutenberg editor: header toolbar, canvas, and settings sidebar. + * + * This is deliberately content-agnostic; post-specific behaviour (title, + * saving, document settings) is layered on by the owner via props. + * + * @param {object} props Component props. + * @returns {React.ReactElement} Editor. + */ +export function BlockEditor( props ) { + const { + actions, + canvasHeader, + children, + initialBlocks = EMPTY_ARRAY, + onChange, + onUpload, + panels = EMPTY_ARRAY, + settings = EMPTY_OBJECT, + } = props; + + const { hasRedo, hasUndo, redo, setValue, undo, value } = useStateWithHistory( { blocks: initialBlocks } ); + const [ isSidebarOpen, setSidebarOpen ] = useState( true ); + + // Toolbar mode: floating over the block (wp-admin's default) or in the header. + const [ hasFixedToolbar, setFixedToolbar ] = useStoredPreference( 'fixed-toolbar', false ); + + // Keep the owner up to date, including after undo/redo. + useEffect( () => { + onChange( value.blocks ); + }, [ onChange, value.blocks ] ); + + const editorSettings = useMemo( () => ( { + ...DEFAULT_SETTINGS, + ...settings, + hasFixedToolbar, + mediaUpload: createMediaUpload( onUpload ), + __experimentalFetchLinkSuggestions: fetchLinkSuggestions, + } ), [ hasFixedToolbar, onUpload, settings ] ); + + const onInput = useCallback( ( blocks, { selection } ) => { + setValue( { + blocks, + selection, + }, true ); + }, [ setValue ] ); + const onPersist = useCallback( ( blocks, { selection } ) => { + setValue( { + blocks, + selection, + }, false ); + }, [ setValue ] ); + + return ( + + + + + +
+
+ + { hasFixedToolbar && ( + + ) } +
+ { actions } +
+
+ +
+
+ { children } + +
+ +
+ { canvasHeader } + +
+
+
+
+ + { isSidebarOpen && ( + + ) } +
+
+
+
+ ); +} + +BlockEditor.propTypes = { + /** Buttons for the right of the header (save, publish, etc). */ + actions: PropTypes.node, + /** Rendered inside the canvas above the blocks (e.g. the title). */ + canvasHeader: PropTypes.node, + /** Rendered between the header and the canvas (e.g. notices). */ + children: PropTypes.node, + initialBlocks: PropTypes.array, + /** Called with the current blocks whenever they change. */ + onChange: PropTypes.func.isRequired, + onUpload: PropTypes.func.isRequired, + /** Extra sidebar tabs, shown before the Block tab. */ + panels: PropTypes.arrayOf( PropTypes.shape( { + name: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + content: PropTypes.node, + } ) ), + /** Overrides for the block editor settings. */ + settings: PropTypes.object, +}; + +const mapDispatchToProps = dispatch => ( { + onUpload: file => dispatch( ( _, getState ) => ( + dispatch( media.uploadSingle( file ) ) + .then( id => ( id ? media.getSingle( getState().media, id ) : null ) ) + ) ), +} ); + +export default connect( null, mapDispatchToProps )( BlockEditor ); diff --git a/src/components/Editor/BlockEditor.scss b/src/components/Editor/BlockEditor.scss new file mode 100644 index 00000000..64281c56 --- /dev/null +++ b/src/components/Editor/BlockEditor.scss @@ -0,0 +1,38 @@ +// Styles for the block editor canvas. These are injected into the canvas +// iframe alongside the theme's editor styles, so nothing here affects the +// rest of the app. + +.h2-block-editor-canvas { + box-sizing: border-box; + min-height: 100%; + padding: 2.5rem 1.5rem 8rem; +} + +.h2-block-editor-title { + display: block; + box-sizing: border-box; + width: 100%; + max-width: 650px; + margin: 0 auto 1.5rem; + padding: 0; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; + color: inherit; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 2rem; + font-weight: 700; + line-height: 1.2; + overflow: hidden; + resize: none; + + &:focus { + outline: none; + } + + &::placeholder { + color: rgba( 80, 76, 76, 0.5 ); + opacity: 1; + } +} diff --git a/src/components/Editor/LazyEditor.js b/src/components/Editor/LazyEditor.js index d0a4fd63..ab3e32fa 100644 --- a/src/components/Editor/LazyEditor.js +++ b/src/components/Editor/LazyEditor.js @@ -14,11 +14,13 @@ const LazyEditor = props => ( ); LazyEditor.propTypes = { + isSubmitting: PropTypes.bool, previewComponent: PropTypes.func, saveText: PropTypes.string, submitText: PropTypes.string, onCancel: PropTypes.func, onSubmit: PropTypes.func.isRequired, + onSwitchToBlocks: PropTypes.func, }; export default LazyEditor; diff --git a/src/components/Editor/index.js b/src/components/Editor/index.js index 5b8a0e05..b72e85c4 100644 --- a/src/components/Editor/index.js +++ b/src/components/Editor/index.js @@ -464,6 +464,18 @@ class Editor extends React.PureComponent { checked={ mode === 'preview' } onSelect={ value => this.setState( { mode: value } ) } /> + { this.props.onSwitchToBlocks && ( +
  • + +
  • + ) } { mode === 'edit' ? ( @@ -518,7 +530,7 @@ class Editor extends React.PureComponent { ) } @@ -582,11 +594,15 @@ Editor.defaultProps = { Editor.propTypes = { className: PropTypes.string, + /** While true, navigating away (e.g. after publishing) won't prompt. */ + isSubmitting: PropTypes.bool, previewComponent: PropTypes.func, saveText: PropTypes.string, submitText: PropTypes.string, onCancel: PropTypes.func, onSubmit: PropTypes.func.isRequired, + /** When set, offers a switch to the block editor with the current content. */ + onSwitchToBlocks: PropTypes.func, }; const mapStateToProps = state => { diff --git a/src/components/Post/BlockEditor.js b/src/components/Post/BlockEditor.js new file mode 100644 index 00000000..f8e29410 --- /dev/null +++ b/src/components/Post/BlockEditor.js @@ -0,0 +1,384 @@ +import countWords from '@iarna/word-count'; +import PropTypes from 'prop-types'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { FormattedRelative } from 'react-intl'; +import { Prompt } from 'react-router-dom'; +import Turndown from 'turndown'; + +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { parse, rawHandler, serialize } from '@wordpress/blocks'; +import { Button, Panel, PanelBody, SelectControl } from '@wordpress/components'; +import { useDispatch } from '@wordpress/data'; + +import compileMarkdown from '../../compile-markdown'; +import { cleanConvertedMarkdown, isBlockContent } from '../../util'; +import BlockEditor from '../Editor/BlockEditor'; +import Notification from '../Notification'; + +import SelectDraft from './SelectDraft'; + +const NAVIGATE_WARNING = 'You have unsaved content. Are you sure you want to leave? Your content will not be saved.'; +const CONVERT_WARNING = 'Switching to the Markdown editor converts your blocks to Markdown, and some formatting may be lost. Continue?'; + +/** + * Convert saved post content into blocks. + * + * Block posts are parsed directly; Markdown is compiled and converted the + * same way Gutenberg converts classic content. + * + * @param {string} content Block markup or Markdown. + * @returns {object[]} Blocks. + */ +function contentToBlocks( content ) { + if ( ! content ) { + return []; + } + + if ( isBlockContent( content ) ) { + return parse( content ); + } + + return rawHandler( { HTML: compileMarkdown( content ) } ); +} + +/** + * Convert blocks to Markdown for the Markdown editor. + * + * @param {object[]} blocks Blocks. + * @returns {string} Markdown. + */ +function blocksToMarkdown( blocks ) { + const turndown = new Turndown( { + headingStyle: 'atx', + hr: '---', + codeBlockStyle: 'fenced', + } ); + return cleanConvertedMarkdown( turndown.turndown( serialize( blocks ) ) ); +} + +function PostTitle( { onChange, value } ) { + const ref = useRef( null ); + const { insertDefaultBlock } = useDispatch( blockEditorStore ); + + // Grow to fit the title. + useEffect( () => { + const el = ref.current; + if ( ! el ) { + return; + } + + el.style.height = 'auto'; + el.style.height = `${ el.scrollHeight }px`; + }, [ value ] ); + + // Start in the title for new posts. + useEffect( () => { + if ( ! value && ref.current ) { + ref.current.focus(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [] ); + + return ( +