PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 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 All 128 releases
← All changes | src/QuickEdit/lib/hover-bar.js +177 -92 3.1.2 → 3.2.2 View file →
@@ -4,20 +4,20 @@
4 4 import { track } from '@shared/lib/track';
5 5 import { __ } from '@wordpress/i18n';
6 6 import { useEditModeStore } from '../state/edit-mode';
7 7 import { useQuickEditStore } from '../state/store';
8 -import { isAgentEligibleForTarget } from './agent-gate';
8 +import { whenAnimationsSettle } from './after-animations';
9 +import { escapesDynamicBlock, isAgentEligibleForTarget } from './agent-gate';
9 10 import {
10 11 askAiAboutElement,
11 12 hasAgentBlockSelected,
12 13 isAgentAvailable,
13 - isAgentSidebarOpen,
14 - stageAgentBlock,
15 14 subscribeToAgentBlock,
16 15 } from './ask-ai';
17 16 import { prefetchBlockSource } from './block-source-cache';
18 17 import { decideClickAction } from './click-rule';
19 18 import { resolveTarget } from './dom';
19 +import { needsContrastRing } from './over-media';
20 20 import { hasQuickEditModalFor } from './quick-edit-handlers';
21 21 import { hasSaver, saveSelected } from './save-bridge';
22 22 import {
23 23 getTranslatedContext,
@@ -42,8 +42,12 @@
42 42 // Body-level fixed overlay rather than an outline on each block:
43 43 // outline overhang would clip inside an ancestor's overflow:hidden,
44 44 // and a single repositioning overlay gets the smooth-expand feel
45 45 // for free via CSS transitions.
46 +// Must match DOMHighlighter's minimum, or the outline resizes on click.
47 +const MIN_OUTLINE_SIZE = 10;
48 +const framed = (size) => Math.max(size, MIN_OUTLINE_SIZE);
49 +
46 50 const ensureOutline = () => {
47 51 if (hoverOutline) return hoverOutline;
48 52 hoverOutline = document.createElement('div');
49 53 hoverOutline.className =
@@ -57,10 +61,11 @@
57 61 // afford to animate: every scroll event would reset the transition target
58 62 // while the outline is still en route, so the outline visibly trails the
59 63 // content during a drag-scroll ("stays fixed in screen"). Hover-driven
60 64 // updates (block A → block B) keep the spring animation.
61 -const showOutline = (el, { instant = false } = {}) => {
62 - const overlay = ensureOutline();
65 +let dropSettle = () => {};
66 +
67 +const positionOutline = (overlay, el, instant) => {
63 68 if (instant) {
64 69 overlay.style.transition = 'none';
65 70 } else if (overlay.style.transition === 'none') {
66 71 overlay.style.transition = '';
@@ -65,17 +70,30 @@
65 70 } else if (overlay.style.transition === 'none') {
66 71 overlay.style.transition = '';
67 72 }
68 73 const r = el.getBoundingClientRect();
69 - overlay.style.top = `${r.top}px`;
70 - overlay.style.left = `${r.left}px`;
71 - overlay.style.width = `${r.width}px`;
72 - overlay.style.height = `${r.height}px`;
74 + const width = framed(r.width);
75 + const height = framed(r.height);
76 + overlay.style.top = `${r.top - (height - r.height) / 2}px`;
77 + overlay.style.left = `${r.left - (width - r.width) / 2}px`;
78 + overlay.style.width = `${width}px`;
79 + overlay.style.height = `${height}px`;
80 +};
81 +
82 +const showOutline = (el, { instant = false } = {}) => {
83 + const overlay = ensureOutline();
84 + positionOutline(overlay, el, instant);
85 + overlay.classList.toggle('is-over-media', needsContrastRing(el));
73 86 overlay.classList.add('is-visible');
87 + dropSettle();
88 + dropSettle = whenAnimationsSettle(el, () =>
89 + positionOutline(overlay, el, true),
90 + );
74 91 debugLog(instant ? 'showOutline (instant)' : 'showOutline', el);
75 92 };
76 93
77 94 const hideOutline = () => {
95 + dropSettle();
78 96 hoverOutline?.classList.remove('is-visible');
79 97 debugLog('hideOutline');
80 98 };
81 99
@@ -88,17 +106,43 @@
88 106 const PART_ATTR = 'data-extendify-part-block-id';
89 107 const PRODUCT_ATTR = 'data-extendify-quick-edit-product-id';
90 108 const WPFORM_FIELD_ATTR = 'data-extendify-quick-edit-wpform-field-id';
91 109 const MEDIATEXT_MEDIA_ATTR = 'data-extendify-quick-edit-mediatext-media';
110 +const PART_SLUG_ATTR = 'data-extendify-part-slug';
92 111
112 +// A synced pattern's blocks live in the wp_block post the id names; Quick
113 +// Edit's save only ever writes the container, so it can't reach them.
114 +const isSyncedPatternId = (id) => /^block:\d+:\d+$/.test(String(id ?? ''));
115 +
116 +// DOMHighlighter's class; a sync listener can't read a React prop.
117 +export const isAgentWorking = () =>
118 + !!document.querySelector('.wp-site-blocks.extendify-agent-working');
119 +
120 +const isAgentWaiting = () =>
121 + !!document.querySelector('.wp-site-blocks.extendify-agent-waiting');
122 +
123 +const hasAgentTask = () =>
124 + !!document.querySelector('.wp-site-blocks.extendify-agent-task');
125 +
93 126 // Resolve the live DOM node for the currently-staged agent block, so the
94 127 // click + hover gates can carve out "inside the staged block." Returns
95 128 // null when no block is staged or its node has detached from the tree.
129 +// Without the slug an id matches another part's block of the same number, and
130 +// a click inside the staged block reads as an outside-click.
96 131 const stagedBlockEl = () => {
97 132 const block = useQuickEditStore.getState().agentBlock;
98 133 if (!block?.id) return null;
99 134 const attr = block.target || POST_ATTR;
100 - return document.querySelector(`[${attr}="${CSS.escape(String(block.id))}"]`);
135 + const slug = block.source?.partSlug || null;
136 + const matches = [
137 + ...document.querySelectorAll(`[${attr}="${CSS.escape(String(block.id))}"]`),
138 + ];
139 + const inScope = matches.filter(
140 + (el) =>
141 + (el.closest(`[${PART_SLUG_ATTR}]`)?.getAttribute(PART_SLUG_ATTR) ??
142 + null) === slug,
143 + );
144 + return inScope[0] ?? matches[0] ?? null;
101 145 };
102 146
103 147 // Resolve the committed selection's live DOM node. buildTarget stashes
104 148 // the element reference on the descriptor; if the block has been swapped
@@ -201,9 +245,9 @@
201 245 // a cover's inner-container) so the bar resolves to the nearest editable
202 246 // parent. Without this, hovering the middle of a hero cover that
203 247 // surfaces post-title returned blockType=null and — combined with the
204 248 // template-part source gating Ask AI off — produced no bar at all.
205 -const buildTarget = (el) => {
249 +const buildTarget = (el, fromEl = el) => {
206 250 let current = resolveTarget(el);
207 251 let safety = 5;
208 252 while (
209 253 current &&
@@ -214,8 +258,11 @@
214 258 const next = resolveTarget(current.el.parentElement);
215 259 if (!next) return current;
216 260 current = next;
217 261 }
262 + if (current && escapesDynamicBlock(fromEl, current.el)) {
263 + return { ...current, dynamicInterior: true };
264 + }
218 265 return current;
219 266 };
220 267
221 268 // Picker blocks keep the bar visible because the dropdown anchors
@@ -234,18 +281,33 @@
234 281 // pill shows the error inline instead, and Ask AI stays reachable.
235 282 const isTranslatedTextBlock = (target) =>
236 283 isTranslatedRender() && isTextBearing(target?.blockType);
237 284
238 -// Which pills a target would surface (without mounting the bar). Click rule
239 -// (Option 7) needs this to decide between opening QE directly, today's
240 -// sticky commit, and the silent agent stage. Exported so keyboard-entry
241 -// gates Enter on the same signal the hover bar uses.
285 +// Which pills a target would surface (without mounting the bar). A target
286 +// with no pills isn't worth pinning. Exported so keyboard-entry gates Enter
287 +// on the same signal the hover bar uses.
242 288 export const pillContextFor = (target) => {
243 289 const quickEditEnabled = !!window.extQuickEditData?.quickEditEnabled;
290 + // A synced pattern is addressed off whichever tagger stamped it, so both
291 + // id spaces have to be checked or the pill returns on pages.
292 + const compositeId =
293 + target?.el?.getAttribute?.(PART_ATTR) ??
294 + target?.el?.getAttribute?.(POST_ATTR);
295 + // An open editor blocks sending, so it would stall the agent's task.
244 296 const quickEditable =
245 - quickEditEnabled && hasQuickEditModalFor(target?.blockType);
297 + quickEditEnabled &&
298 + !hasAgentTask() &&
299 + hasQuickEditModalFor(target?.blockType) &&
300 + !isSyncedPatternId(compositeId);
246 301 const sourceKind = target?.source?.kind ?? null;
247 - const agentSupportedSource = sourceKind === 'post' || sourceKind === null;
302 + // A ref-nav item routes Quick Edit's save through wp_navigation, but the
303 + // agent reaches it through the id the part tagger stamped.
304 + const agentSupportedSource =
305 + sourceKind === 'post' ||
306 + sourceKind === 'template-part' ||
307 + sourceKind === null ||
308 + (sourceKind === 'wp-navigation' &&
309 + !!target?.el?.getAttribute?.('data-extendify-part-block-id'));
248 310 const aiAvailable =
249 311 isAgentAvailable() &&
250 312 agentSupportedSource &&
251 313 isAgentEligibleForTarget(target);
@@ -251,9 +313,9 @@
251 313 isAgentEligibleForTarget(target);
252 314 return { quickEditable, aiAvailable };
253 315 };
254 316
255 -// Exported for keyboard-entry to bypass the bar's click handler.
317 +// Test seam for the Quick Edit pill's handler, which isn't exported.
256 318 export const editTarget = (target) => onEditClick(target);
257 319
258 320 const onEditClick = (target) => {
259 321 const store = useQuickEditStore.getState();
@@ -275,10 +337,11 @@
275 337 const placement = hoverBar?.dataset.extendifyQuickEditPlacement ?? 'above';
276 338
277 339 const isPicker = isPickerType(target.blockType);
278 340 if (!isPicker) clearBar();
341 + // Order matters: clearing the commit first drops a picker's anchor bar.
342 + store.setSelected({ ...target, anchorRect, anchorPlacement: placement });
279 343 store.setCommittedSelection(null);
280 - store.setSelected({ ...target, anchorRect, anchorPlacement: placement });
281 344
282 345 if (!isPicker) {
283 346 track('quick_edit_action', {
284 347 element: target.blockType,
@@ -303,10 +366,9 @@
303 366 });
304 367 askAiAboutElement(el);
305 368 };
306 369
307 -// Exported for keyboard-entry to route Enter on an Ask-AI-only block
308 -// straight to the agent, mirroring the Ask AI pill's click handler.
370 +// The Ask AI pill's handler, for callers holding an element, not a pill.
309 371 export const askAiTarget = (el) => onAiClick(el);
310 372
311 373 // Exported for keyboard-entry's focus-driven mount/dismiss.
312 374 export const showBar = (el) => renderBar(el);
@@ -311,16 +373,17 @@
311 373 // Exported for keyboard-entry's focus-driven mount/dismiss.
312 374 export const showBar = (el) => renderBar(el);
313 375 export const hideBar = () => clearBar();
314 376
315 -const renderBar = (el) => {
377 +const renderBar = (el, fromEl = el) => {
316 378 // While an agent block is staged, the hover bar is intentionally
317 379 // hidden — only DOMHighlighter's X-close indicator is shown.
318 380 // Defense in depth for any caller (a re-render, the keyboard
319 381 // entry's showBar) that might otherwise paint a stale bar.
320 382 if (hasAgentBlockSelected()) return;
383 + if (isAgentWorking()) return;
321 384
322 - const target = buildTarget(el);
385 + const target = buildTarget(el, fromEl);
323 386 const { quickEditable, aiAvailable } = pillContextFor(target);
324 387 // Bail BEFORE clearing the current bar — when the cursor traverses
325 388 // from a renderable block to an UNSUPPORTED tagged ancestor (e.g. a
326 389 // tagged group with too many inner tagged blocks, or a tagged
@@ -398,9 +461,13 @@
398 461 aiBtn.type = 'button';
399 462 aiBtn.className = 'extendify-quick-edit-pill extendify-quick-edit-pill-ai';
400 463 aiBtn.setAttribute('data-extendify-quick-edit-pill', '');
401 464 aiBtn.innerHTML = '<span aria-hidden="true">✦</span>';
402 - aiBtn.append(__('Ask AI', 'extendify-local'));
465 + // translators: Button on a page block that hands it to the AI agent, which asked the user to pick one.
466 + const shareLabel = __('Share with agent', 'extendify-local');
467 + aiBtn.append(
468 + isAgentWaiting() ? shareLabel : __('Ask AI', 'extendify-local'),
469 + );
403 470 aiBtn.addEventListener('mousedown', stopMouseDown);
404 471 aiBtn.addEventListener('click', (ev) => {
405 472 ev.preventDefault();
406 473 ev.stopPropagation();
@@ -408,13 +475,58 @@
408 475 });
409 476 bar.appendChild(aiBtn);
410 477 }
411 478
479 + const closeBtn = document.createElement('button');
480 + closeBtn.type = 'button';
481 + closeBtn.className =
482 + 'extendify-quick-edit-pill extendify-quick-edit-pill-close';
483 + closeBtn.setAttribute('data-extendify-quick-edit-pill', '');
484 + closeBtn.setAttribute('aria-label', __('Dismiss', 'extendify-local'));
485 + closeBtn.innerHTML = '<span aria-hidden="true">✕</span>';
486 + closeBtn.addEventListener('mousedown', stopMouseDown);
487 + closeBtn.addEventListener('click', (ev) => {
488 + ev.preventDefault();
489 + ev.stopPropagation();
490 + useQuickEditStore.getState().setCommittedSelection(null);
491 + clearBar();
492 + });
493 + bar.appendChild(closeBtn);
494 +
412 495 document.body.appendChild(bar);
413 496 hoverBar = bar;
497 + syncDismissState();
414 498 positionBar(bar, anchorEl);
415 499 };
416 500
501 +// Nothing to dismiss until a click pins the bar; hover ends with the pointer.
502 +const syncDismissState = () => {
503 + const closeBtn = hoverBar?.querySelector('.extendify-quick-edit-pill-close');
504 + if (!closeBtn) return;
505 + closeBtn.disabled = !useQuickEditStore.getState().committedSelection;
506 +};
507 +
508 +// preventScroll: the pill is already on screen; scrolling to it jumps the page.
509 +const focusFirstPill = () => {
510 + hoverBar
511 + ?.querySelector('.extendify-quick-edit-pill')
512 + ?.focus({ preventScroll: true });
513 +};
514 +
515 +export const pinTarget = (el, target = buildTarget(el)) => {
516 + const { quickEditable, aiAvailable } = pillContextFor(target);
517 + const store = useQuickEditStore.getState();
518 + if (!quickEditable && !aiAvailable) {
519 + store.setCommittedSelection(null);
520 + clearBar();
521 + return;
522 + }
523 + renderBar(el);
524 + store.setCommittedSelection(target);
525 + syncDismissState();
526 + focusFirstPill();
527 +};
528 +
417 529 // Walk up to the innermost tagged ancestor. resolveTarget then derives
418 530 // blockType from that element's wp-block-* class; the pill renderer
419 531 // decides which pills (if any) to show. Lighter than resolveTarget —
420 532 // onMouseOver hot path doesn't need the full descriptor.
@@ -437,8 +549,9 @@
437 549
438 550 const onMouseOver = (e) => {
439 551 if (!useEditModeStore.getState().on) return;
440 552 if (useQuickEditStore.getState().selected) return;
553 + if (isAgentWorking()) return;
441 554 // Sticky modes hard-suppress all hover-driven bar movement.
442 555 // - agentBlock staged: the bar is intentionally hidden; only
443 556 // DOMHighlighter's X-close is shown. To re-engage Ask AI on the
444 557 // same block, the user clicks X-close (clears agentBlock) then
@@ -454,9 +567,9 @@
454 567 }
455 568 const el = findTagged(e.target);
456 569 if (el === hoverTarget) return;
457 570 if (!el) return;
458 - renderBar(el);
571 + renderBar(el, e.target);
459 572 };
460 573
461 574 const onScrollOrResize = () => {
462 575 if (!hoverTarget) return;
@@ -503,24 +616,18 @@
503 616
504 617 const onDocClickCapture = (e) => {
505 618 if (!useEditModeStore.getState().on) return;
506 619 if (e.target?.closest?.(QE_INTERIOR)) return;
620 + // Below QE_INTERIOR, or a run also deadens the sidebar and canvas.
621 + if (isAgentWorking()) return;
507 622
508 623 // Implicit close on the QE text-edit canvas: clicks outside the canvas
509 624 // while it's open save the in-flight edits instead of discarding them.
510 - // `alsoClear: false` only when the click will open QE on a different
511 - // block (the `select` branch's `quickEditable` cell); otherwise save
512 - // clears so the canvas unmounts. Without that distinction the click
513 - // would race: save's `clearSelected(null)` would overwrite the new
514 - // block's `setSelected(B)`. `hasSaver()` is false for picker blocks
515 - // (image / cover) — they save synchronously on pick and never
516 - // register. Fall through either way so the existing agentBlock-clear +
517 - // clear-bar branches still run.
625 + // `hasSaver()` is false for picker blocks (image / cover) — they save
626 + // synchronously on pick and never register. Fall through either way so
627 + // the existing agentBlock-clear + clear-bar branches still run.
518 628 if (hasSaver() && useQuickEditStore.getState().selected) {
519 - const tagged = findTagged(e.target);
520 - const willOpenQE =
521 - !!tagged && hasQuickEditModalFor(buildTarget(tagged)?.blockType);
522 - saveSelected({ alsoClear: !willOpenQE });
629 + saveSelected();
523 630 }
524 631
525 632 // Soft selection: while a block is staged for Ask AI, clicks INSIDE
526 633 // the staged block route natively (anchor navigates, form control
@@ -570,64 +677,23 @@
570 677 switch (result.action) {
571 678 case 'select': {
572 679 e.preventDefault();
573 680 e.stopPropagation();
574 - // stopPropagation above blocks ImagePicker's bubble-phase
575 - // outside-click — without this clear its menu lingers (issue 19).
576 681 const store = useQuickEditStore.getState();
577 - if (
578 - store.selected &&
579 - isPickerType(store.selected.blockType) &&
580 - store.selected.el !== result.el
581 - ) {
682 + // Only pickers reach this — other canvases cover the block they
683 + // opened on.
684 + if (store.selected?.el === result.el) {
582 685 store.clearSelected();
583 - }
584 - const target = buildTarget(result.el);
585 - const { quickEditable, aiAvailable } = pillContextFor(target);
586 -
587 - // Click semantics by pill count + agent-open state:
588 - // QE-only → open QE menu directly (collapsed gesture).
589 - // AI-only + closed → today's sticky commit (the one path that
590 - // keeps committedSelection alive).
591 - // AI-only + open → silently stage agentBlock (bridge).
592 - // Both pills → open QE menu directly; bridge agentBlock
593 - // too when the agent sidebar is open. The
594 - // Ask AI button now lives on the QE bar
595 - // chrome (BlockTextEditor.jsx), so the
596 - // collapsed click no longer hides Ask AI.
597 - // Picker-type blocks (image, cover) are
598 - // exempt from the silent stage — the
599 - // hover bar stays mounted for them and
600 - // keeps the Ask AI pill, so the user
601 - // escalates explicitly rather than seeing
602 - // both the picker dropdown AND the
603 - // agent's X-close at once.
604 - // Tagged but neither → clear (no outline on a block the user
605 - // can't act on).
606 - if (quickEditable) {
607 - renderBar(result.el);
608 - onEditClick(target);
609 - if (
610 - aiAvailable &&
611 - isAgentSidebarOpen() &&
612 - !isPickerType(target.blockType)
613 - ) {
614 - stageAgentBlock(result.el);
615 - }
616 - return;
617 - }
618 - if (aiAvailable && isAgentSidebarOpen()) {
619 - useQuickEditStore.getState().setCommittedSelection(null);
686 + store.setCommittedSelection(null);
620 687 clearBar();
621 - stageAgentBlock(result.el);
622 688 return;
623 689 }
624 - if (aiAvailable) {
625 - useQuickEditStore.getState().setCommittedSelection(target);
626 - renderBar(result.el);
627 - return;
690 + // stopPropagation above blocks ImagePicker's bubble-phase
691 + // outside-click — without this clear its menu lingers (issue 19).
692 + if (store.selected && isPickerType(store.selected.blockType)) {
693 + store.clearSelected();
628 694 }
629 - clearBar();
695 + pinTarget(result.el);
630 696 return;
631 697 }
632 698 case 'clear':
633 699 clearBar();
@@ -639,8 +705,9 @@
639 705
640 706 let unsubEditMode = null;
641 707 let unsubSelected = null;
642 708 let unsubAgentBlock = null;
709 +let workingObserver = null;
643 710 let unsubCommitted = null;
644 711
645 712 export const attach = () => {
646 713 if (attached) return;
@@ -648,12 +715,8 @@
648 715 document.addEventListener('mouseover', onMouseOver, true);
649 716 window.addEventListener('scroll', onScrollOrResize, true);
650 717 window.addEventListener('resize', onScrollOrResize);
651 718 document.addEventListener('click', onDocClickCapture, true);
652 - // Warm the agent-sidebar state cache so the sync click rule has fresh
653 - // state by the time the user clicks. The dynamic import resolves on
654 - // the microtask queue; user clicks are seconds-later in real use.
655 - isAgentSidebarOpen();
656 719
657 720 unsubEditMode = useEditModeStore.subscribe((state) => {
658 721 if (!state.on) {
659 722 useQuickEditStore.getState().setCommittedSelection(null);
@@ -668,9 +731,12 @@
668 731 let lastCommitted = useQuickEditStore.getState().committedSelection;
669 732 unsubCommitted = useQuickEditStore.subscribe((state) => {
670 733 const prev = lastCommitted;
671 734 lastCommitted = state.committedSelection;
672 - if (prev && !state.committedSelection) clearBar();
735 + if (!prev || state.committedSelection) return;
736 + // A picker dropdown anchors to the bar; clearing here orphans it.
737 + if (isPickerType(state.selected?.blockType)) return;
738 + clearBar();
673 739 });
674 740 // Picker dropdown anchors to the bar; keep it visible for those.
675 741 // On the non-null → null transition (Esc / Cancel / Save closes the
676 742 // canvas) the bar is re-rendered on the previously edited element
@@ -710,13 +776,30 @@
710 776 }
711 777 if (!prev?.el || !document.body.contains(prev.el)) return;
712 778 if (!useEditModeStore.getState().on) return;
713 779 if (hoverTarget === prev.el && hoverBar) return;
780 + // A save resolves after a newer pin; re-rendering here steals its bar.
781 + const committed = useQuickEditStore.getState().committedSelection;
782 + if (committed?.el && committed.el !== prev.el) return;
714 783 renderBar(prev.el);
715 784 });
716 785 unsubAgentBlock = subscribeToAgentBlock((hasBlock) => {
717 786 if (hasBlock) clearBar();
718 787 });
788 + // A bar mounted before the run began stays clickable otherwise.
789 + workingObserver = new MutationObserver(() => {
790 + if (!isAgentWorking()) return;
791 + // Otherwise hover stays suppressed after the workflow ends.
792 + useQuickEditStore.getState().setCommittedSelection(null);
793 + clearBar();
794 + });
795 + const root = document.querySelector('.wp-site-blocks');
796 + if (root) {
797 + workingObserver.observe(root, {
798 + attributes: true,
799 + attributeFilter: ['class'],
800 + });
801 + }
719 802 };
720 803
721 804 export const detach = () => {
722 805 if (!attached) return;
@@ -727,12 +810,14 @@
727 810 document.removeEventListener('click', onDocClickCapture, true);
728 811 unsubEditMode?.();
729 812 unsubSelected?.();
730 813 unsubAgentBlock?.();
814 + workingObserver?.disconnect();
731 815 unsubCommitted?.();
732 816 unsubEditMode = null;
733 817 unsubSelected = null;
734 818 unsubAgentBlock = null;
819 + workingObserver = null;
735 820 unsubCommitted = null;
736 821 clearBar();
737 822 removeOutline();
738 823 };