| 1 |
import { store as blockEditorStore } from '@wordpress/block-editor'; |
| 2 |
import { createBlock, pasteHandler } from '@wordpress/blocks'; |
| 3 |
import { |
| 4 |
MenuGroup, |
| 5 |
MenuItem, |
| 6 |
__experimentalDivider as Divider, |
| 7 |
} from '@wordpress/components'; |
| 8 |
import { useDispatch, useSelect, select, dispatch } from '@wordpress/data'; |
| 9 |
import { useEffect } from '@wordpress/element'; |
| 10 |
import { __, isRTL } from '@wordpress/i18n'; |
| 11 |
import { |
| 12 |
addSubmenu, |
| 13 |
rotateLeft, |
| 14 |
trash, |
| 15 |
replace, |
| 16 |
insertAfter, |
| 17 |
Icon, |
| 18 |
} from '@wordpress/icons'; |
| 19 |
import { useContentHighlight } from '@draft/hooks/useContentHighlight'; |
| 20 |
|
| 21 |
export const InsertMenu = ({ |
| 22 |
prompt, |
| 23 |
completion, |
| 24 |
loading, |
| 25 |
setPrompt, |
| 26 |
setInputText, |
| 27 |
}) => { |
| 28 |
const { toggleHighlight, toggleInsertionPoint } = useContentHighlight(); |
| 29 |
const { insertBlocks, replaceBlocks } = useDispatch(blockEditorStore); |
| 30 |
const { |
| 31 |
getSelectedBlock, |
| 32 |
getSelectedBlockClientIds, |
| 33 |
getBlockRootClientId, |
| 34 |
getBlockIndex, |
| 35 |
getBlock, |
| 36 |
} = useSelect((select) => select(blockEditorStore), []); |
| 37 |
const selectedBlock = getSelectedBlock(); |
| 38 |
const selectedBlockIds = getSelectedBlockClientIds(); |
| 39 |
|
| 40 |
const canReplaceContent = () => { |
| 41 |
const firstBlock = selectedBlock |
| 42 |
? selectedBlock |
| 43 |
: getBlock(selectedBlockIds[0]); |
| 44 |
if (!firstBlock) return false; |
| 45 |
// If it's a header or a p, we can replace the content |
| 46 |
// TODO: support more? |
| 47 |
const unsupported = ['core/list-item', 'core/button']; |
| 48 |
if (unsupported.includes(firstBlock?.name)) { |
| 49 |
// Can we support the same block? |
| 50 |
const blocks = plainTextToBlocks(completion); |
| 51 |
return blocks[0]?.name === firstBlock?.name; |
| 52 |
} |
| 53 |
return true; |
| 54 |
}; |
| 55 |
|
| 56 |
const canInsertAfter = () => { |
| 57 |
const firstBlock = selectedBlock |
| 58 |
? selectedBlock |
| 59 |
: getBlock(selectedBlockIds[0]); |
| 60 |
if (!firstBlock) return true; |
| 61 |
// TODO: more? or should we go up to the parent? |
| 62 |
const unsupported = ['core/list-item', 'core/button']; |
| 63 |
return unsupported.includes(firstBlock?.name) ? false : true; |
| 64 |
}; |
| 65 |
|
| 66 |
const plainTextToBlocks = (plainText) => { |
| 67 |
const blocks = pasteHandler({ plainText: plainText }); |
| 68 |
if (!Array.isArray(blocks)) { |
| 69 |
return [createBlock('core/paragraph', { content: blocks })]; |
| 70 |
} |
| 71 |
return blocks; |
| 72 |
}; |
| 73 |
|
| 74 |
const insertCompletion = async ({ replaceContent = false, position }) => { |
| 75 |
setPrompt({ text: '', promptType: '', systemMessageKey: '' }); |
| 76 |
|
| 77 |
const targetBlockId = selectedBlock |
| 78 |
? selectedBlock?.clientId |
| 79 |
: selectedBlockIds[0]; |
| 80 |
const targetBlock = getBlock(targetBlockId); |
| 81 |
|
| 82 |
const renderingModes = |
| 83 |
select('core/preferences').get('core', 'renderingModes') || {}; |
| 84 |
const currentTheme = select('core').getCurrentTheme()?.stylesheet; |
| 85 |
const isTemplateShown = |
| 86 |
renderingModes?.[currentTheme]?.page === 'template-locked'; |
| 87 |
|
| 88 |
const { set: setPreference } = dispatch('core/preferences'); |
| 89 |
const setRenderingMode = (mode) => |
| 90 |
setPreference('core', 'renderingModes', { |
| 91 |
...renderingModes, |
| 92 |
[currentTheme]: { ...(renderingModes[currentTheme] || {}), page: mode }, |
| 93 |
}); |
| 94 |
|
| 95 |
const blocks = plainTextToBlocks(completion); |
| 96 |
try { |
| 97 |
if (!targetBlockId || position === 'end') { |
| 98 |
if (isTemplateShown) { |
| 99 |
setRenderingMode('post-only'); |
| 100 |
await new Promise((resolve) => requestAnimationFrame(resolve)); |
| 101 |
} |
| 102 |
|
| 103 |
insertBlocks(blocks); |
| 104 |
return; |
| 105 |
} |
| 106 |
|
| 107 |
if (position === 'top') { |
| 108 |
if (isTemplateShown) { |
| 109 |
setRenderingMode('post-only'); |
| 110 |
await new Promise((resolve) => requestAnimationFrame(resolve)); |
| 111 |
} |
| 112 |
|
| 113 |
insertBlocks(blocks, 0); |
| 114 |
return; |
| 115 |
} |
| 116 |
} finally { |
| 117 |
if (isTemplateShown) setRenderingMode('template-locked'); |
| 118 |
} |
| 119 |
|
| 120 |
const targetIsEmpty = targetBlock?.attributes?.content === ''; |
| 121 |
const parentBlockId = getBlockRootClientId(targetBlockId); |
| 122 |
const blockIndex = getBlockIndex(selectedBlockIds.at(-1), parentBlockId); |
| 123 |
if (!replaceContent && !targetIsEmpty) { |
| 124 |
// Multiple blocks are selected, insert after |
| 125 |
insertBlocks(blocks, blockIndex + 1, parentBlockId); |
| 126 |
return; |
| 127 |
} |
| 128 |
|
| 129 |
const bothHaveContent = (one, two) => |
| 130 |
Object.prototype.hasOwnProperty.call(one?.attributes, 'content') && |
| 131 |
Object.prototype.hasOwnProperty.call(two?.attributes, 'content'); |
| 132 |
// If both have content, and it's only one block, they can be merged |
| 133 |
const mergeable = |
| 134 |
blocks.length === 1 && bothHaveContent(targetBlock, blocks[0]); |
| 135 |
|
| 136 |
// Apply formatting to all the blocks |
| 137 |
const formattedBlocks = blocks.map((incomingBlock) => ({ |
| 138 |
...incomingBlock, |
| 139 |
name: mergeable ? targetBlock.name : incomingBlock.name, |
| 140 |
attributes: { |
| 141 |
...targetBlock.attributes, |
| 142 |
content: |
| 143 |
// If they both have content, they can merge and give it to the incoing block |
| 144 |
// otherwise just default to the existing block content |
| 145 |
bothHaveContent(incomingBlock, targetBlock) |
| 146 |
? incomingBlock?.attributes?.content |
| 147 |
: incomingBlock?.attributes?.content, |
| 148 |
}, |
| 149 |
})); |
| 150 |
|
| 151 |
// TODO: some blocks are harder to replace, like list items |
| 152 |
// Should we climb up to the parent in this case? |
| 153 |
// See notes in canReplaceContent() above |
| 154 |
replaceBlocks(selectedBlockIds, formattedBlocks); |
| 155 |
}; |
| 156 |
|
| 157 |
const discard = () => { |
| 158 |
setInputText(''); |
| 159 |
setPrompt({ text: '', promptType: '', systemMessageKey: '' }); |
| 160 |
}; |
| 161 |
|
| 162 |
const retry = () => { |
| 163 |
setInputText(''); |
| 164 |
setPrompt({ text: '', promptType: '', systemMessageKey: '' }); |
| 165 |
setTimeout(() => setPrompt(prompt)); |
| 166 |
}; |
| 167 |
|
| 168 |
useEffect(() => { |
| 169 |
return () => { |
| 170 |
toggleHighlight(selectedBlockIds, { isHighlighted: false }); |
| 171 |
}; |
| 172 |
}, [selectedBlockIds, toggleHighlight]); |
| 173 |
|
| 174 |
return ( |
| 175 |
<MenuGroup> |
| 176 |
<MenuItem |
| 177 |
onClick={() => insertCompletion({ replaceContent: true })} |
| 178 |
onMouseEnter={() => |
| 179 |
toggleHighlight(selectedBlockIds, { |
| 180 |
isHighlighted: true, |
| 181 |
}) |
| 182 |
} |
| 183 |
onMouseLeave={() => |
| 184 |
toggleHighlight(selectedBlockIds, { |
| 185 |
isHighlighted: false, |
| 186 |
}) |
| 187 |
} |
| 188 |
disabled={loading || !canReplaceContent()} |
| 189 |
icon={replace} |
| 190 |
iconPosition="left" |
| 191 |
data-test="replace-selected" |
| 192 |
className="h-auto min-h-10 items-start"> |
| 193 |
<span className="whitespace-normal break-words text-start"> |
| 194 |
{__('Replace selected block text', 'extendify-local')} |
| 195 |
</span> |
| 196 |
</MenuItem> |
| 197 |
<MenuItem |
| 198 |
onClick={() => |
| 199 |
insertCompletion({ replaceContent: false, position: 'top' }) |
| 200 |
} |
| 201 |
disabled={loading} |
| 202 |
iconPosition="left" |
| 203 |
data-test="insert-top" |
| 204 |
className="h-auto min-h-10 items-start"> |
| 205 |
<div className={isRTL() ? '-mr-1' : '-ml-1'}> |
| 206 |
<Icon icon={addSubmenu} className="rotate-180" /> |
| 207 |
</div> |
| 208 |
<div className="whitespace-normal break-words px-1 text-start"> |
| 209 |
{__('Insert at top', 'extendify-local')} |
| 210 |
</div> |
| 211 |
</MenuItem> |
| 212 |
<MenuItem |
| 213 |
onClick={() => insertCompletion({ replaceContent: false })} |
| 214 |
onMouseEnter={() => toggleInsertionPoint(true)} |
| 215 |
onMouseLeave={() => toggleInsertionPoint(false)} |
| 216 |
disabled={loading || !canInsertAfter()} |
| 217 |
icon={insertAfter} |
| 218 |
iconPosition="left" |
| 219 |
data-test="insert-after" |
| 220 |
className="h-auto min-h-10 items-start"> |
| 221 |
<span className="whitespace-normal break-words text-start"> |
| 222 |
{__('Insert after the selected text', 'extendify-local')} |
| 223 |
</span> |
| 224 |
</MenuItem> |
| 225 |
<MenuItem |
| 226 |
onClick={() => |
| 227 |
insertCompletion({ replaceContent: false, position: 'end' }) |
| 228 |
} |
| 229 |
disabled={loading} |
| 230 |
icon={addSubmenu} |
| 231 |
iconPosition="left" |
| 232 |
data-test="insert-bottom"> |
| 233 |
{__('Insert at bottom', 'extendify-local')} |
| 234 |
</MenuItem> |
| 235 |
<Divider /> |
| 236 |
<MenuItem |
| 237 |
onClick={retry} |
| 238 |
disabled={loading} |
| 239 |
icon={rotateLeft} |
| 240 |
iconPosition="left" |
| 241 |
data-test="try-again-button"> |
| 242 |
{__('Try again', 'extendify-local')} |
| 243 |
</MenuItem> |
| 244 |
<MenuItem |
| 245 |
onClick={discard} |
| 246 |
disabled={loading} |
| 247 |
icon={trash} |
| 248 |
iconPosition="left" |
| 249 |
data-test="discard-button"> |
| 250 |
{__('Discard', 'extendify-local')} |
| 251 |
</MenuItem> |
| 252 |
</MenuGroup> |
| 253 |
); |
| 254 |
}; |
| 255 |
|