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 ( + + { () => ( + + + 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, + 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} Link suggestions. + */ +export 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, + } ) ) ); +}; + +/* + * @-mentions. + * + * Mentions are plain `@slug` text which H2 links up when rendering, so the + * completer only needs to insert that. Users come from H2's store; the editor + * components keep the list current via setMentionUsers(). + */ +let mentionUsers = []; + +/** + * Provide the users offered for @-mentions. + * + * @param {object[]} users Users from the REST API. + */ +export function setMentionUsers( users ) { + mentionUsers = users || []; +} + +const mentionCompleter = { + name: 'h2/mentions', + className: 'BlockEditor-mention', + triggerPrefix: '@', + options: () => mentionUsers, + getOptionKeywords: user => [ user.slug, user.name ], + getOptionLabel: user => ( + + + { user.name } + @{ user.slug } + + ), + getOptionCompletion: user => `@${ user.slug } `, +}; + +addFilter( 'editor.Autocomplete.completers', 'h2/mentions', completers => [ + ...completers, + mentionCompleter, +] ); + +/** + * Keyboard undo/redo, which the standalone block editor doesn't provide. + * + * @param {object} props Component props. + * @param {boolean} props.canRedo Whether redo is available. + * @param {boolean} props.canUndo Whether undo is available. + * @param {Function} props.onRedo Redo handler. + * @param {Function} props.onUndo Undo handler. + * @param {object} [props.target] Ref to scope the shortcuts to; defaults to the document. + * @returns {null} Nothing. + */ +export function HistoryShortcuts( { canRedo, canUndo, onRedo, onUndo, target } ) { + useKeyboardShortcut( UNDO_KEYS, event => { + event.preventDefault(); + if ( canUndo ) { + onUndo(); + } + }, { + bindGlobal: true, + target, + } ); + useKeyboardShortcut( REDO_KEYS, event => { + event.preventDefault(); + if ( canRedo ) { + onRedo(); + } + }, { + bindGlobal: true, + target, + } ); + + return null; +} + +/** + * Inserter, undo and redo. + * + * @param {object} props Component props. + * @param {boolean} props.canRedo Whether redo is available. + * @param {boolean} props.canUndo Whether undo is available. + * @param {Function} props.onRedo Redo handler. + * @param {Function} props.onUndo Undo handler. + * @param {boolean} [props.isQuickInserter] Use the compact inserter (search and a short list) instead of the full library. + * @returns {React.ReactElement} Toolbar. + */ +export function DocumentTools( { canRedo, canUndo, isQuickInserter = false, onRedo, onUndo } ) { + return ( + + + ( + + ) } + showInserterHelpPanel + /> + + + + + ); +} + +const mapStateToProps = state => ( { + users: state.users.posts, +} ); + +const mapDispatchToProps = dispatch => ( { + onUpload: file => dispatch( ( _, getState ) => ( + dispatch( media.uploadSingle( file ) ) + .then( id => ( id ? media.getSingle( getState().media, id ) : null ) ) + ) ), +} ); + +/** + * Connect an editor to H2's store: users for mentions, and media uploads. + */ +export const withEditorData = connect( mapStateToProps, mapDispatchToProps ); diff --git a/src/components/Editor/InlineBlockEditor.js b/src/components/Editor/InlineBlockEditor.js new file mode 100644 index 00000000..309c9f31 --- /dev/null +++ b/src/components/Editor/InlineBlockEditor.js @@ -0,0 +1,200 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + BlockCanvas, + BlockEditorKeyboardShortcuts, + BlockEditorProvider, + BlockList, +} from '@wordpress/block-editor'; +import { serialize } from '@wordpress/blocks'; +import { SlotFillProvider } from '@wordpress/components'; +import { useStateWithHistory } from '@wordpress/compose'; + +import { + CANVAS_STYLES, + DEFAULT_SETTINGS, + DocumentTools, + HistoryShortcuts, + contentToBlocks, + createMediaUpload, + fetchLinkSuggestions, + setMentionUsers, + withEditorData, +} from './BlockEditorCore'; + +// Grow with the content, like the Markdown textarea (min-h-72 / max-h-120). +const MIN_HEIGHT = 288; +const MAX_HEIGHT = 480; + +/** + * Size the canvas to its content. + * + * The canvas is an iframe, so watch the content wrapper inside it once the + * iframe has rendered and mirror its height onto the container. + * + * @param {object} containerRef Ref to the element wrapping the canvas. + * @returns {number} Height in pixels. + */ +function useAutoHeight( containerRef ) { + const [ height, setHeight ] = useState( MIN_HEIGHT ); + + useEffect( () => { + const container = containerRef.current; + if ( ! container ) { + return; + } + + let observer = null; + let attempts = 0; + const timer = window.setInterval( () => { + const iframe = container.querySelector( 'iframe[name="editor-canvas"]' ); + const doc = iframe && iframe.contentDocument; + const content = doc && doc.querySelector( '.h2-block-editor-canvas' ); + attempts++; + + if ( ! content ) { + if ( attempts > 100 ) { + window.clearInterval( timer ); + } + return; + } + + window.clearInterval( timer ); + const Observer = doc.defaultView.ResizeObserver; + observer = new Observer( () => { + setHeight( Math.min( MAX_HEIGHT, Math.max( MIN_HEIGHT, content.offsetHeight ) ) ); + } ); + observer.observe( content ); + }, 100 ); + + return () => { + window.clearInterval( timer ); + if ( observer ) { + observer.disconnect(); + } + }; + }, [ containerRef ] ); + + return height; +} + +/** + * A compact block editor for comments and similar short content. + * + * No sidebar, no title, a floating block toolbar, and only the blocks the + * owner allows. Reports the serialized blocks on every change. + * + * @param {object} props Component props. + * @returns {React.ReactElement} Editor. + */ +export function InlineBlockEditor( props ) { + const { + allowedBlocks, + initialContent, + onChange, + onUpload, + placeholder = 'Write a comment…', + users, + } = props; + + const rootRef = useRef( null ); + const [ initialBlocks ] = useState( () => contentToBlocks( initialContent ) ); + const { hasRedo, hasUndo, redo, setValue, undo, value } = useStateWithHistory( { blocks: initialBlocks } ); + const height = useAutoHeight( rootRef ); + + useEffect( () => { + setMentionUsers( users ); + }, [ users ] ); + + // Keep the owner up to date, including after undo/redo. + useEffect( () => { + onChange( serialize( value.blocks ), value.blocks ); + }, [ onChange, value.blocks ] ); + + const settings = useMemo( () => ( { + ...DEFAULT_SETTINGS, + allowedBlockTypes: allowedBlocks || true, + bodyPlaceholder: placeholder, + hasFixedToolbar: false, + mediaUpload: createMediaUpload( onUpload ), + __experimentalFetchLinkSuggestions: fetchLinkSuggestions, + } ), [ allowedBlocks, onUpload, placeholder ] ); + + const onInput = useCallback( ( blocks, { selection } ) => { + setValue( { + blocks, + selection, + }, true ); + }, [ setValue ] ); + const onPersist = useCallback( ( blocks, { selection } ) => { + setValue( { + blocks, + selection, + }, false ); + }, [ setValue ] ); + + return ( + + + + + +
+
+ +
+
+ +
+ +
+
+
+
+
+
+ ); +} + +InlineBlockEditor.propTypes = { + /** Block names to allow; everything else is hidden from the inserter. */ + allowedBlocks: PropTypes.arrayOf( PropTypes.string ), + /** Block markup or Markdown to start from. */ + initialContent: PropTypes.string, + /** Called with ( serializedHtml, blocks ) whenever the blocks change. */ + onChange: PropTypes.func.isRequired, + onUpload: PropTypes.func.isRequired, + placeholder: PropTypes.string, + /** Users offered for @-mentions. */ + users: PropTypes.array, +}; + +export default withEditorData( InlineBlockEditor ); diff --git a/src/components/Editor/LazyEditor.js b/src/components/Editor/LazyEditor.js index d0a4fd63..b32cba27 100644 --- a/src/components/Editor/LazyEditor.js +++ b/src/components/Editor/LazyEditor.js @@ -14,11 +14,15 @@ const LazyEditor = props => ( ); LazyEditor.propTypes = { + allowBlocks: PropTypes.bool, + allowedBlocks: PropTypes.arrayOf( PropTypes.string ), + 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/LazyInlineBlockEditor.js b/src/components/Editor/LazyInlineBlockEditor.js new file mode 100644 index 00000000..718b931b --- /dev/null +++ b/src/components/Editor/LazyInlineBlockEditor.js @@ -0,0 +1,20 @@ +import React, { Suspense } from 'react'; + +import Notification from '../Notification'; + +// Gutenberg is large, so only load it when someone opts in to blocks. +const InlineBlockEditor = React.lazy( () => import( './InlineBlockEditor' ) ); + +export default function LazyInlineBlockEditor( props ) { + return ( + + Loading block editor… +
+ ) } + > + + + ); +} 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*' } - ) : ( -