From 29f2c9705513a71e01f7d5f2baaa42e02acdb71d Mon Sep 17 00:00:00 2001 From: Douglas Henri Date: Mon, 15 Jul 2024 11:36:03 -0300 Subject: [PATCH] AI Assistant: Add long sentences feature to proofread (#38314) * add long sentences feature * changelog * fix ts test * rename word to text * fix popover on second-level highlight --- .../changelog/add-proofread-long-sentences | 4 ++ .../breve/features/_features.colors.scss | 1 + .../breve/features/ambiguous-words/index.ts | 18 +++---- .../breve/features/complex-words/index.ts | 20 ++++---- .../components/breve/features/events.ts | 21 +++++--- .../components/breve/features/index.ts | 5 ++ .../breve/features/long-sentences/index.ts | 47 +++++++++++++++++ .../components/breve/highlight/highlight.ts | 16 +++--- .../components/breve/highlight/index.tsx | 21 +++++--- .../components/breve/store/actions.ts | 15 +++++- .../components/breve/store/reducer.ts | 51 ++++++++++++++++--- .../components/breve/store/selectors.ts | 15 +++++- .../components/breve/types.ts | 15 ++++-- 13 files changed, 195 insertions(+), 54 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/add-proofread-long-sentences create mode 100644 projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/long-sentences/index.ts diff --git a/projects/plugins/jetpack/changelog/add-proofread-long-sentences b/projects/plugins/jetpack/changelog/add-proofread-long-sentences new file mode 100644 index 0000000000000..fedef0ba4179c --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-proofread-long-sentences @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +AI Assistant: Add long sentences feature to proofread diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/_features.colors.scss b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/_features.colors.scss index a7e43dc64d0ca..972f7b41bd94d 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/_features.colors.scss +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/_features.colors.scss @@ -4,6 +4,7 @@ $features-colors: ( 'complex-words': rgba( 240, 184, 73, 1 ), 'ambiguous-words': rgba( 0, 175, 82, 1 ), + 'long-sentences': rgba( 122, 0, 223, 1 ), ); @mixin properties( $feature, $color, $properties ) { diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/ambiguous-words/index.ts b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/ambiguous-words/index.ts index bf0cc8d763e48..0f06af9469cae 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/ambiguous-words/index.ts +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/ambiguous-words/index.ts @@ -6,7 +6,7 @@ import weaselWords from './words'; /** * Types */ -import type { BreveFeatureConfig, HighlightedWord } from '../../types'; +import type { BreveFeatureConfig, HighlightedText } from '../../types'; export const AMBIGUOUS_WORDS: BreveFeatureConfig = { name: 'ambiguous-words', @@ -17,18 +17,18 @@ export const AMBIGUOUS_WORDS: BreveFeatureConfig = { const list = new RegExp( `\\b(${ weaselWords.map( escapeRegExp ).join( '|' ) })\\b`, 'gi' ); -export default function ambiguousWords( text: string ): Array< HighlightedWord > { - const matches = text.matchAll( list ); - const highlightedWords: Array< HighlightedWord > = []; +export default function ambiguousWords( blockText: string ): Array< HighlightedText > { + const matches = blockText.matchAll( list ); + const highlightedTexts: Array< HighlightedText > = []; for ( const match of matches ) { - const word = match[ 0 ].trim(); - highlightedWords.push( { - word, + const text = match[ 0 ].trim(); + highlightedTexts.push( { + text, startIndex: match.index, - endIndex: match.index + word.length, + endIndex: match.index + text.length, } ); } - return highlightedWords; + return highlightedTexts; } diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/complex-words/index.ts b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/complex-words/index.ts index 85acfba4f0aec..83b509c14bcee 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/complex-words/index.ts +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/complex-words/index.ts @@ -6,7 +6,7 @@ import phrases from './phrases'; /** * Types */ -import type { BreveFeatureConfig, HighlightedWord } from '../../types'; +import type { BreveFeatureConfig, HighlightedText } from '../../types'; export const COMPLEX_WORDS: BreveFeatureConfig = { name: 'complex-words', @@ -20,19 +20,19 @@ const list = new RegExp( 'gi' ); -export default function complexWords( text: string ): Array< HighlightedWord > { - const matches = text.matchAll( list ); - const highlightedWords: Array< HighlightedWord > = []; +export default function complexWords( blockText: string ): Array< HighlightedText > { + const matches = blockText.matchAll( list ); + const highlightedTexts: Array< HighlightedText > = []; for ( const match of matches ) { - const word = match[ 0 ].trim(); - highlightedWords.push( { - word, - suggestion: phrases[ word ], + const text = match[ 0 ].trim(); + highlightedTexts.push( { + text, + suggestion: phrases[ text ], startIndex: match.index, - endIndex: match.index + word.length, + endIndex: match.index + text.length, } ); } - return highlightedWords; + return highlightedTexts; } diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/events.ts b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/events.ts index 4cd12de962c65..d4838209f0e8e 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/events.ts +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/events.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import { dispatch } from '@wordpress/data'; +import { dispatch, select } from '@wordpress/data'; /** * Internal dependencies */ @@ -10,22 +10,31 @@ import features from './index'; /** * Types */ -import type { BreveDispatch } from '../types'; +import type { BreveDispatch, BreveSelect } from '../types'; -let timeout: number; +let highlightTimeout: number; function handleMouseEnter( e: React.MouseEvent ) { e.stopPropagation(); - clearTimeout( timeout ); + clearTimeout( highlightTimeout ); + ( dispatch( 'jetpack/ai-breve' ) as BreveDispatch ).increasePopoverLevel(); ( dispatch( 'jetpack/ai-breve' ) as BreveDispatch ).setHighlightHover( true ); ( dispatch( 'jetpack/ai-breve' ) as BreveDispatch ).setPopoverAnchor( e.target ); } function handleMouseLeave( e: React.MouseEvent ) { e.stopPropagation(); - timeout = setTimeout( () => { + ( dispatch( 'jetpack/ai-breve' ) as BreveDispatch ).decreasePopoverLevel(); + + highlightTimeout = setTimeout( () => { + // If the mouse is still over any highlight, don't hide the popover + const { getPopoverLevel } = select( 'jetpack/ai-breve' ) as BreveSelect; + if ( getPopoverLevel() > 0 ) { + return; + } + ( dispatch( 'jetpack/ai-breve' ) as BreveDispatch ).setHighlightHover( false ); - }, 100 ); + }, 50 ); } export default function registerEvents( clientId: string ) { diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/index.ts b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/index.ts index 5c14e7aa36a27..a5a700d0c2f9b 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/index.ts +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/index.ts @@ -3,6 +3,7 @@ */ import ambiguousWords, { AMBIGUOUS_WORDS } from './ambiguous-words'; import complexWords, { COMPLEX_WORDS } from './complex-words'; +import longSentences, { LONG_SENTENCES } from './long-sentences'; /** * Types */ @@ -14,6 +15,10 @@ const features: Array< BreveFeature > = [ config: COMPLEX_WORDS, highlight: complexWords, }, + { + config: LONG_SENTENCES, + highlight: longSentences, + }, { config: AMBIGUOUS_WORDS, highlight: ambiguousWords, diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/long-sentences/index.ts b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/long-sentences/index.ts new file mode 100644 index 0000000000000..337a0eb29da81 --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/features/long-sentences/index.ts @@ -0,0 +1,47 @@ +/** + * Internal dependencies + */ +import { escapeRegExp } from '../../utils/escapeRegExp'; +/** + * Types + */ +import type { BreveFeatureConfig, HighlightedText } from '../../types'; + +export const LONG_SENTENCES: BreveFeatureConfig = { + name: 'long-sentences', + title: 'Long sentences', + tagName: 'span', + className: 'has-proofread-highlight--long-sentences', +}; + +const sentenceRegex = /[^\s][^.!?]+[.!?]+/g; + +export default function longSentences( text: string ): Array< HighlightedText > { + const highlightedTexts: Array< HighlightedText > = []; + + const sentenceMatches = text.match( sentenceRegex ); + + if ( ! sentenceMatches ) { + return highlightedTexts; + } + + const sentences = [ + // Unique sentences with more than 20 words + ...new Set( sentenceMatches.filter( sentence => sentence.split( /\s+/ ).length > 20 ) ), + ]; + + sentences.forEach( sentence => { + const regex = new RegExp( escapeRegExp( sentence ), 'gi' ); + const matches = text.matchAll( regex ); + + for ( const match of matches ) { + highlightedTexts.push( { + text: sentence, + startIndex: match.index, + endIndex: match.index + sentence.length, + } ); + } + } ); + + return highlightedTexts; +} diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/highlight/highlight.ts b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/highlight/highlight.ts index aa76c615fb0a4..eff3dba14211a 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/highlight/highlight.ts +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/highlight/highlight.ts @@ -7,17 +7,19 @@ import { applyFormat } from '@wordpress/rich-text'; */ import type { RichTextFormat, RichTextValue } from '@wordpress/rich-text/build-types/types'; +export type HighlightProps = { + content: RichTextValue; + type: string; + indexes: Array< { startIndex: number; endIndex: number } >; + attributes?: { [ key: string ]: string }; +}; + const applyHighlightFormat = ( { content, type, indexes, attributes = {}, -}: { - content: RichTextValue; - type: string; - indexes: Array< { startIndex: number; endIndex: number } >; - attributes: { [ key: string ]: string }; -} ): RichTextValue => { +}: HighlightProps ): RichTextValue => { let newContent = content; if ( indexes.length > 0 ) { @@ -40,6 +42,6 @@ const applyHighlightFormat = ( { return newContent; }; -export default function highlight( { content, type, indexes, attributes } ) { +export default function highlight( { content, type, indexes, attributes }: HighlightProps ) { return applyHighlightFormat( { indexes, content, type, attributes } ); } diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/highlight/index.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/highlight/index.tsx index e1d7acc64118a..738e648361bcb 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/highlight/index.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/highlight/index.tsx @@ -17,12 +17,13 @@ import './style.scss'; /** * Types */ -import type { BreveSelect } from '../types'; +import type { BreveDispatch, BreveSelect } from '../types'; +import type { WPFormat } from '@wordpress/rich-text/build-types/register-format-type'; import type { RichTextFormatList } from '@wordpress/rich-text/build-types/types'; // Setup the Breve highlights export default function Highlight() { - const { setPopoverHover } = useDispatch( 'jetpack/ai-breve' ); + const { setPopoverHover } = useDispatch( 'jetpack/ai-breve' ) as BreveDispatch; const popoverOpen = useSelect( select => { const store = select( 'jetpack/ai-breve' ) as BreveSelect; @@ -45,11 +46,13 @@ export default function Highlight() { title: '', }; - const handleMouseEnter = () => { + const handleMouseEnter = ( e: React.MouseEvent ) => { + e.stopPropagation(); setPopoverHover( true ); }; - const handleMouseLeave = () => { + const handleMouseLeave = ( e: React.MouseEvent ) => { + e.stopPropagation(); setPopoverHover( false ); }; @@ -86,8 +89,12 @@ export function registerBreveHighlights() { features.forEach( feature => { const { highlight: featureHighlight, config } = feature; const { name, ...configSettings } = config; + const formatName = `jetpack/ai-proofread-${ name }`; const settings = { + name: formatName, + interactive: false, + edit: () => {}, ...configSettings, __experimentalGetPropsForEditableTreePreparation() { @@ -106,7 +113,7 @@ export function registerBreveHighlights() { ) { return ( formats: Array< RichTextFormatList >, text: string ) => { const record = { formats, text } as RichTextValue; - const type = `jetpack/ai-proofread-${ config.name }`; + const type = formatName; if ( text && isProofreadEnabled && isFeatureEnabled ) { const applied = highlight( { @@ -126,8 +133,8 @@ export function registerBreveHighlights() { return removeFormat( record, type, 0, record.text.length ).formats; }; }, - } as never; + } as WPFormat; - registerFormatType( `jetpack/ai-proofread-${ name }`, settings ); + registerFormatType( formatName, settings ); } ); } diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/store/actions.ts b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/store/actions.ts index c03a1cf98a8d0..a7ff1a1b49963 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/store/actions.ts +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/store/actions.ts @@ -19,10 +19,23 @@ export function setPopoverHover( isHover: boolean ) { }; } -export function setPopoverAnchor( anchor: HTMLElement | EventTarget ) { +export function setPopoverAnchor( anchor: HTMLElement | EventTarget, level: number ) { return { type: 'SET_POPOVER_ANCHOR', anchor, + level, + }; +} + +export function increasePopoverLevel() { + return { + type: 'INCREASE_POPOVER_LEVEL', + }; +} + +export function decreasePopoverLevel() { + return { + type: 'DECREASE_POPOVER_LEVEL', }; } diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/store/reducer.ts b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/store/reducer.ts index 3f94a9ecebd37..fb721a7e99cbb 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/store/reducer.ts +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/store/reducer.ts @@ -2,20 +2,23 @@ * WordPress dependencies */ import { combineReducers } from '@wordpress/data'; +/** + * Types + */ +import type { BreveState } from '../types'; const enabledFromLocalStorage = window.localStorage.getItem( 'jetpack-ai-proofread-enabled' ); const disabledFeaturesFromLocalStorage = window.localStorage.getItem( 'jetpack-ai-proofread-disabled-features' ); const initialConfiguration = { - // TODO: Confirm that we will start it as true enabled: enabledFromLocalStorage === 'true' || enabledFromLocalStorage === null, disabled: disabledFeaturesFromLocalStorage !== null ? JSON.parse( disabledFeaturesFromLocalStorage ) : [], }; export function configuration( - state = initialConfiguration, + state: BreveState[ 'configuration' ] = initialConfiguration, action: { type: string; enabled?: boolean; feature?: string } ) { switch ( action.type ) { @@ -28,8 +31,9 @@ export function configuration( enabled, }; } + case 'ENABLE_FEATURE': { - const disabled = state.disabled.filter( feature => feature !== action.feature ); + const disabled = ( state.disabled ?? [] ).filter( feature => feature !== action.feature ); window.localStorage.setItem( 'jetpack-ai-proofread-disabled-features', JSON.stringify( disabled ) @@ -40,8 +44,9 @@ export function configuration( disabled, }; } + case 'DISABLE_FEATURE': { - const disabled = [ ...state.disabled, action.feature ]; + const disabled = [ ...( state.disabled ?? [] ), action.feature ]; window.localStorage.setItem( 'jetpack-ai-proofread-disabled-features', JSON.stringify( disabled ) @@ -58,8 +63,8 @@ export function configuration( } export function popover( - state = {}, - action: { type: string; isHover?: boolean; anchor?: HTMLElement } + state: BreveState[ 'popover' ] = {}, + action: { type: string; isHover?: boolean; anchor?: HTMLElement | EventTarget } ) { switch ( action.type ) { case 'SET_HIGHLIGHT_HOVER': @@ -72,13 +77,43 @@ export function popover( return { ...state, isPopoverHover: action.isHover, + frozenAnchor: action.isHover ? ( state.anchors ?? [] )[ ( state.level ?? 1 ) - 1 ] : null, + }; + + case 'SET_POPOVER_ANCHOR': { + if ( ! action.anchor ) { + return state; + } + + const anchors = [ ...( state.anchors ?? [] ) ]; + + anchors[ Math.max( ( state.level ?? 1 ) - 1, 0 ) ] = action.anchor; + + return { + ...state, + anchors, + }; + } + + case 'INCREASE_POPOVER_LEVEL': { + const level = ( state.level ?? 0 ) + 1; + + return { + ...state, + level, }; + } + + case 'DECREASE_POPOVER_LEVEL': { + const level = Math.max( ( state.level ?? 1 ) - 1, 0 ); + const anchors = ( state.anchors ?? [] ).slice( 0, level ); - case 'SET_POPOVER_ANCHOR': return { ...state, - anchor: action.anchor, + level, + anchors, }; + } } return state; diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/store/selectors.ts b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/store/selectors.ts index bcdaa0f64829f..febb919a047c6 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/store/selectors.ts +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/store/selectors.ts @@ -13,8 +13,19 @@ export function isPopoverHover( state: BreveState ) { return state.popover?.isPopoverHover; } -export function getPopoverAnchor( state: BreveState ) { - return state.popover?.anchor; +export function getPopoverAnchor( state: BreveState ): HTMLElement | EventTarget | null { + if ( state.popover?.frozenAnchor ) { + return state.popover.frozenAnchor; + } + + // Returns the last non-nullish anchor in the array + return ( + ( state.popover?.anchors ?? [] ) as Array< HTMLElement | EventTarget | null > + ).reduceRight( ( acc, anchor ) => acc ?? anchor, null ); +} + +export function getPopoverLevel( state: BreveState ) { + return state.popover?.level; } // CONFIGURATION diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/types.ts b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/types.ts index 0694823fa94ef..33171a1426311 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/types.ts +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/breve/types.ts @@ -4,7 +4,9 @@ export type BreveState = { popover?: { isHighlightHover?: boolean; isPopoverHover?: boolean; - anchor?: HTMLElement | EventTarget; + anchors?: Array< HTMLElement | EventTarget >; + level?: number; + frozenAnchor?: HTMLElement | EventTarget; }; configuration?: { enabled?: boolean; @@ -16,6 +18,7 @@ export type BreveSelect = { isHighlightHover: () => boolean; isPopoverHover: () => boolean; getPopoverAnchor: () => HTMLElement | EventTarget; + getPopoverLevel: () => number; isProofreadEnabled: () => boolean; isFeatureEnabled: ( feature: string ) => boolean; getDisabledFeatures: () => Array< string >; @@ -25,6 +28,10 @@ export type BreveDispatch = { setHighlightHover: ( isHover: boolean ) => void; setPopoverHover: ( isHover: boolean ) => void; setPopoverAnchor: ( anchor: HTMLElement | EventTarget ) => void; + increasePopoverLevel: () => void; + decreasePopoverLevel: () => void; + toggleProofread: ( force?: boolean ) => void; + toggleFeature: ( feature: string, force?: boolean ) => void; }; export type BreveFeatureConfig = { @@ -36,11 +43,11 @@ export type BreveFeatureConfig = { export type BreveFeature = { config: BreveFeatureConfig; - highlight: ( text: string ) => Array< HighlightedWord >; + highlight: ( text: string ) => Array< HighlightedText >; }; -export type HighlightedWord = { - word: string; +export type HighlightedText = { + text: string; suggestion?: string; startIndex: number; endIndex: number;