From 49551171ec3145e8f8e7d88f865814c7e1c07cb2 Mon Sep 17 00:00:00 2001 From: Omar Alshaker Date: Mon, 15 Jan 2024 17:53:18 +0100 Subject: [PATCH] Add VBE editor injector (#86123) Co-authored-by: escapemanuele Co-authored-by: Renan --- .gitignore | 1 + .../comment/comment-block-editor/index.tsx | 8 +- packages/verbum-block-editor/README.md | 34 ++++- packages/verbum-block-editor/package.json | 8 +- packages/verbum-block-editor/src/api/index.ts | 19 ++- .../src/editor/editor-style.scss | 138 ++++++++++-------- .../verbum-block-editor/src/editor/index.tsx | 45 +++--- packages/verbum-block-editor/src/index.ts | 1 + .../src/text-area-injector.tsx | 38 +++++ .../src/use-state-with-history.ts | 93 ------------ packages/verbum-block-editor/src/utils.ts | 9 ++ packages/verbum-block-editor/tsconfig.json | 3 +- .../verbum-block-editor/webpack.config.js | 40 +++++ yarn.lock | 1 + 14 files changed, 243 insertions(+), 195 deletions(-) create mode 100644 packages/verbum-block-editor/src/text-area-injector.tsx delete mode 100644 packages/verbum-block-editor/src/use-state-with-history.ts create mode 100644 packages/verbum-block-editor/src/utils.ts create mode 100644 packages/verbum-block-editor/webpack.config.js diff --git a/.gitignore b/.gitignore index 056a8ebdd4142..452a8c596c557 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ cached-requests.json /packages/*/dist/ /build-tools/dist/ /apps/*/.cache/ +/packages/*/.cache/ /desktop/.cache/ /apps/*/release-files/ /apps/**/*/build_meta.json diff --git a/client/my-sites/comments/comment/comment-block-editor/index.tsx b/client/my-sites/comments/comment/comment-block-editor/index.tsx index 08a834e44ee6f..0b12904483dec 100644 --- a/client/my-sites/comments/comment/comment-block-editor/index.tsx +++ b/client/my-sites/comments/comment/comment-block-editor/index.tsx @@ -24,12 +24,16 @@ const CommentBlockEditor = ( { useEffect( () => { if ( siteId ) { - addApiMiddleware( siteId ); + addApiMiddleware( ( url ) => ( { + path: `/sites/${ encodeURIComponent( siteId ) }/proxy`, + query: `url=${ encodeURIComponent( url ) }`, + apiNamespace: 'oembed/1.0', + } ) ); } }, [ siteId ] ); return ( -
+
); diff --git a/packages/verbum-block-editor/README.md b/packages/verbum-block-editor/README.md index 0fbc1d3c8e513..bb29edeeac156 100644 --- a/packages/verbum-block-editor/README.md +++ b/packages/verbum-block-editor/README.md @@ -1,14 +1,34 @@ # Verbum Block Editor -Verbum Block Editor is a lightweight Gutenberg editor designed specifically for comments. It provides a simplified and intuitive interface for users to compose and format their comments effortlessly. +Verbum Block Editor is a lightweight Gutenberg editor, tailored specifically for enhancing the commenting experience. It offers a user-friendly interface, enabling effortless composition and formatting of comments. ## Features -- Autofocus on the last block when the empty white space is clicked. -- Autofocus on the first paragraph on load. -- Handles embeds by adding all the needed API middlewares. -- Uses and iframed editor to limit CSS collisions. +- Automatically focuses on the last block when clicking on any empty white space. +- Initial focus is set to the first paragraph upon loading. +- Efficiently handles embeds by integrating all necessary API middlewares. +- Incorporates an iframed editor to minimize CSS collisions. -## WIP -Soon, this will be published on NPM and used everywhere a user can edit comments (in Verbum and in Calypso's `/comments/all/` page). +## Development +This package can be utilized in two primary ways: + +### Directly In Calypso +- The package is directly integrated into Calypso as a standard package. +- No separate build process is required after modifications. +- Direct file alterations are reflected immediately in Calypso. + +### Via widgets.wp.com +- The package publishes a bundle on widgets.wp.com for broader accessibility. +- Development process: + 1. Navigate to the package's directory: `cd package/verbum-block-editor`. + 2. Execute `yarn dev --sync`. + 3. Changes are synchronized to `/home/wpcom/public_html/widgets.wp.com/verbum-block-editor` on your sandbox. + +### Deploying Changes + +To deploy modifications to the package: +1. Ensure your sandbox is in a clean git state. +2. Run `yarn build --sync`. +3. Create a patch. +4. Deploy the patch. diff --git a/packages/verbum-block-editor/package.json b/packages/verbum-block-editor/package.json index a651963e2009d..04fb422cdcc14 100644 --- a/packages/verbum-block-editor/package.json +++ b/packages/verbum-block-editor/package.json @@ -23,13 +23,15 @@ "bugs": "https://github.com/Automattic/wp-calypso/issues", "types": "dist/types", "scripts": { - "clean": "tsc --build ./tsconfig.json ./tsconfig-cjs.json --clean && rm -rf dist", - "build": "tsc --build ./tsconfig.json ./tsconfig-cjs.json && copy-assets", - "prepack": "yarn run clean && yarn run build", + "clean": "rm -rf dist", + "build": "NODE_ENV=production yarn dev", + "build:app": "calypso-build", + "dev": "yarn run calypso-apps-builder --localPath dist --remotePath /home/wpcom/public_html/widgets.wp.com/verbum-block-editor", "watch": "tsc --build ./tsconfig.json --watch", "prepare": "yarn build" }, "dependencies": { + "@automattic/calypso-apps-builder": "workspace:^", "@types/wordpress__block-editor": "^11.5.8", "@wordpress/base-styles": "^4.39.0", "@wordpress/block-editor": "^12.16.0", diff --git a/packages/verbum-block-editor/src/api/index.ts b/packages/verbum-block-editor/src/api/index.ts index 346212152d116..409edea561386 100644 --- a/packages/verbum-block-editor/src/api/index.ts +++ b/packages/verbum-block-editor/src/api/index.ts @@ -18,7 +18,15 @@ function createFallbackResponse( url: string ) { }; } -export function addApiMiddleware( siteId: number ) { +export type EmbedRequestParams = { + path: string; + query: string; + apiNamespace: string; +}; + +export function addApiMiddleware( + requestParamsGenerator: ( embedURL: string ) => EmbedRequestParams +) { apiFetch.setFetchHandler( ( options ) => { const { path } = options; @@ -26,11 +34,10 @@ export function addApiMiddleware( siteId: number ) { const url = new URL( 'https://wordpress.com' + path ); const embedUrl = url.searchParams.get( 'url' ); - return wpcomRequest( { - path: `/sites/${ encodeURIComponent( siteId ) }/proxy`, - query: `url=${ embedUrl }`, - apiNamespace: 'oembed/1.0', - } ); + if ( embedUrl ) { + return wpcomRequest( requestParamsGenerator( embedUrl ) ); + } + return Promise.reject( new Error( 'Invalid embed URL' ) ); } return defaultFetchHandler( options ); diff --git a/packages/verbum-block-editor/src/editor/editor-style.scss b/packages/verbum-block-editor/src/editor/editor-style.scss index 0bde0c7c4bd56..3a43b120ef24f 100644 --- a/packages/verbum-block-editor/src/editor/editor-style.scss +++ b/packages/verbum-block-editor/src/editor/editor-style.scss @@ -4,95 +4,109 @@ @import "@wordpress/block-editor/build-style/style"; @import "@wordpress/block-editor/build-style/content.css"; -.editor__wrapper { +.verbum-editor-wrapper { width: 100%; - padding: 0 10px; box-sizing: border-box; -} -.editor__header { - top: 0; - overflow: hidden; - position: sticky; - display: grid; - grid-template-rows: 0fr; - border: 1px solid var(--color-neutral-0); - // Avoid double border. - border-bottom: none; - - &.is-editing { - overflow: visible; - grid-template-rows: 1fr; - transition: all 0.5s 0.5s ease-in-out; - } - @at-root body.admin-bar & { - top: 32px; - } - - .editor__header-wrapper { - display: flex; + .editor__header { + top: 0; overflow: hidden; - justify-content: space-between; - align-items: center; + position: sticky; + display: grid; + border: 1px solid var(--color-neutral-0); + // Avoid double border. + border-bottom: none; + + &.is-editing { + overflow: visible; + } - .editor__header-toolbar { - flex-grow: 1; + @at-root body.admin-bar & { + top: 32px; + } - .block-editor-block-toolbar { + .editor__header-wrapper { + display: flex; + overflow: hidden; + justify-content: space-between; + align-items: center; + min-height: 52px; + border-bottom: solid 1px #dcdcde; + + .editor__header-toolbar { + flex-grow: 1; - .block-editor-block-settings-menu, - button:disabled { - display: none; + .block-editor-block-contextual-toolbar.components-accessible-toolbar { + border-bottom: none; } - .block-editor-block-parent-selector { - background-color: transparent; + .block-editor-block-toolbar { + + .block-editor-block-settings-menu, + button:disabled { + display: none; + } + + .block-editor-block-parent-selector { + background-color: transparent; - button.block-editor-block-parent-selector__button { - border: none; + button.block-editor-block-parent-selector__button { + border: none; + } } } } - } - .block-editor-inserter { - padding: 5px 8px; + .block-editor-inserter { + padding: 5px 8px; - .block-editor-inserter__toggle { - svg { - margin: auto; + .block-editor-inserter__toggle { + svg { + margin: auto; + } } } - } - .block-editor-media-placeholder__url-input-form { - display: flex; - box-sizing: border-box; - } + .block-editor-media-placeholder__url-input-form { + display: flex; + box-sizing: border-box; + } - .block-editor-media-flow__url-input { - border-top: 1px solid #1e1e1e; - margin-top: -9px; - padding: 8px 16px; + .block-editor-media-flow__url-input { + border-top: 1px solid #1e1e1e; + margin-top: -9px; + padding: 8px 16px; - .block-editor-media-replace-flow__image-url-label { - display: block; - margin-bottom: 8px; - font-size: 13px; - } + .block-editor-media-replace-flow__image-url-label { + display: block; + margin-bottom: 8px; + font-size: 13px; + } - .block-editor-link-control { - width: 300px; + .block-editor-link-control { + width: 300px; + } } } } -} -.editor__main { - border: solid 1px var(--color-neutral-0); - margin-bottom: 10px; + .editor__main { + border: 1px solid var(--color-neutral-0); + margin-bottom: 10px; + &.loading-placeholder { + &.loading { + display: flex; + justify-content: center; + align-items: center; + } + } + &, + & iframe { + min-height: 140px; + } + } } diff --git a/packages/verbum-block-editor/src/editor/index.tsx b/packages/verbum-block-editor/src/editor/index.tsx index db15e1e5eae5c..2ae13ca51b505 100644 --- a/packages/verbum-block-editor/src/editor/index.tsx +++ b/packages/verbum-block-editor/src/editor/index.tsx @@ -7,17 +7,17 @@ import { store as blockEditorStore, // @ts-expect-error - Typings missing } from '@wordpress/block-editor'; -import { parse } from '@wordpress/blocks'; import { createBlock, serialize, type BlockInstance } from '@wordpress/blocks'; import { Popover, SlotFillProvider, KeyboardShortcuts } from '@wordpress/components'; import { useStateWithHistory, useResizeObserver } from '@wordpress/compose'; import { useDispatch } from '@wordpress/data'; -import { useState, useEffect, useCallback } from '@wordpress/element'; +import React, { useState, useEffect, useCallback } from '@wordpress/element'; import { rawShortcut } from '@wordpress/keycodes'; import classNames from 'classnames'; +import { safeParse } from '../utils'; import { editorSettings } from './editor-settings'; import { EditorProps, StateWithUndoManager } from './editor-types'; -import type { MouseEvent, KeyboardEvent } from 'react'; +import type { MouseEvent, KeyboardEvent, FC } from 'react'; import css from '!!css-loader!sass-loader!./inline-iframe-style.scss'; import './editor-style.scss'; @@ -28,14 +28,16 @@ const iframedCSS = css.reduce( ( css: string, [ , item ]: [ string, string ] ) = /** * Editor component */ -export const Editor: React.FC< EditorProps > = ( { initialContent = '', onChange, isRTL } ) => { +export const Editor: FC< EditorProps > = ( { initialContent = '', onChange, isRTL } ) => { // We keep the content in state so we can access the blocks in the editor. const { value: editorContent, setValue, undo, redo, - } = useStateWithHistory( parse( initialContent ) ) as unknown as StateWithUndoManager; + } = useStateWithHistory( + initialContent !== '' ? safeParse( initialContent ) : [ createBlock( 'core/paragraph' ) ] + ) as unknown as StateWithUndoManager; const [ isEditing, setIsEditing ] = useState( false ); const handleContentUpdate = useCallback( @@ -53,26 +55,27 @@ export const Editor: React.FC< EditorProps > = ( { initialContent = '', onChange const selectLastBlock = ( event?: MouseEvent | KeyboardEvent ) => { const lastBlock = editorContent[ editorContent.length - 1 ]; + if ( lastBlock ) { + // If this is a click event only shift focus if the click is in the root. + // We don't want to shift focus if the click is in a block. + if ( event ) { + if ( ( event.target as HTMLDivElement ).dataset.isDropZone ) { + // If the last block isn't a paragraph, add a new one. + // This allows the user to add text after a non-text block without clicking the inserter. + if ( lastBlock.name !== 'core/paragraph' ) { + const newParagraph = createBlock( 'core/paragraph' ); + handleContentUpdate( [ ...editorContent, newParagraph ] ); + selectBlock( newParagraph.clientId ); + } - // If this is a click event only shift focus if the click is in the root. - // We don't want to shift focus if the click is in a block. - if ( event ) { - if ( ( event.target as HTMLDivElement ).dataset.isDropZone ) { - // If the last block isn't a paragraph, add a new one. - // This allows the user to add text after a non-text block without clicking the inserter. - if ( lastBlock.name !== 'core/paragraph' ) { - const newParagraph = createBlock( 'core/paragraph' ); - handleContentUpdate( [ ...editorContent, newParagraph ] ); - selectBlock( newParagraph.clientId ); + selectBlock( lastBlock.clientId ); + } else { + return; } - - selectBlock( lastBlock.clientId ); - } else { - return; } - } - selectBlock( lastBlock.clientId ); + selectBlock( lastBlock.clientId ); + } }; useEffect( () => { diff --git a/packages/verbum-block-editor/src/index.ts b/packages/verbum-block-editor/src/index.ts index 2349c6c6e2bae..b3ed74cf07582 100644 --- a/packages/verbum-block-editor/src/index.ts +++ b/packages/verbum-block-editor/src/index.ts @@ -2,3 +2,4 @@ export { Editor } from './editor'; export { loadTextFormatting } from './load-text-formatting'; export { loadBlocksWithCustomizations } from './load-blocks'; export { addApiMiddleware } from './api'; +export { attachGutenberg } from './text-area-injector'; diff --git a/packages/verbum-block-editor/src/text-area-injector.tsx b/packages/verbum-block-editor/src/text-area-injector.tsx new file mode 100644 index 0000000000000..e36e3a8a709e6 --- /dev/null +++ b/packages/verbum-block-editor/src/text-area-injector.tsx @@ -0,0 +1,38 @@ +import React, { createRoot } from '@wordpress/element'; +import { EmbedRequestParams, addApiMiddleware } from './api'; +import { Editor } from './editor'; +import { loadBlocksWithCustomizations } from './load-blocks'; +import { loadTextFormatting } from './load-text-formatting'; +/** + * Add Gutenberg editor to the page. + * @param textarea Textarea element. + * @param setComment Callback that runs when the editor content changes. + * It receives the serialized content as a parameter. + * @param isRTL Whether the editor should be RTL. + * @param requestParamsGenerator Function that generates request params for embeds. It receives the embed URL as a parameter. + */ +export const attachGutenberg = ( + textarea: HTMLTextAreaElement, + setComment: ( newValue: string ) => void, + isRTL = false, + requestParamsGenerator: ( embedURL: string ) => EmbedRequestParams +) => { + const editor = document.createElement( 'div' ); + editor.className = 'verbum-editor-wrapper'; + + // Insert after the textarea, and hide it + textarea.after( editor ); + textarea.style.display = 'none'; + + loadBlocksWithCustomizations(); + loadTextFormatting(); + addApiMiddleware( requestParamsGenerator ); + + createRoot( editor ).render( + setComment( content ) } + /> + ); +}; diff --git a/packages/verbum-block-editor/src/use-state-with-history.ts b/packages/verbum-block-editor/src/use-state-with-history.ts deleted file mode 100644 index c8be72183407e..0000000000000 --- a/packages/verbum-block-editor/src/use-state-with-history.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * WordPress dependencies - */ -import { useCallback, useReducer } from '@wordpress/element'; -import { createUndoManager } from '@wordpress/undo-manager'; -import type { UndoManager } from '@wordpress/undo-manager'; - -type UndoRedoState< T > = { - manager: UndoManager; - value: T; -}; - -function undoRedoReducer< T >( - state: UndoRedoState< T >, - action: { type: 'UNDO' } | { type: 'REDO' } | { type: 'RECORD'; value: T; isStaged: boolean } -): UndoRedoState< T > { - switch ( action.type ) { - case 'UNDO': { - const undoRecord = state.manager.undo(); - if ( undoRecord ) { - return { - ...state, - value: undoRecord[ 0 ].changes.prop.from, - }; - } - return state; - } - case 'REDO': { - const redoRecord = state.manager.redo(); - if ( redoRecord ) { - return { - ...state, - value: redoRecord[ 0 ].changes.prop.to, - }; - } - return state; - } - case 'RECORD': { - state.manager.addRecord( - [ - { - id: 'object', - changes: { - prop: { from: state.value, to: action.value }, - }, - }, - ], - action.isStaged - ); - return { - ...state, - value: action.value, - }; - } - } - - return state; -} - -function initReducer< T >( value: T ) { - return { - manager: createUndoManager(), - value, - }; -} - -/** - * useState with undo/redo history. - * @param initialValue Initial value. - * @returns Value, setValue, hasUndo, hasRedo, undo, redo. - */ -export default function useStateWithHistory< T >( initialValue: T ) { - const [ state, dispatch ] = useReducer( undoRedoReducer, initialValue, initReducer ); - - return { - value: state.value, - setValue: useCallback( ( newValue: T, isStaged: boolean ) => { - dispatch( { - type: 'RECORD', - value: newValue, - isStaged, - } ); - }, [] ), - hasUndo: state.manager.hasUndo(), - hasRedo: state.manager.hasRedo(), - undo: useCallback( () => { - dispatch( { type: 'UNDO' } ); - }, [] ), - redo: useCallback( () => { - dispatch( { type: 'REDO' } ); - }, [] ), - }; -} diff --git a/packages/verbum-block-editor/src/utils.ts b/packages/verbum-block-editor/src/utils.ts new file mode 100644 index 0000000000000..938a7f914ae35 --- /dev/null +++ b/packages/verbum-block-editor/src/utils.ts @@ -0,0 +1,9 @@ +import { createBlock, parse } from '@wordpress/blocks'; + +export function safeParse( text = '' ) { + try { + return parse( text ); + } catch { + return [ createBlock( 'core/paragraph', { content: text } ) ]; + } +} diff --git a/packages/verbum-block-editor/tsconfig.json b/packages/verbum-block-editor/tsconfig.json index 0567c617bec3a..471309840d460 100644 --- a/packages/verbum-block-editor/tsconfig.json +++ b/packages/verbum-block-editor/tsconfig.json @@ -5,7 +5,8 @@ "checkJs": false, "declarationDir": "dist/types", "outDir": "dist/esm", - "rootDir": "src" + "rootDir": "src", + "jsx": "react" }, "include": [ "src", "src/*.json", "types" ], "exclude": [ "**/__tests__/*", "**/__mocks__/*" ], diff --git a/packages/verbum-block-editor/webpack.config.js b/packages/verbum-block-editor/webpack.config.js new file mode 100644 index 0000000000000..052e6412de564 --- /dev/null +++ b/packages/verbum-block-editor/webpack.config.js @@ -0,0 +1,40 @@ +const path = require( 'path' ); +const getBaseWebpackConfig = require( '@automattic/calypso-build/webpack.config.js' ); + +/* Arguments to this function replicate webpack's so this config can be used on the command line, + * with individual options overridden by command line args. + * @see {@link https://webpack.js.org/configuration/configuration-types/#exporting-a-function} + * @see {@link https://webpack.js.org/api/cli/} + * @param {Object} env environment options + * @param {string} env.source plugin slugs, comma separated list + * @param {Object} argv options map + * @param {string} argv.entry entry path + * @returns {Object} webpack config + */ +function getWebpackConfig( env = { source: '' }, argv = {} ) { + const outputPath = path.join( __dirname, 'dist' ); + + const webpackConfig = getBaseWebpackConfig( env, argv ); + + return { + ...webpackConfig, + mode: 'production', + entry: { + 'block-editor': path.join( __dirname, 'src', 'index.ts' ), + }, + output: { + ...webpackConfig.output, + path: outputPath, + // Unfortunately, we can't set the name to `[name].js` for the + // dev env because at runtime we'd also need a way to detect + // if the env was dev or prod, as the file is enqueued in WP + // and there's no way to do that now. The simpler alternative + // is to generate a .min.js for dev and prod, even though the + // file is not really minified in the dev env. + filename: '[name].min.js', // dynamic filename + library: 'verbumBlockEditor', + }, + }; +} + +module.exports = getWebpackConfig; diff --git a/yarn.lock b/yarn.lock index 22ec6f69d7226..f2c1a27486362 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1730,6 +1730,7 @@ __metadata: version: 0.0.0-use.local resolution: "@automattic/verbum-block-editor@workspace:packages/verbum-block-editor" dependencies: + "@automattic/calypso-apps-builder": "workspace:^" "@automattic/calypso-build": "workspace:^" "@automattic/calypso-color-schemes": "workspace:^" "@automattic/calypso-typescript-config": "workspace:^"