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 },
+} );
diff --git a/inc/namespace.php b/inc/namespace.php
index da6fe566..310062e5 100644
--- a/inc/namespace.php
+++ b/inc/namespace.php
@@ -24,6 +24,11 @@ function adjust_default_filters() {
// Add make_clickable to posts.
add_filter( 'the_content', 'make_clickable', 9 );
+ // Render comments written in the block editor. Users without unfiltered_html
+ // have the block delimiters stripped by kses anyway, so for them this is a
+ // no-op on plain HTML.
+ add_filter( 'comment_text', 'do_blocks', 7 );
+
// Normalize entities for easier decoding.
add_filter( 'the_title', 'ent2ncr', 11 );
diff --git a/package.json b/package.json
index ba95a7be..a0ca8a80 100644
--- a/package.json
+++ b/package.json
@@ -38,11 +38,22 @@
"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/hooks": "4.44.0",
+ "@wordpress/icons": "^12.2.0",
"emoji-mart": "^2.11.1",
"interweave": "^11.1.0",
"lodash": "^4.17.19",
@@ -50,9 +61,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 +100,11 @@
{
"path": "./build/h2.*.js"
}
- ]
+ ],
+ "pnpm": {
+ "overrides": {
+ "@wordpress/private-apis": "1.44.0",
+ "@wordpress/hooks": "4.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/Comment.js b/src/components/Comment.js
index 284fd9f0..9977d233 100644
--- a/src/components/Comment.js
+++ b/src/components/Comment.js
@@ -110,6 +110,7 @@ export class Comment extends Component {
Loading…
) : (
this.setState( { isEditing: false } ) }
diff --git a/src/components/Editor/BlockEditor.css b/src/components/Editor/BlockEditor.css
new file mode 100644
index 00000000..276267a7
--- /dev/null
+++ b/src/components/Editor/BlockEditor.css
@@ -0,0 +1,57 @@
+/* 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;
+}
+
+/* The inline (comment) editor has no other header controls. */
+.BlockEditor--inline .BlockEditor-document-tools.components-accessible-toolbar {
+ border-right: none;
+}
+
+/* @-mention suggestions. */
+.BlockEditor-mention .components-autocomplete__result {
+ padding: 4px 8px;
+}
diff --git a/src/components/Editor/BlockEditor.js b/src/components/Editor/BlockEditor.js
new file mode 100644
index 00000000..5f3ab1f3
--- /dev/null
+++ b/src/components/Editor/BlockEditor.js
@@ -0,0 +1,319 @@
+import PropTypes from 'prop-types';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+
+import {
+ BlockCanvas,
+ BlockEditorKeyboardShortcuts,
+ BlockEditorProvider,
+ BlockInspector,
+ BlockList,
+ BlockToolbar,
+ store as blockEditorStore,
+} from '@wordpress/block-editor';
+import {
+ Button,
+ DropdownMenu,
+ MenuGroup,
+ MenuItem,
+ SlotFillProvider,
+} from '@wordpress/components';
+import { useStateWithHistory } from '@wordpress/compose';
+import { useSelect } from '@wordpress/data';
+import { check, drawerRight, moreVertical } from '@wordpress/icons';
+
+import {
+ CANVAS_STYLES,
+ DEFAULT_SETTINGS,
+ DocumentTools,
+ EMPTY_ARRAY,
+ EMPTY_OBJECT,
+ HistoryShortcuts,
+ createMediaUpload,
+ fetchLinkSuggestions,
+ setMentionUsers,
+ withEditorData,
+} from './BlockEditorCore';
+
+const PREFERENCE_PREFIX = 'h2-block-editor:';
+
+/**
+ * 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 ];
+}
+
+/**
+ * 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 (
+
+ { () => (
+
+
+
+ ) }
+
+ );
+}
+
+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,
+ users,
+ } = 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 );
+
+ useEffect( () => {
+ setMentionUsers( users );
+ }, [ users ] );
+
+ // 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,
+ /** Users offered for @-mentions. */
+ users: PropTypes.array,
+};
+
+export default withEditorData( BlockEditor );
diff --git a/src/components/Editor/BlockEditor.scss b/src/components/Editor/BlockEditor.scss
new file mode 100644
index 00000000..62437c49
--- /dev/null
+++ b/src/components/Editor/BlockEditor.scss
@@ -0,0 +1,49 @@
+// 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;
+ }
+}
+
+// The inline (comment) editor sizes itself to its content and uses the full
+// width of the comment column.
+.h2-block-editor-canvas--inline {
+ min-height: 0;
+ padding: 1rem 1rem 2.5rem;
+
+ .block-editor-block-list__layout {
+ max-width: none;
+ }
+}
diff --git a/src/components/Editor/BlockEditorCore.js b/src/components/Editor/BlockEditorCore.js
new file mode 100644
index 00000000..0b6f4f10
--- /dev/null
+++ b/src/components/Editor/BlockEditorCore.js
@@ -0,0 +1,387 @@
+/**
+ * Shared plumbing for H2's block editors.
+ *
+ * Registers blocks and formats, wires Gutenberg's data needs (uploads, link
+ * search, API access, @-mentions) into H2, and provides the pieces both the
+ * full post editor and the inline comment editor are built from.
+ */
+import React from 'react';
+import { connect } from 'react-redux';
+
+import apiFetch from '@wordpress/api-fetch';
+import { Inserter, NavigableToolbar } 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,
+ parse,
+ rawHandler,
+ setFreeformContentHandlerName,
+ unregisterBlockType,
+} from '@wordpress/blocks';
+import { ToolbarButton, ToolbarGroup } from '@wordpress/components';
+import componentsStyles from '@wordpress/components/build-style/style.css?raw';
+import { useKeyboardShortcut } from '@wordpress/compose';
+import { addFilter } from '@wordpress/hooks';
+import { plus, redo as redoIcon, undo as undoIcon } from '@wordpress/icons';
+
+import api from '../../api';
+import compileMarkdown from '../../compile-markdown';
+import h2EditorStyle from '../../editor-style.scss?inline';
+import { media } from '../../types';
+import { decodeEntities, isBlockContent } 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.
+export const CANVAS_STYLES = [
+ { css: componentsStyles },
+ { css: blockEditorContentStyles },
+ { css: blockLibraryStyles },
+ { css: blockLibraryEditorStyles },
+ { css: h2EditorStyle },
+ { css: canvasStyles },
+];
+
+export 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,
+ },
+ },
+};
+
+export const EMPTY_ARRAY = [];
+export const EMPTY_OBJECT = {};
+
+const UNDO_KEYS = [ 'mod+z' ];
+const REDO_KEYS = [ 'mod+shift+z', 'ctrl+y' ];
+
+/**
+ * Convert saved content into blocks.
+ *
+ * Block content is parsed directly; Markdown is compiled and converted the
+ * same way Gutenberg converts classic content.
+ *
+ * @param {string} content Block markup or Markdown.
+ * @returns {object[]} Blocks.
+ */
+export function contentToBlocks( content ) {
+ if ( ! content ) {
+ return [];
+ }
+
+ if ( isBlockContent( content ) ) {
+ return parse( content );
+ }
+
+ return rawHandler( { HTML: compileMarkdown( content ) } );
+}
+
+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.
+ */
+export 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
+ ) }
+ >
+
+
+ );
+}
diff --git a/src/components/Editor/index.js b/src/components/Editor/index.js
index 5b8a0e05..0c666acc 100644
--- a/src/components/Editor/index.js
+++ b/src/components/Editor/index.js
@@ -9,13 +9,14 @@ import Turndown from 'turndown';
import compileMarkdown from '../../compile-markdown';
import { media } from '../../types';
-import { cleanConvertedMarkdown, isWordContent } from '../../util';
+import { cleanConvertedMarkdown, isBlockContent, isWordContent } from '../../util';
import Button from '../Button';
import DropUpload from '../DropUpload';
import MessageContent from '../Message/Content';
import Shortcuts from '../Shortcuts';
import EmojiCompletion from './EmojiCompletion';
+import LazyInlineBlockEditor from './LazyInlineBlockEditor';
import MentionCompletion from './MentionCompletion';
const apply = ( selection, start, end ) => {
@@ -58,6 +59,27 @@ const BUTTONS = {
};
const navigateWarning = 'You have unsaved content. Are you sure you want to leave? Your content will not be saved.';
+const convertWarning = 'Switching to the Markdown editor converts your blocks to Markdown, and some formatting may be lost. Continue?';
+
+const TURNDOWN_OPTIONS = {
+ headingStyle: 'atx',
+ hr: '---',
+ codeBlockStyle: 'fenced',
+};
+
+// Blocks offered in the inline (comment) editor. This mirrors what H2 allows
+// in comment HTML.
+const DEFAULT_ALLOWED_BLOCKS = [
+ 'core/paragraph',
+ 'core/heading',
+ 'core/list',
+ 'core/list-item',
+ 'core/quote',
+ 'core/code',
+ 'core/preformatted',
+ 'core/image',
+ 'core/separator',
+];
const completions = {
'@': MentionCompletion,
@@ -65,14 +87,18 @@ const completions = {
};
const Preview = props => {
- const compiled = compileMarkdown( props.children );
+ const compiled = props.html !== undefined ? props.html : compileMarkdown( props.children );
return (
);
};
-Preview.propTypes = { children: PropTypes.string.isRequired };
+Preview.propTypes = {
+ children: PropTypes.string,
+ /** Ready-made HTML to show instead of compiling the Markdown children. */
+ html: PropTypes.string,
+};
const Tab = ( { checked, title, value, onSelect } ) => (
@@ -113,14 +139,28 @@ class Editor extends React.PureComponent {
constructor( props ) {
super( props );
+ // Block content is stored as block markup; open that in the block editor.
+ const startInBlocks = !! props.allowBlocks && isBlockContent( props.initialValue );
+
this.state = {
- content: props.initialValue,
+ content: startInBlocks ? '' : props.initialValue,
completion: null,
- count: 0,
+ count: countWords( startInBlocks ? props.initialValue.replace( /<[^>]*>/g, ' ' ) : ( props.initialValue || '' ) ),
hasFocus: false,
height: null,
- mode: 'edit',
+ mode: startInBlocks ? 'blocks' : 'edit',
uploading: [],
+
+ // Which editor currently holds the content ('edit' or 'blocks'),
+ // regardless of whether the preview is showing.
+ editMode: startInBlocks ? 'blocks' : 'edit',
+
+ // Block mode: what the block editor loads, its serialized output, and
+ // the output it started from (for change tracking).
+ blocksSource: startInBlocks ? props.initialValue : '',
+ blocksHtml: startInBlocks ? props.initialValue : '',
+ blocksInitialHtml: startInBlocks ? props.initialValue : '',
+ blocksKey: 0,
};
this.textarea = null;
}
@@ -134,7 +174,7 @@ class Editor extends React.PureComponent {
}
warnBeforeLeaving = e => {
- if ( this.state.content === '' || ( this.props.initialValue && this.state.content === this.props.initialValue ) ) {
+ if ( ! this.isDirty() ) {
return;
}
@@ -143,6 +183,12 @@ class Editor extends React.PureComponent {
}
componentDidUpdate() {
+ this.setState( state => {
+ const text = state.editMode === 'blocks' ? state.blocksHtml.replace( /<[^>]*>/g, ' ' ) : state.content;
+
+ return { count: countWords( text ) };
+ } );
+
if ( ! this.textarea ) {
return;
}
@@ -154,11 +200,71 @@ class Editor extends React.PureComponent {
if ( desired > height ) {
this.setState( { height: desired } );
}
+ }
- this.setState( state => {
- const count = countWords( state.content );
+ /**
+ * Whether there are changes which haven't been submitted.
+ *
+ * @returns {boolean} True if the content has changed.
+ */
+ isDirty() {
+ if ( this.state.editMode === 'blocks' ) {
+ return this.state.blocksHtml !== this.state.blocksInitialHtml;
+ }
+
+ return ! ( this.state.content === '' || ( this.props.initialValue && this.state.content === this.props.initialValue ) );
+ }
+
+ /**
+ * Get the content to submit.
+ *
+ * Block content is stored as-is, with the block markup doubling as the
+ * editable source (kses may strip the delimiters from the rendered copy).
+ *
+ * @returns {string[]} Content and unprocessed content.
+ */
+ getOutput() {
+ if ( this.state.editMode === 'blocks' ) {
+ return [ this.state.blocksHtml, this.state.blocksHtml ];
+ }
+
+ return [ compileMarkdown( this.state.content ), this.state.content ];
+ }
- return { count };
+ onSelectMode = mode => {
+ const { editMode, mode: current } = this.state;
+ if ( mode === current ) {
+ return;
+ }
+
+ // Previewing, or returning from the preview, keeps the content where it is.
+ if ( mode === 'preview' || mode === editMode ) {
+ this.setState( { mode } );
+ return;
+ }
+
+ if ( mode === 'blocks' ) {
+ // Markdown to blocks; the block editor converts on load.
+ this.setState( state => ( {
+ mode,
+ editMode: 'blocks',
+ blocksSource: state.content,
+ blocksHtml: '',
+ blocksKey: state.blocksKey + 1,
+ } ) );
+ return;
+ }
+
+ // Blocks to Markdown, which is lossy.
+ const html = this.state.blocksHtml;
+ if ( html && ! window.confirm( convertWarning ) ) {
+ return;
+ }
+
+ this.setState( {
+ mode,
+ editMode: 'edit',
+ content: html ? cleanConvertedMarkdown( new Turndown( TURNDOWN_OPTIONS ).turndown( html ) ) : '',
} );
}
@@ -267,11 +373,11 @@ class Editor extends React.PureComponent {
onSubmit( e ) {
e.preventDefault();
- this.props.onSubmit( compileMarkdown( this.state.content ), this.state.content );
+ this.props.onSubmit( ...this.getOutput() );
}
onSave = () => {
- this.props.onSave( compileMarkdown( this.state.content ), this.state.content );
+ this.props.onSave( ...this.getOutput() );
}
onBlur() {
@@ -412,6 +518,10 @@ class Editor extends React.PureComponent {
return ;
}
+ onChangeBlocks = html => {
+ this.setState( { blocksHtml: html } );
+ }
+
focus() {
if ( ! this.textarea ) {
return;
@@ -421,7 +531,7 @@ class Editor extends React.PureComponent {
}
render() {
- const { content, count, hasFocus, height, mode } = this.state;
+ const { content, count, editMode, hasFocus, height, mode } = this.state;
const shortcuts = {};
Object.keys( BUTTONS ).forEach( buttonType => {
@@ -456,14 +566,34 @@ class Editor extends React.PureComponent {
title="Write"
value="edit"
checked={ mode === 'edit' }
- onSelect={ value => this.setState( { mode: value } ) }
+ onSelect={ this.onSelectMode }
/>
this.setState( { mode: value } ) }
+ onSelect={ this.onSelectMode }
/>
+ { this.props.allowBlocks && (
+
+ ) }
+ { this.props.onSwitchToBlocks && (
+
+
+
+ ) }
{ mode === 'edit' ? (
@@ -492,37 +622,52 @@ class Editor extends React.PureComponent {
-
this.onUpload( file ) }
- >
- { mode === 'preview' ? (
- { content || '*Nothing to preview*' }
- ) : (
-
@@ -581,12 +726,20 @@ Editor.defaultProps = {
};
Editor.propTypes = {
+ /** Offer a Blocks tab with the inline block editor (comments). */
+ allowBlocks: PropTypes.bool,
+ /** Blocks available in the inline block editor. */
+ allowedBlocks: PropTypes.arrayOf( PropTypes.string ),
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/Message/WriteComment.js b/src/components/Message/WriteComment.js
index a8508f2f..c0c5f7bd 100644
--- a/src/components/Message/WriteComment.js
+++ b/src/components/Message/WriteComment.js
@@ -76,6 +76,7 @@ export class WriteComment extends React.Component {
}
ref={ editor => this.editor = editor }
submitText={ this.state.isSubmitting ? 'Commenting...' : 'Comment' }
diff --git a/src/components/Post/BlockEditor.js b/src/components/Post/BlockEditor.js
new file mode 100644
index 00000000..6e48813d
--- /dev/null
+++ b/src/components/Post/BlockEditor.js
@@ -0,0 +1,363 @@
+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 { serialize } from '@wordpress/blocks';
+import { Button, Panel, PanelBody, SelectControl } from '@wordpress/components';
+import { useDispatch } from '@wordpress/data';
+
+import { cleanConvertedMarkdown, isBlockContent } from '../../util';
+import BlockEditor from '../Editor/BlockEditor';
+import { contentToBlocks } from '../Editor/BlockEditorCore';
+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 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 (
+
+ ) }
+ >
+
+
+ );
+}
diff --git a/src/components/Post/SelectDraft.js b/src/components/Post/SelectDraft.js
index 2a58d573..765ee2de 100644
--- a/src/components/Post/SelectDraft.js
+++ b/src/components/Post/SelectDraft.js
@@ -24,10 +24,20 @@ class SelectDraft extends React.Component {
const { loading, posts } = this.props;
if ( ! this.state.showingSelector ) {
+ const onOpen = () => this.setState( { showingSelector: true } );
+
+ // Let the parent supply its own button (e.g. a Gutenberg-styled one).
+ if ( this.props.renderToggle ) {
+ return this.props.renderToggle( {
+ count: posts ? posts.length : null,
+ onClick: onOpen,
+ } );
+ }
+
return (