Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions blocks/canvas/editor-utils/selection-toolbar-controller.js
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to explicitly hide the toolbar when related interactions are happening? Why not still have the toolbar in the background when the table or other modals are open? We then dont have to worry about rendering the toolbar again on closure.

// 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.
},
};
41 changes: 21 additions & 20 deletions blocks/canvas/editor-utils/selection-toolbar.js
Original file line number Diff line number Diff line change
@@ -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']);

Expand All @@ -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;
}

Expand All @@ -27,7 +28,7 @@ export function getSelectionToolbar() {
}

export function hideSelectionToolbar() {
toolbar?.hide?.();
selectionToolbarController.setInactive();
}

export function openLinkDialog(view) {
Expand All @@ -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,
Expand All @@ -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();
},
};
},
Expand Down
33 changes: 32 additions & 1 deletion blocks/canvas/ew-editor-doc/ew-editor-doc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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();
Expand All @@ -164,6 +191,7 @@ export class EwEditorDoc extends LitElement {

_teardown() {
this._stopObservingUndoManager();
this._unwireProseFocus?.();
const { wsProvider, view, proseEl } = this._proseContext ?? {};
teardownEditorDocResources({
clearPortHandler: () => this._clearControllerPort(),
Expand Down Expand Up @@ -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();
Expand All @@ -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) => {
Expand Down
20 changes: 17 additions & 3 deletions blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading