PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 0.7.0 All 126 releases
extendify / src / QuickEdit / components / BlockTextEditor.jsx

BlockTextEditor.jsx in Extendify 3.1.5, at src/QuickEdit/components/BlockTextEditor.jsx

939 lines 36.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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