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..16017faa --- /dev/null +++ b/blocks/canvas/editor-utils/selection-toolbar-controller.js @@ -0,0 +1,86 @@ +/** + * 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; + 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) { + // 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..6ff5973f 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(); @@ -240,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 930c942d..2c7a5b94 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`); @@ -52,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(); @@ -99,7 +106,6 @@ export class EwEditorWysiwyg extends LitElement { const view = this._canvasActiveView ?? 'layout'; const showWysiwyg = view === 'layout' || view === 'split'; this.hidden = !showWysiwyg; - hideSelectionToolbar(); } _resetCookieStateForCtxChange() { @@ -196,7 +202,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..50e1017b 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,42 +49,30 @@ export function handleCursorMove({ cursorOffset, textCursorOffset }, ctx) { return; } - view.hasFocus = () => true; - const { tr } = state; tr.setSelection(TextSelection.create(state.doc, position)); - - // 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. + tr.setMeta(NX_QUICK_EDIT_IFRAME_SELECTION_META, true); + + // 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; - 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 +88,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 +110,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 +129,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 +139,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..330ac598 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`); @@ -41,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 }, @@ -49,13 +51,24 @@ class EwSelectionToolbar extends LitElement { connectedCallback() { super.connectedCallback(); 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(); + }); + // 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 +76,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 +91,7 @@ class EwSelectionToolbar extends LitElement { this.requestUpdate(); } - hide() { + _hide() { this.classList.remove('open'); } @@ -114,7 +128,7 @@ class EwSelectionToolbar extends LitElement { if (cmd) { cmd.apply(this.view); this.requestUpdate(); - this.view.focus(); + selectionToolbarController.restoreFocus(); } } @@ -134,13 +148,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 +213,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; } @@ -272,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()}