From b9c64369b931bb979a9620be6e02e3aeca2f6882 Mon Sep 17 00:00:00 2001 From: Hannes Hertach Date: Fri, 19 Jun 2026 11:46:59 +0200 Subject: [PATCH 1/2] refactor toolbar in canvas --- .../selection-toolbar-controller.js | 93 +++++++++++++++++++ .../canvas/editor-utils/selection-toolbar.js | 41 ++++---- blocks/canvas/ew-editor-doc/ew-editor-doc.js | 29 ++++++ .../ew-editor-wysiwyg/ew-editor-wysiwyg.js | 11 ++- .../ew-editor-wysiwyg/utils/handlers.js | 73 ++++++++------- .../ew-selection-toolbar.js | 33 +++++-- 6 files changed, 214 insertions(+), 66 deletions(-) create mode 100644 blocks/canvas/editor-utils/selection-toolbar-controller.js diff --git a/blocks/canvas/editor-utils/selection-toolbar-controller.js b/blocks/canvas/editor-utils/selection-toolbar-controller.js new file mode 100644 index 00000000..fa402c53 --- /dev/null +++ b/blocks/canvas/editor-utils/selection-toolbar-controller.js @@ -0,0 +1,93 @@ +/** + * Owns the selection-toolbar's visibility state across both editor modes (doc + * and wysiwyg). Each editor declares activation/deactivation explicitly rather + * than the toolbar introspecting `view.hasFocus()` — which lies in wysiwyg mode + * (see editor-doc cursor-broadcast hack in handlers.js). + */ + +const listeners = new Set(); + +const state = { + mode: 'none', // 'doc' | 'wysiwyg' | 'none' + hasRange: false, + interacting: false, + isNonTextSelection: false, + view: null, + iframe: null, +}; + +function shouldShow() { + if (state.mode === 'none') return false; + if (state.interacting) return false; + // Hide for table/image node selections in doc mode (they have their own UI). + // Don't apply in wysiwyg — trust the iframe's intent if it reports a text range. + if (state.mode === 'doc' && state.isNonTextSelection) return false; + return true; +} + +function emit() { + const snapshot = { ...state, shouldShow: shouldShow() }; + listeners.forEach((fn) => fn(snapshot)); +} + +export const selectionToolbarController = { + getState() { + return { ...state, shouldShow: shouldShow() }; + }, + + subscribe(fn) { + listeners.add(fn); + fn({ ...state, shouldShow: shouldShow() }); + return () => listeners.delete(fn); + }, + + setActive(mode, { view, iframe } = {}) { + if (mode !== 'doc' && mode !== 'wysiwyg') return; + let changed = false; + if (state.mode !== mode) { + state.mode = mode; + changed = true; + } + if (view !== undefined && state.view !== view) { + state.view = view; + changed = true; + } + if (iframe !== undefined && state.iframe !== iframe) { + state.iframe = iframe; + changed = true; + } + if (changed) emit(); + }, + + setInactive(mode) { + // Only deactivate if the caller owns the current mode — prevents a stale + // blur from one editor wiping the active state of the other. + if (mode && state.mode !== mode) return; + if (state.mode === 'none' && !state.hasRange) return; + state.mode = 'none'; + state.hasRange = false; + state.isNonTextSelection = false; + emit(); + }, + + setRange(hasRange, { isNonTextSelection = false } = {}) { + if (state.hasRange === hasRange && state.isNonTextSelection === isNonTextSelection) return; + state.hasRange = hasRange; + state.isNonTextSelection = isNonTextSelection; + emit(); + }, + + setInteracting(flag) { + if (state.interacting === flag) return; + state.interacting = flag; + emit(); + }, + + /** After a toolbar command — return focus to whichever editor owned it. */ + restoreFocus() { + if (state.mode === 'doc') { + state.view?.focus(); + } + // wysiwyg: rely on mousedown.preventDefault keeping iframe focus. + }, +}; diff --git a/blocks/canvas/editor-utils/selection-toolbar.js b/blocks/canvas/editor-utils/selection-toolbar.js index 33307f90..ed611878 100644 --- a/blocks/canvas/editor-utils/selection-toolbar.js +++ b/blocks/canvas/editor-utils/selection-toolbar.js @@ -1,5 +1,6 @@ /* eslint-disable import/no-unresolved -- importmap */ import { Plugin, PluginKey, NodeSelection } from 'da-y-wrapper'; +import { selectionToolbarController } from './selection-toolbar-controller.js'; const NON_TEXT_NODES = new Set(['table', 'image']); @@ -11,7 +12,7 @@ export const NX_QUICK_EDIT_CLEAR_IFRAME_SELECTION_ORIGIN_META = 'nxClearQuickEdi const selectionToolbarOriginKey = new PluginKey('nxSelectionToolbarOrigin'); -function getSelectionOriginFromIframe(state) { +export function getSelectionOriginFromIframe(state) { return selectionToolbarOriginKey.getState(state)?.fromIframe ?? false; } @@ -27,7 +28,7 @@ export function getSelectionToolbar() { } export function hideSelectionToolbar() { - toolbar?.hide?.(); + selectionToolbarController.setInactive(); } export function openLinkDialog(view) { @@ -39,19 +40,6 @@ function isNonTextSelection({ selection }) { && NON_TEXT_NODES.has(selection.node.type.name); } -function syncToolbar(view) { - if (!view) return; - const tb = getSelectionToolbar(); - if (tb.linkDialogOpen || tb.isInteracting) return; - if (isNonTextSelection(view.state)) { - hideSelectionToolbar(); - return; - } - if (!view.hasFocus()) return; - tb.view = view; - tb.show(); -} - export function createSelectionToolbarPlugin() { return new Plugin({ key: selectionToolbarOriginKey, @@ -67,16 +55,29 @@ export function createSelectionToolbarPlugin() { }, }, view() { + // Mount the toolbar element so it's ready to subscribe to the controller. + getSelectionToolbar(); return { update(view) { - const header = document.querySelector('ew-canvas-header'); - const ev = header?.editorView; - if (ev !== 'content' && ev !== 'split') return; + // WYSIWYG-origin dispatches are owned by handlers.js — don't let + // this plugin overwrite the controller state during them. if (getSelectionOriginFromIframe(view.state)) return; - syncToolbar(view); + // Don't claim doc mode while wysiwyg is active; otherwise PM + // dispatches triggered by iframe sync would flip the mode. + if (selectionToolbarController.getState().mode === 'wysiwyg') return; + // Require real PM focus before claiming 'doc' — without this, + // background dispatches (collab updates, etc.) would show the + // toolbar on a doc the user isn't actually editing. + if (!view.hasFocus()) return; + const { selection } = view.state; + selectionToolbarController.setActive('doc', { view }); + selectionToolbarController.setRange( + !selection.empty, + { isNonTextSelection: isNonTextSelection(view.state) }, + ); }, destroy() { - hideSelectionToolbar(); + selectionToolbarController.setInactive(); }, }; }, diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js index 44b9fdd0..da73e982 100644 --- a/blocks/canvas/ew-editor-doc/ew-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js @@ -24,6 +24,7 @@ import { resolveEditorDocSession } from './utils/load-editor-doc.js'; import { afterNextPaint, ensureProseMountedInShadow } from './utils/shadow-mount.js'; import { teardownEditorDocResources } from './utils/teardown.js'; import { hideSelectionToolbar } from '../editor-utils/selection-toolbar.js'; +import { selectionToolbarController } from '../editor-utils/selection-toolbar-controller.js'; import { createExtensionsBridgePlugin } from '../editor-utils/extensions-bridge.js'; const { loadStyle } = await import(`${getNx()}/utils/utils.js`); @@ -144,6 +145,32 @@ export class EwEditorDoc extends LitElement { wireQuickEditControllerPort(this._controllerCtx); } + _wireProseFocus(view, proseEl) { + this._unwireProseFocus?.(); + const onFocus = () => { + const { selection } = view.state; + selectionToolbarController.setActive('doc', { view }); + selectionToolbarController.setRange(!selection.empty); + }; + const onBlur = () => { + // Defer: clicking on the toolbar blurs PM, but focus returns synchronously. + // If focus ends up inside the toolbar we want to stay active in 'doc' mode. + setTimeout(() => { + const active = document.activeElement; + const toolbar = document.querySelector('ew-selection-toolbar'); + if (toolbar && active && (active === toolbar || toolbar.contains(active))) return; + selectionToolbarController.setInactive('doc'); + }, 0); + }; + proseEl.addEventListener('focusin', onFocus); + proseEl.addEventListener('focusout', onBlur); + this._unwireProseFocus = () => { + proseEl.removeEventListener('focusin', onFocus); + proseEl.removeEventListener('focusout', onBlur); + this._unwireProseFocus = undefined; + }; + } + _setupAwareness(wsProvider) { if (this._awarenessOff) { this._awarenessOff(); @@ -164,6 +191,7 @@ export class EwEditorDoc extends LitElement { _teardown() { this._stopObservingUndoManager(); + this._unwireProseFocus?.(); const { wsProvider, view, proseEl } = this._proseContext ?? {}; teardownEditorDocResources({ clearPortHandler: () => this._clearControllerPort(), @@ -223,6 +251,7 @@ export class EwEditorDoc extends LitElement { this._proseContext = { proseEl, wsProvider, view, ydoc, undoManager }; this._setupAwareness(wsProvider); this._observeUndoManager(undoManager); + this._wireProseFocus(view, proseEl); this._emitHtmlChange(); this._setupController(); diff --git a/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js index 930c942d..ac536b00 100644 --- a/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js +++ b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js @@ -3,6 +3,7 @@ import { getNx } from '../../../scripts/utils.js'; import { getPreviewOrigin, fetchWysiwygCookie, fetchWysiwygBranch } from '../editor-utils/editor-utils.js'; import { initIms as loadIms } from '../../shared/utils.js'; import { hideSelectionToolbar } from '../editor-utils/selection-toolbar.js'; +import { selectionToolbarController } from '../editor-utils/selection-toolbar-controller.js'; const { loadStyle } = await import(`${getNx()}/utils/utils.js`); @@ -196,7 +197,15 @@ export class EwEditorWysiwyg extends LitElement { } _onIframeBlur() { - hideSelectionToolbar(); + // Defer so we can see where focus actually went. If it moved to the + // toolbar (clicking a button) keep the wysiwyg context active; otherwise + // deactivate. relatedTarget is unreliable for iframe blurs across browsers. + setTimeout(() => { + const active = document.activeElement; + const toolbar = document.querySelector('ew-selection-toolbar'); + if (toolbar && active && (active === toolbar || toolbar.contains(active))) return; + selectionToolbarController.setInactive('wysiwyg'); + }, 0); } render() { diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js index e7d8bed8..69e20349 100644 --- a/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js +++ b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js @@ -1,21 +1,41 @@ import { TextSelection, yUndo, yRedo } from 'da-y-wrapper'; import { - getSelectionToolbar, NX_QUICK_EDIT_IFRAME_SELECTION_META, NX_QUICK_EDIT_CLEAR_IFRAME_SELECTION_ORIGIN_META, } from '../../editor-utils/selection-toolbar.js'; +import { selectionToolbarController } from '../../editor-utils/selection-toolbar-controller.js'; import { editorSelectChange } from '../../editor-utils/editor-utils.js'; import { getActiveBlockIndex } from '../../editor-utils/blocks.js'; +/** + * Run `view.dispatch(tr)` while temporarily pretending the view has focus. + * y-prosemirror's cursor plugin reads `view.hasFocus()` synchronously inside + * its `view.update()` to decide whether to broadcast the cursor to awareness. + * In WYSIWYG mode the iframe owns DOM focus, so without the lie collaborators + * lose sight of this user's cursor. The lie is restored to truth immediately + * after dispatch so PM's undo path, DOM observer, focus-event timeouts, etc. + * see the real focus state at all other times. + */ +function dispatchWithFakeFocus(view, tr) { + const hadOwn = Object.hasOwn(view, 'hasFocus'); + const prev = hadOwn ? view.hasFocus : undefined; + view.hasFocus = () => true; + try { + view.dispatch(tr); + } finally { + if (hadOwn) view.hasFocus = prev; + else delete view.hasFocus; + } +} + export function handleCursorMove({ cursorOffset, textCursorOffset }, ctx) { - const { view, wsProvider } = ctx; + const { view, wsProvider, iframe } = ctx; if (!view || !wsProvider) return; if (cursorOffset == null || textCursorOffset == null) { - delete view.hasFocus; + // Iframe reports the cursor has left the editable area entirely. wsProvider.awareness.setLocalStateField('cursor', null); - const tb = getSelectionToolbar(); - if (!tb.isInteracting && !tb.linkDialogOpen) tb.hide?.(); + selectionToolbarController.setInactive('wysiwyg'); return; } @@ -29,10 +49,9 @@ export function handleCursorMove({ cursorOffset, textCursorOffset }, ctx) { return; } - view.hasFocus = () => true; - const { tr } = state; tr.setSelection(TextSelection.create(state.doc, position)); + tr.setMeta(NX_QUICK_EDIT_IFRAME_SELECTION_META, true); // Sync stored marks so the toolbar reflects the marks active at the cursor. // Two problems this solves: @@ -58,13 +77,12 @@ export function handleCursorMove({ cursorOffset, textCursorOffset }, ctx) { } ctx.suppressRerender = true; - view.dispatch(tr.scrollIntoView()); + dispatchWithFakeFocus(view, tr.scrollIntoView()); ctx.suppressRerender = false; - const tb = getSelectionToolbar(); - if (!tb.linkDialogOpen && !tb.isInteracting) { - tb.view = view; - tb.show(); - } + + selectionToolbarController.setActive('wysiwyg', { view, iframe }); + selectionToolbarController.setRange(false); + const blockIndex = getActiveBlockIndex(view); if (blockIndex !== ctx.lastBlockIndex) { ctx.lastBlockIndex = blockIndex; @@ -80,19 +98,11 @@ export function handleUndoRedo(data, ctx) { const { action } = data; const view = ctx?.view; if (!view) return; - // hasFocus may be overridden to () => true by the cursor-move hack; temporarily - // restore the prototype so ProseMirror skips _isDomSelectionInView during the - // undo dispatch (the editor may be in a hidden or unfocused state). - const hadHasFocus = Object.hasOwn(view, 'hasFocus'); - delete view.hasFocus; - if (action === 'undo') { yUndo(view.state); } else if (action === 'redo') { yRedo(view.state); } - - if (hadHasFocus) view.hasFocus = () => true; } export function handleStoredMarks({ marks }, ctx) { @@ -110,7 +120,7 @@ export function handleStoredMarks({ marks }, ctx) { const { tr } = state; tr.setStoredMarks(parsedMarks); ctx.suppressRerender = true; - view.dispatch(tr); + dispatchWithFakeFocus(view, tr); ctx.suppressRerender = false; } catch (e) { // eslint-disable-next-line no-console @@ -129,7 +139,7 @@ export function handleSelectionChange({ anchor, head }, ctx, { fromQuickEditIfra tr.setSelection(TextSelection.create(state.doc, a, h)); if (fromQuickEditIframe) tr.setMeta(NX_QUICK_EDIT_IFRAME_SELECTION_META, true); ctx.suppressRerender = true; - view.dispatch(tr); + dispatchWithFakeFocus(view, tr); ctx.suppressRerender = false; return true; } catch (e) { @@ -139,32 +149,25 @@ export function handleSelectionChange({ anchor, head }, ctx, { fromQuickEditIfra } } -function showToolbarInIFrame(ctx) { - const { view } = ctx; - const tb = getSelectionToolbar(); - tb.view = view; - tb.show(); -} - /** PostMessage `selection-change` from wysiwyg iframe: sync PM selection and toolbar. */ export function handleIframeSelectionChange(data, ctx) { const { anchor, head } = data; + const { view, iframe } = ctx; if (anchor === head) { - const tb = getSelectionToolbar(); - if (tb.isInteracting) return; - const { view } = ctx; if (view) { const tr = view.state.tr .setMeta(NX_QUICK_EDIT_CLEAR_IFRAME_SELECTION_ORIGIN_META, true) .setMeta('addToHistory', false); ctx.suppressRerender = true; - view.dispatch(tr); + dispatchWithFakeFocus(view, tr); ctx.suppressRerender = false; } + selectionToolbarController.setRange(false); return; } if (!handleSelectionChange(data, ctx, { fromQuickEditIframe: true })) return; - showToolbarInIFrame(ctx); + selectionToolbarController.setActive('wysiwyg', { view, iframe }); + selectionToolbarController.setRange(true); } diff --git a/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js index 0cb4fc89..a0a03a06 100644 --- a/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js +++ b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js @@ -8,6 +8,7 @@ import { applyLink, removeLink, } from '../editor-utils/command-helpers.js'; +import { selectionToolbarController } from '../editor-utils/selection-toolbar-controller.js'; const { loadStyle } = await import(`${getNx()}/utils/utils.js`); @@ -49,13 +50,23 @@ class EwSelectionToolbar extends LitElement { connectedCallback() { super.connectedCallback(); this.shadowRoot.adoptedStyleSheets = [styles]; + this._unsubscribeController = selectionToolbarController.subscribe((s) => { + this.view = s.view ?? this.view; + if (s.shouldShow) this._show(); + else if (!this.isInteracting) this._hide(); + }); + // Safety net: any click outside the toolbar, the active editor DOM, and + // the WYSIWYG iframe deactivates the controller. Editor blur handlers + // should normally take care of this; this catches the gaps. this._onOutsidePointerDown = (e) => { if (!this.open) return; const path = e.composedPath(); if (path.includes(this)) return; - const editorDom = this.view?.dom; + const ctrl = selectionToolbarController.getState(); + const editorDom = ctrl.view?.dom; if (editorDom && path.includes(editorDom)) return; - this.hide(); + if (ctrl.iframe && path.includes(ctrl.iframe)) return; + selectionToolbarController.setInactive(); }; document.addEventListener('pointerdown', this._onOutsidePointerDown); } @@ -63,11 +74,12 @@ class EwSelectionToolbar extends LitElement { disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener('pointerdown', this._onOutsidePointerDown); + this._unsubscribeController?.(); } get _picker() { return this.shadowRoot?.querySelector('nx-picker'); } - show() { + _show() { const main = document.querySelector('main'); if (main) { const { left, width } = main.getBoundingClientRect(); @@ -77,7 +89,7 @@ class EwSelectionToolbar extends LitElement { this.requestUpdate(); } - hide() { + _hide() { this.classList.remove('open'); } @@ -114,7 +126,7 @@ class EwSelectionToolbar extends LitElement { if (cmd) { cmd.apply(this.view); this.requestUpdate(); - this.view.focus(); + selectionToolbarController.restoreFocus(); } } @@ -134,13 +146,13 @@ class EwSelectionToolbar extends LitElement { if (link === 'remove') { removeLink(this.view); this.requestUpdate(); - this.view.focus(); + selectionToolbarController.restoreFocus(); return; } if (id) { COMMAND_BY_ID.get(id)?.apply(this.view); this.requestUpdate(); - this.view.focus(); + selectionToolbarController.restoreFocus(); } } @@ -199,20 +211,21 @@ class EwSelectionToolbar extends LitElement { this._linkHref = ''; this._linkText = from !== to ? this.view.state.doc.textBetween(from, to) : ''; } - this.hide(); + selectionToolbarController.setInteracting(true); this._linkDialogOpen = true; } _closeLinkDialog() { this._linkDialogOpen = false; - this.view?.focus(); + selectionToolbarController.setInteracting(false); + selectionToolbarController.restoreFocus(); } _onLinkDialogSubmit(e) { const { href, text } = e.detail; this._closeLinkDialog(); applyLink(this.view, { href, text }); - this.view.focus(); + selectionToolbarController.restoreFocus(); } get linkDialogOpen() { return this._linkDialogOpen ?? false; } From ac9741ab0768cc4b06eb0b6896324c8387271996 Mon Sep 17 00:00:00 2001 From: Hannes Hertach Date: Fri, 19 Jun 2026 14:18:31 +0200 Subject: [PATCH 2/2] fix selection toolbar caret, mark, and visibility regressions - toolbar shows on caret (not just range); marks-at-cursor drive its active state - drop storedMarks preservation across cursor moves so toggled marks clear if the user moves without typing - emit controller updates unconditionally on setActive so the toolbar re-renders against fresh view.state - hide inline-only block controls in wysiwyg/layout mode - only hide the toolbar on actual canvas view-mode transitions, not on port-ready/ctx/iframe-load Co-Authored-By: Claude Opus 4.7 (1M context) --- .../selection-toolbar-controller.js | 21 ++++-------- blocks/canvas/ew-editor-doc/ew-editor-doc.js | 4 ++- .../ew-editor-wysiwyg/ew-editor-wysiwyg.js | 9 ++++-- .../ew-editor-wysiwyg/utils/handlers.js | 32 +++++++------------ .../ew-selection-toolbar.js | 31 +++++++++++------- 5 files changed, 47 insertions(+), 50 deletions(-) diff --git a/blocks/canvas/editor-utils/selection-toolbar-controller.js b/blocks/canvas/editor-utils/selection-toolbar-controller.js index fa402c53..16017faa 100644 --- a/blocks/canvas/editor-utils/selection-toolbar-controller.js +++ b/blocks/canvas/editor-utils/selection-toolbar-controller.js @@ -43,20 +43,13 @@ export const selectionToolbarController = { setActive(mode, { view, iframe } = {}) { if (mode !== 'doc' && mode !== 'wysiwyg') return; - let changed = false; - if (state.mode !== mode) { - state.mode = mode; - changed = true; - } - if (view !== undefined && state.view !== view) { - state.view = view; - changed = true; - } - if (iframe !== undefined && state.iframe !== iframe) { - state.iframe = iframe; - changed = true; - } - if (changed) emit(); + state.mode = mode; + if (view !== undefined) state.view = view; + if (iframe !== undefined) state.iframe = iframe; + // Emit unconditionally — even if mode/view identity hasn't changed, + // the underlying PM state (selection, storedMarks, marks) usually has, + // and the toolbar needs to re-query active/visible commands. + emit(); }, setInactive(mode) { diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js index da73e982..6ff5973f 100644 --- a/blocks/canvas/ew-editor-doc/ew-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js @@ -269,8 +269,10 @@ export class EwEditorDoc extends LitElement { this.shadowRoot.adoptedStyleSheets = [style]; this._onCanvasEditorActive = (e) => { const view = e.detail?.view; + const changed = this._canvasActiveView !== view; + this._canvasActiveView = view; this.hidden = view === 'layout'; - hideSelectionToolbar(); + if (changed) hideSelectionToolbar(); }; this.parentElement?.addEventListener('nx-canvas-editor-active', this._onCanvasEditorActive); this._onWysiwygPortReady = (e) => { diff --git a/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js index ac536b00..2c7a5b94 100644 --- a/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js +++ b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js @@ -53,8 +53,14 @@ export class EwEditorWysiwyg extends LitElement { super.connectedCallback(); this.shadowRoot.adoptedStyleSheets = [style]; this._onCanvasEditorActive = (e) => { - this._canvasActiveView = e.detail?.view; + const next = e.detail?.view; + const changed = this._canvasActiveView !== next; + this._canvasActiveView = next; this._syncCanvasVisibility(); + // Only hide the toolbar when the user-visible canvas view actually + // transitions — _syncCanvasVisibility also runs on port-ready, ctx + // change, and iframe load, which shouldn't drop an active toolbar. + if (changed) hideSelectionToolbar(); }; this.parentElement?.addEventListener('nx-canvas-editor-active', this._onCanvasEditorActive); this._syncCanvasVisibility(); @@ -100,7 +106,6 @@ export class EwEditorWysiwyg extends LitElement { const view = this._canvasActiveView ?? 'layout'; const showWysiwyg = view === 'layout' || view === 'split'; this.hidden = !showWysiwyg; - hideSelectionToolbar(); } _resetCookieStateForCtxChange() { diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js index 69e20349..50e1017b 100644 --- a/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js +++ b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js @@ -53,28 +53,18 @@ export function handleCursorMove({ cursorOffset, textCursorOffset }, ctx) { tr.setSelection(TextSelection.create(state.doc, position)); tr.setMeta(NX_QUICK_EDIT_IFRAME_SELECTION_META, true); - // Sync stored marks so the toolbar reflects the marks active at the cursor. - // Two problems this solves: - // 1. ProseMirror clears storedMarks whenever selection.anchor changes, which - // happens on every cursor-move — that wipes toolbar-toggled marks before the - // first keystroke arrives. - // 2. marksAcross() returns Mark.none when the cursor is at the end of a mark - // run (nothing to the right), so the toolbar shows the mark as inactive even - // though the text is marked. nodeBefore/nodeAfter covers both sides. + // PM's `marks()` returns Mark.none when the cursor sits at the end of a mark + // run (nothing immediately to the right), which would make the toolbar show + // an adjacent mark as inactive. Inspecting nodeBefore covers both sides so + // typing-inherits-mark is consistent with what the toolbar displays. + // Intentionally NOT preserving `state.storedMarks` when there's no adjacent + // mark: a cursor move without typing should drop any toolbar-toggled mark + // that was queued for the next keystroke. const $pos = state.doc.resolve(position); - const marksBefore = $pos.nodeBefore?.marks; - const marksAfter = $pos.nodeAfter?.marks; - const marksAtCursor = (marksBefore?.length ? marksBefore : null) - ?? (marksAfter?.length ? marksAfter : null); - - if (marksAtCursor) { - // Cursor is adjacent to marked text — use those marks (handles Cmd+B case). - tr.setStoredMarks(marksAtCursor); - } else if (state.storedMarks?.length) { - // No marked text at this position, but user explicitly toggled a mark via - // the toolbar — preserve it so it survives cursor-move events before typing. - tr.setStoredMarks(state.storedMarks); - } + const marksAtCursor = $pos.nodeBefore?.marks?.length + ? $pos.nodeBefore.marks + : $pos.nodeAfter?.marks; + if (marksAtCursor?.length) tr.setStoredMarks(marksAtCursor); ctx.suppressRerender = true; dispatchWithFakeFocus(view, tr.scrollIntoView()); diff --git a/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js index a0a03a06..330ac598 100644 --- a/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js +++ b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js @@ -42,6 +42,7 @@ function blockTypeLabelForRaw(raw) { class EwSelectionToolbar extends LitElement { static properties = { view: { attribute: false }, + _mode: { state: true }, _linkDialogOpen: { state: true }, _linkHref: { state: true }, _linkText: { state: true }, @@ -52,6 +53,7 @@ class EwSelectionToolbar extends LitElement { this.shadowRoot.adoptedStyleSheets = [styles]; this._unsubscribeController = selectionToolbarController.subscribe((s) => { this.view = s.view ?? this.view; + this._mode = s.mode; if (s.shouldShow) this._show(); else if (!this.isInteracting) this._hide(); }); @@ -285,24 +287,29 @@ class EwSelectionToolbar extends LitElement { render() { const disabled = !this.view; + // In wysiwyg mode the iframe owns block-level structure (paragraph/heading, + // lists, tables) — only show inline marks and link controls. + const inlineOnly = this._mode === 'wysiwyg'; return html`
e.preventDefault()}>
this._onToolbarClick(e)}> - - this._onBlockTypeChange(e)} - > - - + ${inlineOnly ? nothing : html` + + this._onBlockTypeChange(e)} + > + + + `} ${MARK_ITEMS.map((m) => this._renderMarkButton(m))} - ${this._renderBlockStructure()} + ${inlineOnly ? nothing : this._renderBlockStructure()} ${this._renderLinkButtons()}