| 1 |
import { |
| 2 |
BlockEditorProvider, |
| 3 |
BlockList, |
| 4 |
BlockToolbar, |
| 5 |
BlockTools, |
| 6 |
ObserveTyping, |
| 7 |
WritingFlow, |
| 8 |
} from '@wordpress/block-editor'; |
| 9 |
// Don't deep-import @wordpress/format-library/build-module — webpack-asset-php |
| 10 |
// emits invalid script handles for those paths and the enqueue silently fails. |
| 11 |
// Use the wp.formatLibrary global at runtime instead. |
| 12 |
import { registerCoreBlocks } from '@wordpress/block-library'; |
| 13 |
|
| 14 |
// The blocks QE's text canvas parses/serializes through. |
| 15 |
const QE_REQUIRED_BLOCKS = ['core/paragraph', 'core/heading', 'core/button']; |
| 16 |
|
| 17 |
// True when every block QE edits is in the live registry. With no inspectable |
| 18 |
// registry (unit-test env, no window.wp) it returns true — fail open, matching |
| 19 |
// the pre-hardening behavior. |
| 20 |
const requiredBlocksAvailable = () => { |
| 21 |
const getBlockType = window.wp?.blocks?.getBlockType; |
| 22 |
if (typeof getBlockType !== 'function') return true; |
| 23 |
return QE_REQUIRED_BLOCKS.every((name) => !!getBlockType(name)); |
| 24 |
}; |
| 25 |
|
| 26 |
// Foreign (non-core) rich-text formats register into the shared wp.richText |
| 27 |
// registry, and <BlockToolbar> then auto-renders a button for each — e.g. |
| 28 |
// Spectra's `zipai/chat` "AI Assistant" lands between our alignment and bold |
| 29 |
// controls. Drop every non-core format so only core's bold/italic/link show |
| 30 |
// (the rest sit in the CSS-hidden "More" overflow) beside our own buttons. |
| 31 |
// core/text-color stays registered — our ColorButton serializes through it — |
| 32 |
// and content using a dropped format round-trips verbatim via core/unknown, |
| 33 |
// so this never corrupts a save. |
| 34 |
export const pruneForeignFormats = () => { |
| 35 |
const rt = window.wp?.richText; |
| 36 |
if (typeof rt?.unregisterFormatType !== 'function') return; |
| 37 |
// getFormatTypes lives on the core/rich-text data store in current WP; |
| 38 |
// fall back to the rich-text package export for older runtimes. |
| 39 |
const store = window.wp?.data?.select?.('core/rich-text'); |
| 40 |
const types = |
| 41 |
(typeof store?.getFormatTypes === 'function' |
| 42 |
? store.getFormatTypes() |
| 43 |
: rt.getFormatTypes?.()) || []; |
| 44 |
for (const t of types) { |
| 45 |
if (t?.name && !t.name.startsWith('core/')) { |
| 46 |
try { |
| 47 |
rt.unregisterFormatType(t.name); |
| 48 |
} catch { |
| 49 |
// best-effort — another runtime may already have dropped it |
| 50 |
} |
| 51 |
} |
| 52 |
} |
| 53 |
}; |
| 54 |
|
| 55 |
export const ensureRegistered = () => { |
| 56 |
// Register when a block QE edits is missing — not only when the registry is |
| 57 |
// empty. A second plugin that loaded @wordpress/blocks and registered its |
| 58 |
// own block leaves getBlockTypes().length > 0 while core/* is still absent; |
| 59 |
// the old `length === 0` gate skipped registration and QE then round-tripped |
| 60 |
// core/paragraph through a registry that has no core/paragraph. |
| 61 |
if (window.wp?.blocks?.getBlockType && !requiredBlocksAvailable()) { |
| 62 |
try { |
| 63 |
registerCoreBlocks(); |
| 64 |
} catch (e) { |
| 65 |
console.warn('[QE] registerCoreBlocks:', e?.message); |
| 66 |
} |
| 67 |
} |
| 68 |
if ( |
| 69 |
window.wp?.formatLibrary && |
| 70 |
window.wp?.richText?.getFormatTypes?.()?.length === 0 |
| 71 |
) { |
| 72 |
const lib = window.wp.formatLibrary; |
| 73 |
const reg = window.wp.richText.registerFormatType; |
| 74 |
for (const key of Object.keys(lib)) { |
| 75 |
const m = lib[key]; |
| 76 |
if (m?.name && m?.title && !window.wp.richText.getFormatType(m.name)) { |
| 77 |
try { |
| 78 |
reg(m.name, m); |
| 79 |
} catch { |
| 80 |
// idempotent |
| 81 |
} |
| 82 |
} |
| 83 |
} |
| 84 |
} |
| 85 |
pruneForeignFormats(); |
| 86 |
return requiredBlocksAvailable(); |
| 87 |
}; |
| 88 |
ensureRegistered(); |
| 89 |
|
| 90 |
import { track } from '@shared/lib/track'; |
| 91 |
import { parse, serialize } from '@wordpress/blocks'; |
| 92 |
import { Popover } from '@wordpress/components'; |
| 93 |
import { useDispatch, useRegistry, useSelect } from '@wordpress/data'; |
| 94 |
import { createPortal, useEffect, useRef, useState } from '@wordpress/element'; |
| 95 |
import { __ } from '@wordpress/i18n'; |
| 96 |
import { isAgentEligibleForTarget } from '../lib/agent-gate'; |
| 97 |
import { save } from '../lib/api'; |
| 98 |
import { askAiAboutElement, isAgentAvailable } from '../lib/ask-ai'; |
| 99 |
import { |
| 100 |
getBlockSource, |
| 101 |
invalidateBlockSource, |
| 102 |
} from '../lib/block-source-cache'; |
| 103 |
import { splice } from '../lib/dom'; |
| 104 |
import { friendlyMessage } from '../lib/errors'; |
| 105 |
import { normalizedTextEquals, textFingerprint } from '../lib/fingerprint'; |
| 106 |
import { fetchLinkSuggestions } from '../lib/link-suggestions'; |
| 107 |
import { registerSaver, unregisterSaver } from '../lib/save-bridge'; |
| 108 |
import { useQuickEditStore } from '../state/store'; |
| 109 |
import { pushUndo } from '../state/undo'; |
| 110 |
import { ColorButton } from './toolbar/ColorButton'; |
| 111 |
import { HeadingLevelButton } from './toolbar/HeadingLevelButton'; |
| 112 |
import { TextAlignButtons } from './toolbar/TextAlignButtons'; |
| 113 |
|
| 114 |
// AutoSelectFirstBlock needs the rich-text attribute key per block name — |
| 115 |
// selectBlock alone doesn't flip RichText's isSelected; BlockToolbar reads |
| 116 |
// off selectionStart.attributeKey. |
| 117 |
const RICHTEXT_ATTR_BY_BLOCK = { |
| 118 |
'core/paragraph': 'content', |
| 119 |
'core/heading': 'content', |
| 120 |
'core/verse': 'content', |
| 121 |
'core/button': 'text', |
| 122 |
'core/pullquote': 'value', |
| 123 |
'core/code': 'content', |
| 124 |
'core/preformatted': 'content', |
| 125 |
}; |
| 126 |
|
| 127 |
// A header phone CTA is a paragraph whose entire text is a single `tel:` link — |
| 128 |
// an inline rich-text format, unlike a button's block-level link. When the whole |
| 129 |
// block is one link, return its [start, end] offsets so the caller can select |
| 130 |
// it: an active core/link format makes WP surface its inline link editor on open |
| 131 |
// and point the toolbar link button at the existing link instead of creating a |
| 132 |
// new one. Returns null for plain text or a paragraph only partly linked, which |
| 133 |
// stay at a collapsed caret (no spurious link UI). Reads the live rich-text |
| 134 |
// runtime; degrades to null when it's absent. |
| 135 |
const fullLinkRange = (content) => { |
| 136 |
const create = window.wp?.richText?.create; |
| 137 |
if (typeof create !== 'function') return null; |
| 138 |
const html = |
| 139 |
typeof content === 'string' ? content : content?.toHTMLString?.(); |
| 140 |
// No anchor → can't be a single link. Bail before the rich-text parse |
| 141 |
// below, which otherwise runs on every block open (plain paragraph, |
| 142 |
// heading, button) for no reason. |
| 143 |
if (!html || !html.includes('<a')) return null; |
| 144 |
let value; |
| 145 |
try { |
| 146 |
value = create({ html }); |
| 147 |
} catch { |
| 148 |
return null; |
| 149 |
} |
| 150 |
const length = value?.text?.length ?? 0; |
| 151 |
if (!length) return null; |
| 152 |
for (let i = 0; i < length; i++) { |
| 153 |
const formats = value.formats?.[i]; |
| 154 |
if (!formats?.some?.((f) => f?.type === 'core/link')) return null; |
| 155 |
} |
| 156 |
return { start: 0, end: length }; |
| 157 |
}; |
| 158 |
|
| 159 |
const AutoSelectFirstBlock = () => { |
| 160 |
const dispatch = useDispatch('core/block-editor'); |
| 161 |
const registry = useRegistry(); |
| 162 |
const first = useSelect((select) => { |
| 163 |
const editor = select('core/block-editor'); |
| 164 |
const clientId = editor.getBlockOrder()[0]; |
| 165 |
if (!clientId) return null; |
| 166 |
return { clientId, name: editor.getBlock(clientId)?.name }; |
| 167 |
}, []); |
| 168 |
useEffect(() => { |
| 169 |
if (!first || !dispatch?.selectBlock) return; |
| 170 |
dispatch.selectBlock(first.clientId, 0); |
| 171 |
const attrKey = RICHTEXT_ATTR_BY_BLOCK[first.name]; |
| 172 |
if (attrKey && dispatch.selectionChange) { |
| 173 |
// Read the block content imperatively so this effect keys off the |
| 174 |
// stable block identity only. Pulling attributes into the reactive |
| 175 |
// `first` above would re-run it on every keystroke and reset the |
| 176 |
// caret to offset 0, reversing the typed text. |
| 177 |
const block = registry |
| 178 |
.select('core/block-editor') |
| 179 |
.getBlock(first.clientId); |
| 180 |
const linkRange = fullLinkRange(block?.attributes?.[attrKey]); |
| 181 |
dispatch.selectionChange( |
| 182 |
first.clientId, |
| 183 |
attrKey, |
| 184 |
linkRange?.start ?? 0, |
| 185 |
linkRange?.end ?? 0, |
| 186 |
); |
| 187 |
} |
| 188 |
// preventScroll keeps the scroll-preservation effect intact. |
| 189 |
// Two RAFs wait for React commit + browser paint. |
| 190 |
let r2 = 0; |
| 191 |
const r1 = requestAnimationFrame(() => { |
| 192 |
r2 = requestAnimationFrame(() => { |
| 193 |
const editable = document.querySelector( |
| 194 |
'.extendify-quick-edit-canvas .block-editor-rich-text__editable', |
| 195 |
); |
| 196 |
if (editable && document.body.contains(editable)) { |
| 197 |
editable.focus({ preventScroll: true }); |
| 198 |
} |
| 199 |
}); |
| 200 |
}); |
| 201 |
return () => { |
| 202 |
cancelAnimationFrame(r1); |
| 203 |
if (r2) cancelAnimationFrame(r2); |
| 204 |
}; |
| 205 |
}, [first, dispatch, registry]); |
| 206 |
return null; |
| 207 |
}; |
| 208 |
|
| 209 |
// Equivalent to "Detach pattern" in core: drops metadata.patternName so |
| 210 |
// block-editor doesn't lock the block and suppress the format toolbar. |
| 211 |
const sanitizeForEditor = (block) => { |
| 212 |
if (!block || typeof block !== 'object') return block; |
| 213 |
const meta = block.attributes?.metadata; |
| 214 |
const next = { ...block }; |
| 215 |
if (meta && 'patternName' in meta) { |
| 216 |
const { patternName: _drop, ...rest } = meta; |
| 217 |
const nextMeta = Object.keys(rest).length ? rest : undefined; |
| 218 |
next.attributes = { ...block.attributes }; |
| 219 |
if (nextMeta) { |
| 220 |
next.attributes.metadata = nextMeta; |
| 221 |
} else { |
| 222 |
delete next.attributes.metadata; |
| 223 |
} |
| 224 |
} |
| 225 |
if (Array.isArray(block.innerBlocks) && block.innerBlocks.length) { |
| 226 |
next.innerBlocks = block.innerBlocks.map(sanitizeForEditor); |
| 227 |
} |
| 228 |
return next; |
| 229 |
}; |
| 230 |
|
| 231 |
// Walk up looking for an ancestor with a non-transparent background so the |
| 232 |
// editor host can be made opaque. The canvas grows downward as the user types, |
| 233 |
// extending past the live block's bounds; without a background, the next block |
| 234 |
// in flow (still mounted, just not pushed down by our absolutely-positioned |
| 235 |
// host) bleeds through behind the editor. |
| 236 |
const findOpaqueBackground = (el) => { |
| 237 |
// Inside a cover block: extract the overlay tint from the cover's |
| 238 |
// `.wp-block-cover__background` direct-child span. Gutenberg paints |
| 239 |
// the dim/overlay there, not on the cover div itself — the cover's |
| 240 |
// own background-color is almost always transparent. The dim ratio |
| 241 |
// shows up as CSS `opacity` on the span (via `has-background-dim-N`), |
| 242 |
// so combine the span's bg-color with its opacity into a single rgba |
| 243 |
// the host can use as a flat background-color (host opacity stays 1 |
| 244 |
// so descendant text isn't affected). |
| 245 |
// Image-only covers (dim-0 or transparent overlay) fall back to a |
| 246 |
// semi-transparent black floor so overflow text typed past the |
| 247 |
// cover's height has a legible backdrop. Canvas-only; never |
| 248 |
// persisted to save. |
| 249 |
const cover = el?.closest?.('.wp-block-cover'); |
| 250 |
if (cover) { |
| 251 |
const span = cover.querySelector(':scope > .wp-block-cover__background'); |
| 252 |
if (span) { |
| 253 |
const style = window.getComputedStyle(span); |
| 254 |
const opacity = parseFloat(style.opacity); |
| 255 |
const match = style.backgroundColor.match( |
| 256 |
/^rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)$/, |
| 257 |
); |
| 258 |
if (match && opacity > 0.01) { |
| 259 |
const [, r, g, b, a = '1'] = match; |
| 260 |
const alpha = parseFloat(a) * opacity; |
| 261 |
if (alpha > 0.01) { |
| 262 |
return `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`; |
| 263 |
} |
| 264 |
} |
| 265 |
} |
| 266 |
return 'rgba(0, 0, 0, 0.5)'; |
| 267 |
} |
| 268 |
let n = el?.parentElement; |
| 269 |
while (n && n !== document.documentElement) { |
| 270 |
const bg = window.getComputedStyle(n).backgroundColor; |
| 271 |
if (bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)') { |
| 272 |
return bg; |
| 273 |
} |
| 274 |
n = n.parentElement; |
| 275 |
} |
| 276 |
return ''; |
| 277 |
}; |
| 278 |
|
| 279 |
// Compensates for the Agent sidebar's wp-site-blocks scale so editor host |
| 280 |
// coords line up with viewport-pixel rects. |
| 281 |
const getAncestorScale = (el) => { |
| 282 |
let scale = 1; |
| 283 |
let n = el?.parentElement; |
| 284 |
while (n && n !== document.body) { |
| 285 |
const t = window.getComputedStyle(n).transform; |
| 286 |
if (t && t !== 'none') { |
| 287 |
const m = /matrix\(([^)]+)\)/.exec(t); |
| 288 |
if (m) { |
| 289 |
const a = parseFloat(m[1].split(',')[0]); |
| 290 |
if (Number.isFinite(a) && a > 0) scale *= a; |
| 291 |
} |
| 292 |
} |
| 293 |
n = n.parentElement; |
| 294 |
} |
| 295 |
return scale; |
| 296 |
}; |
| 297 |
|
| 298 |
export const BlockTextEditor = ({ selected }) => { |
| 299 |
const [blocks, setBlocks] = useState(null); |
| 300 |
const [loadError, setLoadError] = useState(null); |
| 301 |
const [saving, setSaving] = useState(false); |
| 302 |
const [saveError, setSaveError] = useState(null); |
| 303 |
const [host, setHost] = useState(null); |
| 304 |
const [barHost, setBarHost] = useState(null); |
| 305 |
// saveRef keeps the freshest handler since the Cmd+Enter binding is one-shot. |
| 306 |
const saveRef = useRef(null); |
| 307 |
const beforeRawBlockRef = useRef(null); |
| 308 |
const statusRef = useRef(null); |
| 309 |
|
| 310 |
const clearSelected = useQuickEditStore((s) => s.clearSelected); |
| 311 |
// Singleton store: stale selection from a prior session leaves BlockToolbar empty on remount. |
| 312 |
const blockEditorDispatch = useDispatch('core/block-editor'); |
| 313 |
|
| 314 |
const sourceKind = selected.source?.kind ?? null; |
| 315 |
const agentSupportedSource = sourceKind === 'post' || sourceKind === null; |
| 316 |
const aiAvailable = |
| 317 |
isAgentAvailable() && |
| 318 |
agentSupportedSource && |
| 319 |
isAgentEligibleForTarget(selected); |
| 320 |
|
| 321 |
const handleAskAiClick = async () => { |
| 322 |
const el = selected.el; |
| 323 |
// Await save before staging — the hover-bar bridge reads save's |
| 324 |
// setSelected(null) as "user dismissed the canvas" and would clear |
| 325 |
// agentBlock if askAi staged it first. Save's clearSelected runs |
| 326 |
// while agentBlock is still null, so the bridge no-ops; askAi then |
| 327 |
// stages cleanly. The captured el can detach via splice but only |
| 328 |
// its agent-block id is used downstream. |
| 329 |
await saveRef.current?.(); |
| 330 |
askAiAboutElement(el); |
| 331 |
}; |
| 332 |
|
| 333 |
// post and template-part both load through get-block-code (the cache keys |
| 334 |
// them apart); anything else (product/wpforms/nav) uses its own editor and |
| 335 |
// never reaches this canvas. |
| 336 |
const loadableSource = |
| 337 |
sourceKind === 'post' || sourceKind === 'template-part' |
| 338 |
? selected.source |
| 339 |
: null; |
| 340 |
useEffect(() => { |
| 341 |
if (!loadableSource || !selected.blockId) return; |
| 342 |
// Refuse to edit through a runtime that can't provide the core blocks the |
| 343 |
// canvas serializes — a clean "try again" beats silently round-tripping |
| 344 |
// core/paragraph through a foreign @wordpress/blocks (block invalidation |
| 345 |
// or markup drift on save). |
| 346 |
if (!ensureRegistered()) { |
| 347 |
setLoadError(friendlyMessage()); |
| 348 |
return; |
| 349 |
} |
| 350 |
let alive = true; |
| 351 |
// Reuses the hover-bar prefetch so the editor mounts with no round-trip wait. |
| 352 |
getBlockSource(loadableSource, selected.blockId) |
| 353 |
.then((res) => { |
| 354 |
if (!alive) return; |
| 355 |
if (!res?.block) { |
| 356 |
setLoadError(friendlyMessage()); |
| 357 |
return; |
| 358 |
} |
| 359 |
// Capture pre-parse — parse/serialize would nudge the markup |
| 360 |
// and the undo entry needs the server's exact bytes back. |
| 361 |
beforeRawBlockRef.current = res.block; |
| 362 |
const parsed = parse(res.block).map(sanitizeForEditor); |
| 363 |
setBlocks(parsed); |
| 364 |
}) |
| 365 |
.catch((err) => { |
| 366 |
if (!alive) return; |
| 367 |
setLoadError(friendlyMessage(err)); |
| 368 |
}); |
| 369 |
return () => { |
| 370 |
alive = false; |
| 371 |
}; |
| 372 |
}, [loadableSource, selected.blockId]); |
| 373 |
|
| 374 |
// Flush stale singleton state before BlockEditorProvider's resetBlocks runs. |
| 375 |
useEffect(() => { |
| 376 |
if (!blockEditorDispatch?.resetBlocks) return; |
| 377 |
blockEditorDispatch.resetBlocks([]); |
| 378 |
blockEditorDispatch.clearSelectedBlock?.(); |
| 379 |
blockEditorDispatch.resetSelection?.( |
| 380 |
{ clientId: null, attributeKey: null, offset: 0 }, |
| 381 |
{ clientId: null, attributeKey: null, offset: 0 }, |
| 382 |
); |
| 383 |
}, [blockEditorDispatch, selected.blockId]); |
| 384 |
|
| 385 |
// Sibling-of-live mount: shares the live block's transform context and |
| 386 |
// stays outside the Tailwind preflight scope so BlockEditor keeps its |
| 387 |
// native styling. Live element is hidden later, after blocks paint. |
| 388 |
useEffect(() => { |
| 389 |
const live = selected.el; |
| 390 |
if (!live?.isConnected || !live.parentNode) return; |
| 391 |
|
| 392 |
const node = document.createElement('div'); |
| 393 |
node.className = 'extendify-quick-edit extendify-quick-edit-host'; |
| 394 |
node.dataset.test = 'quick-edit-host'; |
| 395 |
node.style.position = 'absolute'; |
| 396 |
node.style.top = '0px'; |
| 397 |
node.style.left = '0px'; |
| 398 |
node.style.margin = '0'; |
| 399 |
node.style.zIndex = '99998'; |
| 400 |
// Stays hidden until the editable's text catches up to the live |
| 401 |
// block's text — BlockEditorProvider's sub-registry briefly paints |
| 402 |
// the prior session's blocks via use-block-sync's post-commit |
| 403 |
// resetBlocks effect. Reveal in lockstep with the live-hide swap |
| 404 |
// below so the user never sees the wrong content inside the canvas. |
| 405 |
node.style.visibility = 'hidden'; |
| 406 |
const opaqueBg = findOpaqueBackground(live); |
| 407 |
if (opaqueBg) node.style.backgroundColor = opaqueBg; |
| 408 |
// Cover-block-specific: also copy the cover's background-image to |
| 409 |
// the host so overflow text (typed past the live block's height) |
| 410 |
// reads on the cover's image rather than the bare page background |
| 411 |
// below. The image is "scoped" to the host (heading-sized) so it |
| 412 |
// won't perfectly continue from the cover, but it's enough for |
| 413 |
// legibility. Combined with the cover's overlay color above |
| 414 |
// (handled by findOpaqueBackground), the host visually echoes the |
| 415 |
// cover. Image-only covers (no overlay) still get the image — the |
| 416 |
// overlay color may be transparent in that case. |
| 417 |
const cover = live?.closest?.('.wp-block-cover'); |
| 418 |
if (cover) { |
| 419 |
const imgBg = cover.querySelector('.wp-block-cover__image-background'); |
| 420 |
if (imgBg) { |
| 421 |
const imgStyle = window.getComputedStyle(imgBg); |
| 422 |
if (imgStyle.backgroundImage && imgStyle.backgroundImage !== 'none') { |
| 423 |
node.style.backgroundImage = imgStyle.backgroundImage; |
| 424 |
node.style.backgroundSize = imgStyle.backgroundSize || 'cover'; |
| 425 |
node.style.backgroundPosition = |
| 426 |
imgStyle.backgroundPosition || 'center'; |
| 427 |
node.style.backgroundRepeat = 'no-repeat'; |
| 428 |
} |
| 429 |
} |
| 430 |
} |
| 431 |
// Pin the live anchor's computed color so the canvas's link doesn't |
| 432 |
// flip to the theme's :hover color while the user's cursor sits over |
| 433 |
// the text area mid-edit. live is `visibility: hidden`, so its own |
| 434 |
// :hover never fires and its computed color is the resting value. |
| 435 |
// Exposed as a CSS var consumed by quick-edit.css. |
| 436 |
const liveLink = live.querySelector?.('a'); |
| 437 |
if (liveLink) { |
| 438 |
const liveColor = window.getComputedStyle(liveLink).color; |
| 439 |
if (liveColor) node.style.setProperty('--qe-link-color', liveColor); |
| 440 |
} |
| 441 |
live.parentNode.insertBefore(node, live.nextSibling); |
| 442 |
|
| 443 |
// Body-level fixed bar so it lives in the root stacking context. |
| 444 |
// Inside .wp-block-cover, the cover-background span (opacity:0, its |
| 445 |
// own stacking context) hijacks Chrome's hit-testing and the toolbar |
| 446 |
// becomes visible-but-unclickable. |
| 447 |
const bar = document.createElement('div'); |
| 448 |
bar.className = 'extendify-quick-edit extendify-quick-edit-floating-bar'; |
| 449 |
bar.dataset.test = 'quick-edit-floating-bar'; |
| 450 |
bar.style.position = 'fixed'; |
| 451 |
bar.style.zIndex = '100002'; |
| 452 |
bar.style.visibility = 'hidden'; |
| 453 |
bar.setAttribute('role', 'toolbar'); |
| 454 |
bar.setAttribute('aria-label', __('Quick Edit toolbar', 'extendify-local')); |
| 455 |
// preventDefault on mousedown so a toolbar click doesn't blur the contenteditable. |
| 456 |
bar.addEventListener('mousedown', (ev) => ev.preventDefault()); |
| 457 |
document.body.appendChild(bar); |
| 458 |
|
| 459 |
const align = () => { |
| 460 |
if (!live.isConnected || !node.isConnected) return; |
| 461 |
const liveRect = live.getBoundingClientRect(); |
| 462 |
const op = node.offsetParent; |
| 463 |
if (!op) return; |
| 464 |
const scale = getAncestorScale(node) || 1; |
| 465 |
// Derive the host's positioning origin from where it currently |
| 466 |
// renders, not from offsetParent's box: a static <body> is the |
| 467 |
// reported offsetParent but an absolute child resolves against the |
| 468 |
// viewport, not body's box. So when the admin bar / Simple Toolbar |
| 469 |
// adds `html { margin-top:32px }` (e.g. on Playground), subtracting |
| 470 |
// body's rect lifts the host by that margin and the floating bar |
| 471 |
// overlaps the editable. Measuring the live origin is correct for |
| 472 |
// every case (static body, positioned ancestor, scaled sidebar) and |
| 473 |
// self-corrects instead of guessing the containing block. |
| 474 |
const curTop = parseFloat(node.style.top) || 0; |
| 475 |
const curLeft = parseFloat(node.style.left) || 0; |
| 476 |
const nodeRect = node.getBoundingClientRect(); |
| 477 |
const originTop = nodeRect.top - curTop * scale; |
| 478 |
const originLeft = nodeRect.left - curLeft * scale; |
| 479 |
node.style.top = `${(liveRect.top - originTop) / scale}px`; |
| 480 |
node.style.left = `${(liveRect.left - originLeft) / scale}px`; |
| 481 |
node.style.width = `${liveRect.width / scale}px`; |
| 482 |
node.style.minHeight = `${liveRect.height / scale}px`; |
| 483 |
if (bar.isConnected) { |
| 484 |
bar.style.top = `${liveRect.top - 52}px`; |
| 485 |
// Clamp horizontally — for a live element near the right edge of |
| 486 |
// the viewport the bar's full width would otherwise extend off |
| 487 |
// screen (the bar is fixed-positioned and ~600px wide once the |
| 488 |
// toolbar groups + colors + actions render). |
| 489 |
const bw = bar.offsetWidth; |
| 490 |
const vw = document.documentElement.clientWidth; |
| 491 |
let left = liveRect.left; |
| 492 |
if (bw && left + bw > vw - 4) { |
| 493 |
left = Math.max(4, vw - bw - 4); |
| 494 |
} |
| 495 |
bar.style.left = `${Math.max(4, left)}px`; |
| 496 |
} |
| 497 |
}; |
| 498 |
align(); |
| 499 |
// Re-align over two frames in case font/image loads shift measurements. |
| 500 |
requestAnimationFrame(() => { |
| 501 |
align(); |
| 502 |
requestAnimationFrame(align); |
| 503 |
}); |
| 504 |
|
| 505 |
setHost(node); |
| 506 |
setBarHost(bar); |
| 507 |
|
| 508 |
const ro = new ResizeObserver(align); |
| 509 |
ro.observe(live); |
| 510 |
// Also watch the bar: it mounts at 0px width and only reaches its real |
| 511 |
// ~600px once the toolbar content portals in, so the right-edge clamp |
| 512 |
// in align() no-ops on the first pass and must recompute when the bar |
| 513 |
// resizes. align() only moves the bar (top/left), never resizes it, so |
| 514 |
// this can't loop. |
| 515 |
ro.observe(bar); |
| 516 |
window.addEventListener('scroll', align, { |
| 517 |
capture: true, |
| 518 |
passive: true, |
| 519 |
}); |
| 520 |
window.addEventListener('resize', align); |
| 521 |
|
| 522 |
return () => { |
| 523 |
ro.disconnect(); |
| 524 |
window.removeEventListener('scroll', align, { capture: true }); |
| 525 |
window.removeEventListener('resize', align); |
| 526 |
live.style.visibility = ''; |
| 527 |
node.remove(); |
| 528 |
bar.remove(); |
| 529 |
setHost(null); |
| 530 |
setBarHost(null); |
| 531 |
}; |
| 532 |
}, [selected.el]); |
| 533 |
|
| 534 |
// Gutenberg's useScrollSelectionIntoView + browser focus-scroll yank |
| 535 |
// the page when a near-top block gets focus; no-op the scroll APIs |
| 536 |
// for the mount window so reverting after the fact doesn't stutter. |
| 537 |
useEffect(() => { |
| 538 |
if (!host) return; |
| 539 |
const origScrollIntoView = Element.prototype.scrollIntoView; |
| 540 |
const origWindowScrollTo = window.scrollTo.bind(window); |
| 541 |
const origWindowScroll = window.scroll.bind(window); |
| 542 |
Element.prototype.scrollIntoView = () => {}; |
| 543 |
window.scrollTo = () => {}; |
| 544 |
window.scroll = () => {}; |
| 545 |
const t = setTimeout(() => { |
| 546 |
Element.prototype.scrollIntoView = origScrollIntoView; |
| 547 |
window.scrollTo = origWindowScrollTo; |
| 548 |
window.scroll = origWindowScroll; |
| 549 |
}, 800); |
| 550 |
return () => { |
| 551 |
clearTimeout(t); |
| 552 |
Element.prototype.scrollIntoView = origScrollIntoView; |
| 553 |
window.scrollTo = origWindowScrollTo; |
| 554 |
window.scroll = origWindowScroll; |
| 555 |
}; |
| 556 |
}, [host]); |
| 557 |
|
| 558 |
// Defer the live→canvas swap until BlockEditor's editable shows text |
| 559 |
// that matches the live block. Two distinct flashes converge here: |
| 560 |
// (1) the editable is empty for ~1-2 frames after BlockEditorProvider |
| 561 |
// mounts, and (2) the sub-registry's BlockList paints the prior |
| 562 |
// session's content for several frames before use-block-sync's post- |
| 563 |
// commit resetBlocks lands. A fixed RAF delay can't see (2) because the |
| 564 |
// real delay is content-driven, not frame-count-driven. Poll until the |
| 565 |
// editable's textContent matches the live block's, then reveal the |
| 566 |
// host + bar and hide the live element in one tick. The cap (~33 |
| 567 |
// frames ≈ 500ms) bounds the wait in case the editable never converges; |
| 568 |
// after the cap, swap anyway. The match folds wptexturize differences |
| 569 |
// (see normalizedTextEquals) — without that, every smart-quote block |
| 570 |
// missed the content match and rode the cap, revealing ~½s late. |
| 571 |
// Gate on `hasBlocks` (boolean) rather than `blocks` so the effect |
| 572 |
// doesn't re-run on every keystroke — that was unhiding the live |
| 573 |
// element between renders and bleeding the pre-edit text through the |
| 574 |
// transparent canvas ("ghost text" while typing). |
| 575 |
const hasBlocks = !!blocks; |
| 576 |
useEffect(() => { |
| 577 |
if (!host || !hasBlocks) return; |
| 578 |
const live = selected.el; |
| 579 |
if (!live?.isConnected) return; |
| 580 |
const liveText = live.textContent ?? ''; |
| 581 |
|
| 582 |
let raf = 0; |
| 583 |
let attempts = 0; |
| 584 |
const swap = () => { |
| 585 |
if (!live.isConnected) return; |
| 586 |
live.style.visibility = 'hidden'; |
| 587 |
if (host.isConnected) host.style.visibility = ''; |
| 588 |
if (barHost) barHost.style.visibility = ''; |
| 589 |
// Copy the live element's computed text-shaping properties onto |
| 590 |
// the canvas's rendered block. BlockEditor cascades its own |
| 591 |
// defaults for letter-spacing / kerning / variant / white-space |
| 592 |
// that can differ from the theme's computed values and shift |
| 593 |
// wrap boundaries (a single-line heading splitting across an |
| 594 |
// extra line in edit mode). The whiteSpace + lineBreak pair is |
| 595 |
// load-bearing because the editor forces `pre-wrap` / |
| 596 |
// `after-white-space` on the editable; those break the same |
| 597 |
// string at different points than the live render's `normal` / |
| 598 |
// `auto`. |
| 599 |
const editable = host.querySelector('.block-editor-rich-text__editable'); |
| 600 |
if (editable) { |
| 601 |
const liveStyle = window.getComputedStyle(live); |
| 602 |
for (const prop of [ |
| 603 |
'letterSpacing', |
| 604 |
'wordSpacing', |
| 605 |
'fontKerning', |
| 606 |
'fontFeatureSettings', |
| 607 |
'fontVariant', |
| 608 |
'fontVariantLigatures', |
| 609 |
'fontVariantNumeric', |
| 610 |
'fontStretch', |
| 611 |
'textRendering', |
| 612 |
'whiteSpace', |
| 613 |
'lineBreak', |
| 614 |
]) { |
| 615 |
editable.style[prop] = liveStyle[prop]; |
| 616 |
} |
| 617 |
} |
| 618 |
}; |
| 619 |
const poll = () => { |
| 620 |
const editable = host.querySelector('.block-editor-rich-text__editable'); |
| 621 |
// Reveal once the editable's text catches up to the live block's. |
| 622 |
// normalizedTextEquals folds wptexturize (curly vs straight quote) |
| 623 |
// so a smart-punctuation block isn't held unequal until the cap; |
| 624 |
// two empty texts compare equal (an empty block being edited). |
| 625 |
// Cap at ~500ms in case it never converges (e.g. font ligatures). |
| 626 |
const matched = |
| 627 |
editable && normalizedTextEquals(editable.textContent, liveText); |
| 628 |
if (matched || ++attempts > 33) { |
| 629 |
swap(); |
| 630 |
return; |
| 631 |
} |
| 632 |
raf = requestAnimationFrame(poll); |
| 633 |
}; |
| 634 |
raf = requestAnimationFrame(poll); |
| 635 |
return () => { |
| 636 |
if (raf) cancelAnimationFrame(raf); |
| 637 |
if (live.isConnected) live.style.visibility = ''; |
| 638 |
}; |
| 639 |
}, [host, hasBlocks, selected.el, barHost]); |
| 640 |
|
| 641 |
// Capture phase beats the agent's Escape handler, which would close the chat. |
| 642 |
useEffect(() => { |
| 643 |
const onKey = (e) => { |
| 644 |
if (e.key === 'Escape') { |
| 645 |
e.preventDefault(); |
| 646 |
e.stopPropagation(); |
| 647 |
clearSelected(); |
| 648 |
return; |
| 649 |
} |
| 650 |
if (e.key !== 'Enter') return; |
| 651 |
if (e.metaKey || e.ctrlKey) { |
| 652 |
// A focused link popover (LinkControl, for both button and |
| 653 |
// phone-paragraph links) holds the typed URL until it commits. |
| 654 |
// Hijacking Cmd+Enter here would save the stale href and drop |
| 655 |
// the edit — let the key reach the popover so it applies first. |
| 656 |
if (e.target?.closest?.('.block-editor-link-control')) return; |
| 657 |
e.preventDefault(); |
| 658 |
e.stopPropagation(); |
| 659 |
saveRef.current?.(); |
| 660 |
return; |
| 661 |
} |
| 662 |
// Plain Enter inside the canvas → soft line break, not a new block. |
| 663 |
// Multi-block saves would need server-side renumbering of TagBlocks |
| 664 |
// IDs after the insert, plus a page reload to pick them up; soft |
| 665 |
// breaks keep blocks.length === 1 and ride the single-block splice |
| 666 |
// path. Shift+Enter is Gutenberg's native soft break — pass through. |
| 667 |
if (e.shiftKey || e.altKey) return; |
| 668 |
if (!host || !host.contains(e.target)) return; |
| 669 |
const editable = e.target.closest?.('[contenteditable="true"]'); |
| 670 |
if (!editable || !host.contains(editable)) return; |
| 671 |
const sel = e.target.ownerDocument?.defaultView?.getSelection?.(); |
| 672 |
if (!sel || sel.rangeCount === 0) return; |
| 673 |
e.preventDefault(); |
| 674 |
e.stopPropagation(); |
| 675 |
const range = sel.getRangeAt(0); |
| 676 |
range.deleteContents(); |
| 677 |
const br = e.target.ownerDocument.createElement('br'); |
| 678 |
// rich-text's createFromElement (create.cjs:323) drops any <br> |
| 679 |
// without `data-rich-text-line-break` from the value it reads |
| 680 |
// back from the editable. Without the attribute, the input-event |
| 681 |
// dispatch below runs createRecord → handleChange and the new |
| 682 |
// record has no line break, so React reverts the DOM. Match the |
| 683 |
// attribute Gutenberg adds for its own Shift+Enter <br>s. |
| 684 |
br.setAttribute('data-rich-text-line-break', 'true'); |
| 685 |
range.insertNode(br); |
| 686 |
range.setStartAfter(br); |
| 687 |
range.collapse(true); |
| 688 |
sel.removeAllRanges(); |
| 689 |
sel.addRange(range); |
| 690 |
editable.dispatchEvent( |
| 691 |
new InputEvent('input', { |
| 692 |
bubbles: true, |
| 693 |
inputType: 'insertLineBreak', |
| 694 |
}), |
| 695 |
); |
| 696 |
}; |
| 697 |
document.addEventListener('keydown', onKey, true); |
| 698 |
return () => document.removeEventListener('keydown', onKey, true); |
| 699 |
}, [clearSelected, host]); |
| 700 |
|
| 701 |
const handleSave = async () => { |
| 702 |
if (saving) return; |
| 703 |
if (!blocks) { |
| 704 |
clearSelected(); |
| 705 |
return; |
| 706 |
} |
| 707 |
// Move focus off the Save button before it disables so focus isn't |
| 708 |
// stranded on a disabled control; the status node then announces |
| 709 |
// "Saving…" politely. |
| 710 |
statusRef.current?.focus({ preventScroll: true }); |
| 711 |
setSaving(true); |
| 712 |
setSaveError(null); |
| 713 |
const snap = selected; |
| 714 |
const beforeRawBlock = beforeRawBlockRef.current; |
| 715 |
const fingerprint = textFingerprint(snap.el); |
| 716 |
try { |
| 717 |
const rawBlock = serialize(blocks); |
| 718 |
const res = await save({ |
| 719 |
source: snap.source, |
| 720 |
blockId: snap.blockId, |
| 721 |
blockType: snap.blockType, |
| 722 |
rawBlock, |
| 723 |
fingerprint, |
| 724 |
}); |
| 725 |
if (!res.rendered) throw new Error('No rendered HTML in response'); |
| 726 |
const newEl = splice(snap.el, res.rendered); |
| 727 |
if (!newEl) throw new Error('Splice failed'); |
| 728 |
invalidateBlockSource(snap.source, snap.blockId); |
| 729 |
if (beforeRawBlock) { |
| 730 |
pushUndo({ |
| 731 |
kind: 'block', |
| 732 |
source: snap.source, |
| 733 |
blockId: snap.blockId, |
| 734 |
blockType: snap.blockType, |
| 735 |
rawBlock: beforeRawBlock, |
| 736 |
}); |
| 737 |
} |
| 738 |
track('save', { kind: 'block', blockType: snap.blockType }); |
| 739 |
clearSelected(); |
| 740 |
} catch (err) { |
| 741 |
track('save_failed', { |
| 742 |
kind: 'block', |
| 743 |
blockType: snap.blockType, |
| 744 |
reason: err?.status || err?.message, |
| 745 |
}); |
| 746 |
setSaveError(friendlyMessage(err)); |
| 747 |
setSaving(false); |
| 748 |
} |
| 749 |
}; |
| 750 |
|
| 751 |
saveRef.current = handleSave; |
| 752 |
|
| 753 |
useEffect(() => { |
| 754 |
const proxy = (options) => saveRef.current?.(options); |
| 755 |
registerSaver(proxy); |
| 756 |
return () => unregisterSaver(proxy); |
| 757 |
}, []); |
| 758 |
|
| 759 |
if (loadError) { |
| 760 |
return <ErrorPill message={loadError} onDismiss={clearSelected} />; |
| 761 |
} |
| 762 |
// Render the canvas as soon as the host exists so the editor outline |
| 763 |
// flows continuously from the hover bar's outline. |
| 764 |
if (!host) return null; |
| 765 |
|
| 766 |
return ( |
| 767 |
<> |
| 768 |
{createPortal( |
| 769 |
<div className="extendify-quick-edit-canvas"> |
| 770 |
{blocks ? ( |
| 771 |
<BlockEditorProvider |
| 772 |
value={blocks} |
| 773 |
onChange={setBlocks} |
| 774 |
onInput={setBlocks} |
| 775 |
settings={{ |
| 776 |
hasFixedToolbar: true, |
| 777 |
__experimentalSetIsInserterOpened: () => {}, |
| 778 |
// Sub-registry doesn't inherit the global LinkControl backend. |
| 779 |
__experimentalFetchLinkSuggestions: fetchLinkSuggestions, |
| 780 |
}} |
| 781 |
> |
| 782 |
<AutoSelectFirstBlock /> |
| 783 |
{barHost |
| 784 |
? createPortal( |
| 785 |
<div |
| 786 |
data-test="quick-edit-floating-bar-inner" |
| 787 |
className="extendify-quick-edit-floating-bar-inner inline-flex w-max flex-nowrap items-center gap-[4px] whitespace-nowrap rounded-[8px] bg-white p-[6px] font-qe shadow-[0_8px_24px_-6px_rgba(15,23,42,0.25),0_0_0_1px_rgba(15,23,42,0.06)]" |
| 788 |
> |
| 789 |
{selected.blockType === 'core/heading' ? ( |
| 790 |
<div className="relative inline-flex items-center"> |
| 791 |
<HeadingLevelButton /> |
| 792 |
</div> |
| 793 |
) : null} |
| 794 |
<TextAlignButtons /> |
| 795 |
{BlockToolbar ? <BlockToolbar hideDragHandle /> : null} |
| 796 |
{/* `core/button` is controlled by global styles, not inline text |
| 797 |
color — color buttons render only for rich-text blocks. */} |
| 798 |
{selected.blockType === 'core/paragraph' || |
| 799 |
selected.blockType === 'core/heading' ? ( |
| 800 |
<div |
| 801 |
data-test="quick-edit-colors-group" |
| 802 |
className="relative inline-flex items-center gap-[4px] pl-[12px] ml-[4px] before:content-[''] before:absolute before:left-0 before:top-1/2 before:h-[14px] before:w-px before:-translate-y-1/2 before:bg-gray-300" |
| 803 |
> |
| 804 |
<ColorButton |
| 805 |
kind="text" |
| 806 |
label={__('Text color', 'extendify-local')} |
| 807 |
iconClassName="before:content-['A'] after:content-[''] after:absolute after:bottom-[1px] after:left-[2px] after:right-[2px] after:h-[2px] after:rounded-[1px] after:bg-[var(--wp--preset--color--primary,#3b82f6)]" |
| 808 |
/> |
| 809 |
<ColorButton |
| 810 |
kind="highlight" |
| 811 |
label={__('Highlight color', 'extendify-local')} |
| 812 |
iconClassName="before:content-['A'] before:rounded-[3px] before:bg-[#fde047] before:px-[4px] before:py-[2px]" |
| 813 |
/> |
| 814 |
</div> |
| 815 |
) : null} |
| 816 |
<div className="relative inline-flex flex-nowrap items-center gap-[4px] whitespace-nowrap ml-[8px] pl-[12px] before:content-[''] before:absolute before:left-0 before:top-1/2 before:h-[14px] before:w-px before:-translate-y-1/2 before:bg-gray-300"> |
| 817 |
{aiAvailable ? ( |
| 818 |
<button |
| 819 |
type="button" |
| 820 |
data-test="quick-edit-ask-ai" |
| 821 |
className="inline-flex h-[28px] cursor-pointer items-center justify-center gap-[6px] rounded-[6px] border-0 px-[12px] py-0 text-[13px] font-medium leading-[1.4] text-white transition-[background] duration-[120ms] bg-[#3858e9] hover:bg-[#2145e6] disabled:cursor-not-allowed disabled:opacity-60" |
| 822 |
onClick={handleAskAiClick} |
| 823 |
disabled={saving} |
| 824 |
> |
| 825 |
<span aria-hidden="true">✦</span> |
| 826 |
{__('Ask AI', 'extendify-local')} |
| 827 |
</button> |
| 828 |
) : null} |
| 829 |
<button |
| 830 |
type="button" |
| 831 |
data-test="quick-edit-cancel" |
| 832 |
className="inline-flex h-[28px] cursor-pointer items-center justify-center rounded-[6px] border-0 px-[12px] py-0 text-[13px] font-medium leading-[1.4] bg-gray-200 text-gray-900 transition-[background] duration-[120ms] disabled:cursor-not-allowed disabled:opacity-50" |
| 833 |
onClick={clearSelected} |
| 834 |
disabled={saving} |
| 835 |
> |
| 836 |
{__('Cancel', 'extendify-local')} |
| 837 |
</button> |
| 838 |
<button |
| 839 |
type="button" |
| 840 |
data-test="quick-edit-save" |
| 841 |
className="inline-flex h-[28px] cursor-pointer items-center justify-center rounded-[6px] border-0 px-[12px] py-0 text-[13px] font-medium leading-[1.4] bg-gray-900 text-white transition-[background] duration-[120ms] hover:bg-black disabled:cursor-not-allowed disabled:opacity-60" |
| 842 |
onClick={handleSave} |
| 843 |
disabled={saving} |
| 844 |
> |
| 845 |
{saving |
| 846 |
? __('Saving…', 'extendify-local') |
| 847 |
: __('Save', 'extendify-local')} |
| 848 |
</button> |
| 849 |
{/* biome-ignore lint/a11y/useSemanticElements: deliberate live region; <output> changes display + semantics */} |
| 850 |
<div |
| 851 |
ref={statusRef} |
| 852 |
role="status" |
| 853 |
aria-live="polite" |
| 854 |
tabIndex={-1} |
| 855 |
className="extendify-quick-edit-floating-status sr-only" |
| 856 |
> |
| 857 |
{saving ? __('Saving…', 'extendify-local') : ''} |
| 858 |
</div> |
| 859 |
</div> |
| 860 |
</div>, |
| 861 |
barHost, |
| 862 |
) |
| 863 |
: null} |
| 864 |
<BlockTools> |
| 865 |
<WritingFlow> |
| 866 |
<ObserveTyping> |
| 867 |
<BlockList /> |
| 868 |
</ObserveTyping> |
| 869 |
</WritingFlow> |
| 870 |
</BlockTools> |
| 871 |
{/* Inline rich-text popovers (the LinkControl URL editor) |
| 872 |
resolve to the `__unstable-block-tools-after` slot, which |
| 873 |
BlockTools renders inside this canvas. When the edited |
| 874 |
block sits in a sticky header — a containing block with |
| 875 |
overflow:hidden — the position:fixed popover is clipped to |
| 876 |
a sliver (floating-ui's size middleware measures the tiny |
| 877 |
header interior). Rendering the slot at document.body, last |
| 878 |
so it wins the name in the shared SlotFillProvider, lifts |
| 879 |
the popover out of that clip so it measures the viewport. */} |
| 880 |
{/* The wrapper is positioned (quick-edit.css) — without it the |
| 881 |
popover ignores the admin bar margin and covers its anchor. */} |
| 882 |
{createPortal( |
| 883 |
<div className="extendify-quick-edit-popover-slot"> |
| 884 |
<Popover.Slot name="__unstable-block-tools-after" /> |
| 885 |
</div>, |
| 886 |
document.body, |
| 887 |
)} |
| 888 |
</BlockEditorProvider> |
| 889 |
) : null} |
| 890 |
{saveError ? ( |
| 891 |
<div |
| 892 |
data-test="quick-edit-canvas-error" |
| 893 |
className="mt-[8px] rounded-[6px] bg-red-100 px-[12px] py-[8px] text-[13px] text-red-800" |
| 894 |
role="alert" |
| 895 |
> |
| 896 |
{saveError} |
| 897 |
</div> |
| 898 |
) : null} |
| 899 |
</div>, |
| 900 |
host, |
| 901 |
)} |
| 902 |
</> |
| 903 |
); |
| 904 |
}; |
| 905 |
|
| 906 |
const ErrorPill = ({ message, onDismiss }) => ( |
| 907 |
<div |
| 908 |
role="alert" |
| 909 |
style={{ |
| 910 |
position: 'fixed', |
| 911 |
top: 16, |
| 912 |
right: 16, |
| 913 |
zIndex: 99999, |
| 914 |
padding: '8px 12px', |
| 915 |
background: '#fee2e2', |
| 916 |
color: '#991b1b', |
| 917 |
borderRadius: 8, |
| 918 |
fontSize: 13, |
| 919 |
boxShadow: '0 4px 12px rgba(0,0,0,0.1)', |
| 920 |
}} |
| 921 |
> |
| 922 |
{message} |
| 923 |
<button |
| 924 |
type="button" |
| 925 |
aria-label={__('Dismiss', 'extendify-local')} |
| 926 |
onClick={onDismiss} |
| 927 |
style={{ |
| 928 |
marginLeft: 8, |
| 929 |
border: 0, |
| 930 |
background: 'transparent', |
| 931 |
cursor: 'pointer', |
| 932 |
fontWeight: 'bold', |
| 933 |
}} |
| 934 |
> |
| 935 |
× |
| 936 |
</button> |
| 937 |
</div> |
| 938 |
); |
| 939 |
|