diff --git a/blocks/canvas/canvas.js b/blocks/canvas/canvas.js index d3f2a3b80..b19c827ac 100644 --- a/blocks/canvas/canvas.js +++ b/blocks/canvas/canvas.js @@ -5,6 +5,7 @@ import { persistCanvasEditorView, } from './utils/view.js'; import { shouldAutoOpenAfterPanel } from './utils/panel.js'; +import { toolbarController } from './editor-utils/toolbar-controller.js'; import './ew-canvas-header/ew-canvas-header.js'; import './ew-editor-doc/ew-editor-doc.js'; import './ew-editor-wysiwyg/ew-editor-wysiwyg.js'; @@ -15,12 +16,13 @@ import { removeSplitGutter, } from './ew-editor-split/ew-editor-split.js'; import { resolveEditorDocSession } from './ew-editor-doc/utils/load-editor-doc.js'; +import { sourceUrlFromEditorCtx } from './ew-editor-doc/utils/ctx.js'; import { SEL_BLOCK, SEL_ITEM, SEL_TEXT } from './ew-editor-doc/utils/selection.js'; import { getChatPanelContent } from '../shared/chat-panel.js'; import { canvasBus } from './utils/canvas-bus.js'; const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`); -const { CHAT_EVENT } = await import(`${getNx()}/utils/chat.js`); +const { CHAT_EVENT } = await import(`${getNx()}/blocks/chat/constants.js`); const { wasPanelOpen, registerPanelSection, @@ -38,6 +40,7 @@ function buildCanvasDocPath(state) { function notifyCanvasEditorActive(view) { const v = normalizeCanvasEditorView(view); + toolbarController.setEditorMode(v); canvasBus.editorViewState.emit({ view: v }); } @@ -103,11 +106,10 @@ async function syncCanvasEditorsToHash({ mountRoot, header, state }) { removeCanvasEditors(mountRoot); removeNotPermitted(mountRoot); header.authorized = true; - header.canWrite = true; return; } const ctx = editorCtxFromHashState(state, fullPath); - const session = await resolveEditorDocSession(ctx); + const session = await resolveEditorDocSession(sourceUrlFromEditorCtx(ctx)); if (loadCount !== editorLoadCount) return; if (!session.ok) { removeCanvasEditors(mountRoot); @@ -116,15 +118,11 @@ async function syncCanvasEditorsToHash({ mountRoot, header, state }) { return; } removeNotPermitted(mountRoot); - const canWrite = (session.permissions ?? []).some((p) => p === 'write'); header.authorized = true; - header.canWrite = canWrite; const docEl = ensureNxEditorDoc(mountRoot); docEl.session = session; docEl.ctx = ctx; - const frameEl = ensureNxEditorWysiwyg(mountRoot); - frameEl.canWrite = canWrite; - frameEl.ctx = ctx; + ensureNxEditorWysiwyg(mountRoot).ctx = ctx; finalizeSplitEditorMountOrder(mountRoot); notifyCanvasEditorActive(header.editorView); syncEditorSplitLayout({ mountRoot, view: header.editorView }); @@ -271,7 +269,7 @@ export default async function decorate(block) { } // Any non-empty selection in doc mode is sent as chat context. - // wysiwyg has no block-select equivalent yet. + // wysiwyg has no block-select equivalent yet — see docs/canvas-events.md. const CANVAS_CHAT_KEY = 'canvas-selection'; const SELECTION_LABEL = 'Selection'; let hasContext = false; diff --git a/blocks/canvas/editor-utils/editor-utils.js b/blocks/canvas/editor-utils/editor-utils.js index c92162562..d79eb9fd0 100644 --- a/blocks/canvas/editor-utils/editor-utils.js +++ b/blocks/canvas/editor-utils/editor-utils.js @@ -2,12 +2,34 @@ import { TextSelection } from 'da-y-wrapper'; import prose2aem from '../../shared/prose2aem.js'; import { getNx } from '../../../scripts/utils.js'; import { daFetch, fetchDaConfigs, getFirstSheet } from '../../shared/utils.js'; -import { getSelectionToolbar } from './selection-toolbar.js'; +import { toolbarController } from './toolbar-controller.js'; import { MESSAGE_TYPES } from '../utils/quick-edit-messages.js'; import { canvasBus, registerEditorSelectEnricher } from '../utils/canvas-bus.js'; const { DA_CONTENT } = await import(`${getNx()}/utils/utils.js`); +/** + * Dispatch a mirror transaction while forcing `view.hasFocus()` true for the + * duration, then restore it. y-prosemirror's cursor plugin broadcasts this user's + * cursor to collaborators only while the view "has focus". The toolbar controller + * already keeps `hasFocus` true whenever the wysiwyg surface is active, but a + * mirrored edit can land in the instant before the surface flips (the message is + * applied, then the surface is claimed) — this guarantees the very edit that moves + * the caret also broadcasts it. Toolbar visibility never reads focus; it derives + * from the active surface. + */ +export 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; + } +} + // --- state.js --- function findInsertedRange(oldText, newText) { @@ -60,11 +82,10 @@ export function updateState(data, ctx) { tr.setSelection(TextSelection.create(tr.doc, restoredFrom, restoredTo)); ctx.suppressRerender = true; - view.dispatch(tr); + dispatchWithFakeFocus(view, tr); ctx.suppressRerender = false; - const tb = getSelectionToolbar(); - if (tb.open && !tb.isInteracting) tb.requestUpdate(); + toolbarController.refresh(); // Sync the updated node (with marks applied) back to the portal's mini editor. // Without this, the portal's editor retains the plain-text version, so the next diff --git a/blocks/canvas/editor-utils/selection-toolbar.js b/blocks/canvas/editor-utils/selection-toolbar.js index e2084a1ca..91aa6889d 100644 --- a/blocks/canvas/editor-utils/selection-toolbar.js +++ b/blocks/canvas/editor-utils/selection-toolbar.js @@ -1,13 +1,9 @@ /* eslint-disable import/no-unresolved -- importmap */ import { Plugin, PluginKey, NodeSelection } from 'da-y-wrapper'; -import { getTableBlockName, getTableBlockVariant } from './blocks.js'; -import { canvasBus } from '../utils/canvas-bus.js'; +import { toolbarController } from './toolbar-controller.js'; const NON_TEXT_NODES = new Set(['table']); -/** Editor views the selection/block toolbars may appear in. */ -const TOOLBAR_EDITOR_VIEWS = new Set(['content', 'split', 'layout']); - /** Set on transactions that mirror WYSIWYG iframe text selection into ProseMirror. */ export const NX_QUICK_EDIT_IFRAME_SELECTION_META = 'nxQuickEditIframeSelection'; @@ -16,72 +12,27 @@ 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; } -let toolbar; -let componentLoaded; - -let selectionToolbarCanWrite = false; - -export function getSelectionToolbar() { - if (toolbar) return toolbar; - componentLoaded ??= import('../ew-selection-toolbar/ew-selection-toolbar.js'); - toolbar = document.createElement('ew-selection-toolbar'); - document.body.append(toolbar); - return toolbar; -} - -let blockToolbar; -let blockComponentLoaded; - -export function getBlockToolbar() { - if (blockToolbar) return blockToolbar; - blockComponentLoaded ??= import('../ew-block-toolbar/ew-block-toolbar.js'); - blockToolbar = document.createElement('ew-block-toolbar'); - document.body.append(blockToolbar); - return blockToolbar; -} - -export function hideBlockToolbar() { - blockToolbar?.hide?.(); -} - -export function canShowSelectionToolbar() { - return selectionToolbarCanWrite; -} - -export function setSelectionToolbarCtx({ - org = null, - site = null, - sourceUrl = null, - canWrite = false, -} = {}) { - selectionToolbarCanWrite = canWrite === true; - const tb = getSelectionToolbar(); +export function setSelectionToolbarCtx({ org = null, site = null, sourceUrl = null } = {}) { + const tb = toolbarController.ensureToolbar(); tb.org = org; tb.site = site; tb.sourceUrl = sourceUrl; - const blockTb = getBlockToolbar(); - blockTb.org = org; - blockTb.site = site; -} - -export function hideSelectionToolbar() { - toolbar?.hide?.(); } export function openLinkDialog(view) { - getSelectionToolbar().openLinkDialog(view); + toolbarController.ensureToolbar().openLinkDialog(view); } export function openAltDialog() { - getSelectionToolbar().openAltDialog(); + toolbarController.ensureToolbar().openAltDialog(); } export function triggerAddImage() { - getSelectionToolbar().triggerAddImage(); + toolbarController.ensureToolbar().triggerAddImage(); } function isNonTextSelection({ selection }) { @@ -89,35 +40,6 @@ function isNonTextSelection({ selection }) { && NON_TEXT_NODES.has(selection.node.type.name); } -function syncToolbar(view, editorView, blockEditOpen) { - if (!view) return; - if (!selectionToolbarCanWrite) { - hideSelectionToolbar(); - return; - } - const tb = getSelectionToolbar(); - if (tb.linkDialogOpen || tb.altDialogOpen || tb.isInteracting) return; - if (isNonTextSelection(view.state)) { - // A block is selected — show the block toolbar in every editor view. - hideSelectionToolbar(); - const blockTb = getBlockToolbar(); - blockTb.view = view; - const { node } = view.state.selection; - blockTb.show(getTableBlockName(node), getTableBlockVariant(node)); - return; - } - hideBlockToolbar(); - // The text toolbar is only relevant when the doc editor is visible, and never - // for selections that originate in (and are already served by) the WYSIWYG iframe. - if (getSelectionOriginFromIframe(view.state)) return; - // In layout view the doc editor is hidden — except while the block-edit modal is open, - // which puts the (single-block) doc editor on screen. - if (editorView === 'layout' && !blockEditOpen) return; - if (!view.hasFocus()) return; - tb.view = view; - tb.show(); -} - export function createSelectionToolbarPlugin() { return new Plugin({ key: selectionToolbarOriginKey, @@ -133,26 +55,16 @@ export function createSelectionToolbarPlugin() { }, }, view() { - // Track the active editor view and block-edit state off the canvas bus rather - // than querying ew-canvas-header / ew-editor-doc from the DOM. Both channels - // replay their last value, so a plugin created after the last emit still starts - // with the current state. - let editorView = 'layout'; - let blockEditOpen = false; - const unsubscribeEditorView = canvasBus.editorViewState - .subscribe(({ view }) => { editorView = view; }); - const unsubscribeBlockEdit = canvasBus.blockEditState - .subscribe(({ open }) => { blockEditOpen = open; }); return { update(view) { - if (!blockEditOpen && !TOOLBAR_EDITOR_VIEWS.has(editorView)) return; - syncToolbar(view, editorView, blockEditOpen); + // Iframe-origin dispatches are owned by the wysiwyg handlers; the doc + // plugin only reports the *doc* selection, and never claims the surface + // (activation comes from real focus — see toolbar-controller.js). + if (getSelectionOriginFromIframe(view.state)) return; + toolbarController.setDocSelection({ showable: !isNonTextSelection(view.state) }); }, destroy() { - unsubscribeEditorView(); - unsubscribeBlockEdit(); - hideSelectionToolbar(); - hideBlockToolbar(); + toolbarController.reset(); }, }; }, diff --git a/blocks/canvas/editor-utils/toolbar-controller.js b/blocks/canvas/editor-utils/toolbar-controller.js new file mode 100644 index 000000000..b86a9e913 --- /dev/null +++ b/blocks/canvas/editor-utils/toolbar-controller.js @@ -0,0 +1,225 @@ +/** + * Single owner of the selection-toolbar's visibility across the canvas doc editor + * and the WYSIWYG iframe. See docs/canvas-toolbar-architecture.md. + * + * Nothing outside this module shows, hides, or positions the toolbar. Callers emit + * intent (activate / deactivate / selection / editor-mode) and this module derives + * visibility once per animation frame from an explicit "active surface" — never + * from `view.hasFocus()`, which lies while the user edits in the iframe. + */ + +let toolbarEl; +let toolbarLoading; +let pointerdownInstalled = false; +let windowBlurInstalled = false; + +const state = { + activeSurface: null, // 'doc' | 'wysiwyg' | null + docView: null, // the single ProseMirror view — always the command target + iframeEl: null, // for outside-click hit-testing + showable: false, // current selection is one the toolbar serves + editorMode: 'layout', // 'layout' | 'content' | 'split' +}; + +function ensureToolbar() { + if (toolbarEl) return toolbarEl; + toolbarEl = document.createElement('ew-selection-toolbar'); + document.body.append(toolbarEl); + // The element definition loads lazily; re-render once it upgrades so a render + // scheduled before the module resolved takes effect. + toolbarLoading ??= import('../ew-selection-toolbar/ew-selection-toolbar.js') + // eslint-disable-next-line no-use-before-define -- mutually recursive with render + .then(() => scheduleRender()); + return toolbarEl; +} + +function editorModeAllows(surface) { + if (surface === 'doc') return state.editorMode === 'content' || state.editorMode === 'split'; + if (surface === 'wysiwyg') return state.editorMode === 'layout' || state.editorMode === 'split'; + return false; +} + +/** The one visibility predicate. Interaction state is pulled from the element so + * a dialog / picker / menu is the single source of truth for "is interacting". */ +function shouldShow(tb) { + if (tb.linkDialogOpen || tb.altDialogOpen || tb.isInteracting) return false; + return state.activeSurface !== null + && state.showable + && editorModeAllows(state.activeSurface); +} + +let renderQueued = false; +function render() { + const tb = ensureToolbar(); + // Element not upgraded yet; ensureToolbar re-renders when its module resolves. + if (typeof tb.show !== 'function') return; + // Only assign a known view; never clobber with null (teardown just hides, and a + // fresh view arrives via setDocView on the next load). + if (state.docView) tb.view = state.docView; + // The element renders a surface-appropriate button set: the wysiwyg iframe owns + // block-level structure, so it gets inline/link/image controls only. + tb.activeSurface = state.activeSurface; + if (shouldShow(tb)) { + tb.show(); + } else if (!tb.linkDialogOpen && !tb.altDialogOpen && !tb.isInteracting) { + tb.hide(); + } +} + +function scheduleRender() { + if (renderQueued) return; + renderQueued = true; + requestAnimationFrame(() => { + renderQueued = false; + render(); + }); +} + +/** Pierce shadow roots to find the truly focused element. `document.activeElement` + * stops at a shadow host, so a focused iframe nested in a shadow root reports the + * host, not the iframe. */ +function deepActiveElement() { + let el = document.activeElement; + while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement; + return el; +} + +/** Detect focus entering the wysiwyg iframe. The iframe element's own focus event + * is unreliable for cross-origin frames, and da-nx sends no message when a click + * doesn't change the block selection (e.g. clicking where the caret already sits). + * When focus moves into an iframe the parent window blurs and the (deep) active + * element becomes that iframe — a robust, message-independent signal. */ +function installIframeFocusDetection() { + if (windowBlurInstalled) return; + windowBlurInstalled = true; + window.addEventListener('blur', () => { + setTimeout(() => { + if (state.iframeEl && deepActiveElement() === state.iframeEl) { + // Entering an editable pane; assume showable so the toolbar appears even + // when no positional message follows. A later node-select (e.g. a table) + // refines it. + state.activeSurface = 'wysiwyg'; + state.showable = true; + scheduleRender(); + } + }, 0); + }); +} + +/** + * While the wysiwyg iframe owns editing, keep the doc view broadcasting this + * user's cursor to collaborators without ever letting real focus land on it. + * + * y-prosemirror's cursor plugin broadcasts the local cursor only while the view + * "has focus", and clears it on the next update once focus is lost — so a caret + * mirrored from the iframe would flash to peers and vanish. We lie about focus so + * the plugin keeps broadcasting; but `hasFocus` lying alone would let ProseMirror's + * `selectionToDOM` treat the doc editor as focused. That only writes a DOM + * selection range (harmless — it doesn't move focus), so the one thing left to + * guard is `view.focus()`, which really would steal focus from the iframe and + * bring back the toolbar-visibility bugs. Neuter it while wysiwyg is active. + */ +const focusGuardedViews = new WeakSet(); +function installSurfaceFocusGuards(view) { + if (focusGuardedViews.has(view)) return; + focusGuardedViews.add(view); + const realHasFocus = view.hasFocus.bind(view); + view.hasFocus = () => state.activeSurface === 'wysiwyg' || realHasFocus(); + const realFocus = view.focus.bind(view); + view.focus = () => { + if (state.activeSurface === 'wysiwyg') return; + realFocus(); + }; +} + +function installOutsidePointerdown() { + if (pointerdownInstalled) return; + pointerdownInstalled = true; + document.addEventListener('pointerdown', (e) => { + if (state.activeSurface === null) return; + const path = e.composedPath(); + if (toolbarEl && path.includes(toolbarEl)) return; + if (state.docView?.dom && path.includes(state.docView.dom)) return; + if (state.iframeEl && path.includes(state.iframeEl)) return; + // A real pointerdown in the parent document outside every editing surface — + // the user is leaving. (Clicks inside the cross-origin iframe never reach here, + // and are handled by the iframe's own blur.) + state.activeSurface = null; + scheduleRender(); + }); +} + +export const toolbarController = { + ensureToolbar, + + /** Register the doc editor's view (the command target). */ + setDocView(view) { + state.docView = view ?? null; + if (view) installSurfaceFocusGuards(view); + installOutsidePointerdown(); + scheduleRender(); + }, + + setIframe(iframeEl) { + state.iframeEl = iframeEl ?? null; + if (iframeEl) installIframeFocusDetection(); + }, + + setEditorMode(mode) { + if (state.editorMode === mode) return; + state.editorMode = mode; + scheduleRender(); + }, + + /** The user is now editing in `surface` (driven by real focus / positional intent). */ + activate(surface, { iframeEl } = {}) { + if (surface !== 'doc' && surface !== 'wysiwyg') return; + if (iframeEl !== undefined) state.iframeEl = iframeEl; + state.activeSurface = surface; + installOutsidePointerdown(); + scheduleRender(); + }, + + /** Ownership-guarded: only the surface that currently owns the toolbar may + * deactivate it, so a late blur from one editor can't wipe the other. */ + deactivate(surface) { + if (surface && state.activeSurface !== surface) return; + state.activeSurface = null; + scheduleRender(); + }, + + /** Doc selection changed. Never claims the surface — a background/collab/mirror + * dispatch must not show the toolbar on a doc the user isn't editing. */ + setDocSelection({ showable }) { + if (state.activeSurface === 'wysiwyg') return; + state.showable = showable; + scheduleRender(); + }, + + /** A positional message from the iframe: the user is editing there. */ + setWysiwygSelection({ showable }) { + state.activeSurface = 'wysiwyg'; + state.showable = showable; + installOutsidePointerdown(); + scheduleRender(); + }, + + /** Re-query the toolbar's command/visibility state (e.g. after a command runs + * or a dialog/picker closes) without changing surface. */ + refresh() { + scheduleRender(); + }, + + /** After a toolbar command / dialog close — return focus to the active surface. */ + restoreFocus() { + if (state.activeSurface === 'doc') state.docView?.focus(); + // wysiwyg: focus never left the iframe (toolbar suppresses it via mousedown). + }, + + reset() { + state.activeSurface = null; + state.showable = false; + state.docView = null; + scheduleRender(); + }, +}; diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js index 9eaf2ed0b..859b20e15 100644 --- a/blocks/canvas/ew-editor-doc/ew-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js @@ -1,15 +1,19 @@ import { LitElement, html, nothing } from 'da-lit'; -import { yUndo, yRedo, NodeSelection, TextSelection } from 'da-y-wrapper'; +import { yUndo, yRedo, NodeSelection } from 'da-y-wrapper'; import { getNx } from '../../../scripts/utils.js'; -import { updateDocument, updateCursors, getInstrumentedHTML, getEditor } from '../editor-utils/editor-utils.js'; -import { getActiveBlockIndex, getBlockPositions, getTableBlockName } from '../editor-utils/blocks.js'; +import { + updateDocument, updateCursors, getInstrumentedHTML, + editorHtmlChange, editorSelectChange, getEditor, +} from '../editor-utils/editor-utils.js'; +import { getActiveBlockIndex, getBlockPositions } from '../editor-utils/blocks.js'; import { editorDocCanLoad, + sourceUrlFromEditorCtx, controllerPathnameFromEditorCtx, editorDocRenderPhase, } from './utils/ctx.js'; import { subscribeCollabUserList } from './utils/awareness-users.js'; -import { describeDocSelection, applyHighlight, SEL_BLOCK, selectedNodePayload, activeContentProseIndex } from './utils/selection.js'; +import { describeDocSelection, applyHighlight, SEL_BLOCK, selectedNodePayload } from './utils/selection.js'; import { prefetchWysiwygCookiesIfSignedIn, wireQuickEditControllerPort, @@ -17,30 +21,17 @@ import { import { initIms as loadIms } from '../../shared/utils.js'; import { forceSave } from '../../shared/forcesave.js'; import initProse from './prose.js'; -import { setBlockFocus, clearBlockFocus } from './prose-plugins/blockFocus.js'; import { createTrackingPlugin } from '../editor-utils/prose-diff.js'; import { resolveEditorDocSession } from './utils/load-editor-doc.js'; import { afterNextPaint, ensureProseMountedInShadow } from './utils/shadow-mount.js'; import { teardownEditorDocResources } from './utils/teardown.js'; -import { getSelectionToolbar, hideSelectionToolbar, setSelectionToolbarCtx } from '../editor-utils/selection-toolbar.js'; +import { setSelectionToolbarCtx } from '../editor-utils/selection-toolbar.js'; +import { toolbarController } from '../editor-utils/toolbar-controller.js'; import { createExtensionsBridgePlugin } from '../editor-utils/extensions-bridge.js'; -import mediaBusImage from './prose-plugins/mediaBusImage.js'; import { MESSAGE_TYPES } from '../utils/quick-edit-messages.js'; -import { canvasBus } from '../utils/canvas-bus.js'; - -// Maps ew-page-outline's default-content `kind` to the PM node type(s) it can back, -// so a matching node at proseIndex can be selected as a whole (see _scrollDocToProseIndex). -const CONTENT_KIND_NODE_NAMES = { - paragraph: ['paragraph'], - heading: ['heading'], - list: ['bullet_list', 'ordered_list'], - code: ['code_block'], - quote: ['blockquote'], -}; const { loadStyle } = await import(`${getNx()}/utils/utils.js`); -const { CHAT_EVENT } = await import(`${getNx()}/utils/chat.js`); -await import(`${getNx()}/blocks/shared/dialog/dialog.js`); +const { CHAT_EVENT } = await import(`${getNx()}/blocks/chat/constants.js`); const style = await loadStyle(import.meta.url); @@ -50,22 +41,20 @@ export class EwEditorDoc extends LitElement { session: { type: Object }, quickEditPort: { type: Object }, _error: { state: true }, - _blockEditMode: { state: true }, - _blockEditName: { state: true }, }; willUpdate(changed) { super.willUpdate(changed); if (changed.has('ctx')) { this.quickEditPort = undefined; - this._canWrite = false; this._teardown(); setSelectionToolbarCtx(); + toolbarController.reset(); this._error = undefined; this._lastDocBlockIndex = undefined; this._lastDocSelKey = undefined; this._lastBroadcastNodeKey = undefined; - canvasBus.editorHtmlState.emit(''); + editorHtmlChange.emit(''); } } @@ -89,14 +78,18 @@ export class EwEditorDoc extends LitElement { _emitHtmlChange() { const { view } = this._proseContext ?? {}; if (!view) return; - canvasBus.editorHtmlState.emit(getInstrumentedHTML(view)); + editorHtmlChange.emit(getInstrumentedHTML(view)); } _emitUndoState() { const mgr = this._proseContext?.undoManager; const canUndo = mgr ? mgr.undoStack.length > 0 : false; const canRedo = mgr ? mgr.redoStack.length > 0 : false; - canvasBus.undoState.emit({ canUndo, canRedo }); + this.dispatchEvent(new CustomEvent('nx-editor-undo-state', { + bubbles: true, + composed: true, + detail: { canUndo, canRedo }, + })); } _observeUndoManager(mgr) { @@ -128,54 +121,11 @@ export class EwEditorDoc extends LitElement { view.dispatch(view.state.tr.setSelection(sel).scrollIntoView()); } - // TextSelection.near is the fallback for a drifted/mid-node proseIndex. A kind match - // selects a NodeSelection instead, for the block-style highlight. Either way, broadcasts - // the raw proseIndex, since that's what layout-view's data-prose-index carries. - _scrollDocToProseIndex(proseIndex, kind) { - if (proseIndex == null || proseIndex < 0) return; - const { view } = this._proseContext ?? {}; - if (!view) return; - const { doc } = view.state; - if (proseIndex > doc.content.size) return; - - // The dispatch below runs the tracking plugin's onSelectionChange synchronously, which - // would otherwise broadcast its own (null, for non-image/table selections) node payload - // an instant before the correct one just below overwrites it. - this._suppressAutoBroadcast = true; - if (kind === 'image' && doc.nodeAt(proseIndex)?.type.name === 'image') { - const sel = NodeSelection.create(doc, proseIndex); - view.dispatch(view.state.tr.setSelection(sel).scrollIntoView()); - this._suppressAutoBroadcast = false; - this._broadcastSelectedNode(true); - return; - } - - // Non-image content's proseIndex is one position inside the node's own start - // (see activeContentProseIndex in utils/selection.js) — step back one for the anchor. - const nodeStart = proseIndex - 1; - const nodeNames = CONTENT_KIND_NODE_NAMES[kind]; - if (nodeStart >= 0 && nodeNames?.includes(doc.nodeAt(nodeStart)?.type.name)) { - const sel = NodeSelection.create(doc, nodeStart); - view.dispatch(view.state.tr.setSelection(sel).scrollIntoView()); - this._suppressAutoBroadcast = false; - this._broadcastSelectedNode(true, { anchorType: 'content', proseIndex }); - return; - } - - const sel = TextSelection.near(doc.resolve(proseIndex)); - view.dispatch(view.state.tr.setSelection(sel).scrollIntoView()); - this._suppressAutoBroadcast = false; - this._broadcastSelectedNode(true, { anchorType: 'content', proseIndex }); - } - - // overrideNode lets content navigation (a TextSelection selectedNodePayload can't - // classify) broadcast an explicit anchorType/proseIndex instead of a derived one. - _broadcastSelectedNode(scrollIntoView = false, overrideNode = undefined) { - if (this._suppressAutoBroadcast && overrideNode === undefined) return; + _broadcastSelectedNode(scrollIntoView = false) { const port = this._controllerCtx?.port; const { view } = this._proseContext ?? {}; if (!port || !view) return; - const node = overrideNode !== undefined ? overrideNode : selectedNodePayload(view); + const node = selectedNodePayload(view); const key = node ? `${node.anchorType}:${node.proseIndex}` : 'null'; const forceScroll = scrollIntoView && Boolean(node); if (!forceScroll && key === this._lastBroadcastNodeKey) return; @@ -222,15 +172,37 @@ export class EwEditorDoc extends LitElement { port: this.quickEditPort, iframe: this._wysiwygIframe, suppressRerender: false, + lastBlockIndex: undefined, owner: org, repo, path: controllerPathnameFromEditorCtx(this.ctx), - canWrite: this._canWrite === true, getToken: async () => (await loadIms())?.accessToken?.token ?? null, }; wireQuickEditControllerPort(this._controllerCtx); } + _wireProseFocus(view, proseEl) { + this._unwireProseFocus?.(); + const onFocusIn = () => toolbarController.activate('doc'); + const onFocusOut = () => { + // Defer so focus can settle. If it landed on the toolbar (button/dialog), + // stay active; otherwise the user left the doc surface. + setTimeout(() => { + const tb = toolbarController.ensureToolbar(); + const active = document.activeElement; + if (active && (active === tb || tb.contains(active))) return; + toolbarController.deactivate('doc'); + }, 0); + }; + proseEl.addEventListener('focusin', onFocusIn); + proseEl.addEventListener('focusout', onFocusOut); + this._unwireProseFocus = () => { + proseEl.removeEventListener('focusin', onFocusIn); + proseEl.removeEventListener('focusout', onFocusOut); + this._unwireProseFocus = undefined; + }; + } + _setupAwareness(wsProvider) { if (this._awarenessOff) { this._awarenessOff(); @@ -251,6 +223,7 @@ export class EwEditorDoc extends LitElement { _teardown() { this._stopObservingUndoManager(); + this._unwireProseFocus?.(); const { wsProvider, view, proseEl } = this._proseContext ?? {}; teardownEditorDocResources({ clearPortHandler: () => this._clearControllerPort(), @@ -269,44 +242,41 @@ export class EwEditorDoc extends LitElement { return; } - const session = this.session ?? await resolveEditorDocSession(this.ctx); + const sourceUrl = sourceUrlFromEditorCtx(this.ctx); + + const session = this.session ?? await resolveEditorDocSession(sourceUrl); if (!session.ok) { this._error = session.error; return; } - const { sourceUrl } = session; try { const { token, permissions } = session; - this._canWrite = permissions.some((permission) => permission === 'write'); const { proseEl, wsProvider, view, ydoc, undoManager } = await initProse({ path: sourceUrl, permissions, setEditable: (editable) => this._setEditable(editable), getToken: () => token, extraPlugins: [ - mediaBusImage(this.ctx), createExtensionsBridgePlugin(), createTrackingPlugin( () => { const body = this._controllerCtx ? updateDocument(this._controllerCtx) : getInstrumentedHTML(this._proseContext?.view); - if (body) canvasBus.editorHtmlState.emit(body); + if (body) editorHtmlChange.emit(body); }, () => { if (this._controllerCtx) updateCursors(this._controllerCtx); }, (data) => { if (this._controllerCtx) getEditor(data, this._controllerCtx); }, (pmView) => { const blockIndex = getActiveBlockIndex(pmView); - const proseIndex = activeContentProseIndex(pmView); const { kind, ...descriptor } = describeDocSelection(pmView); const selKey = `${descriptor.selFrom}|${descriptor.selTo}|${kind}`; if (blockIndex === this._lastDocBlockIndex && selKey === this._lastDocSelKey) return; this._lastDocBlockIndex = blockIndex; this._lastDocSelKey = selKey; - canvasBus.editorSelectState.emit({ + editorSelectChange.emit({ blockIndex, - proseIndex, source: 'doc', explicit: descriptor.selectionType === SEL_BLOCK, ...descriptor, @@ -318,12 +288,9 @@ export class EwEditorDoc extends LitElement { }); this._proseContext = { proseEl, wsProvider, view, ydoc, undoManager }; - setSelectionToolbarCtx({ - org: this.ctx?.org, - site: this.ctx?.repo, - sourceUrl, - canWrite: this._canWrite, - }); + setSelectionToolbarCtx({ org: this.ctx?.org, site: this.ctx?.repo, sourceUrl }); + toolbarController.setDocView(view); + this._wireProseFocus(view, proseEl); this._setupAwareness(wsProvider); this._observeUndoManager(undoManager); this._emitHtmlChange(); @@ -341,29 +308,25 @@ export class EwEditorDoc extends LitElement { connectedCallback() { super.connectedCallback(); this.shadowRoot.adoptedStyleSheets = [style]; - this._unsubscribeEditorActive = canvasBus.editorViewState.subscribe(({ view }) => { - this._editorView = view; + this._onCanvasEditorActive = (e) => { + const view = e.detail?.view; this.hidden = view === 'layout'; - hideSelectionToolbar(); - }); - this._unsubscribeWysiwygPortReady = canvasBus.wysiwygPortReady.subscribe( - ({ port, iframe } = {}) => { - if (port) { - this._wysiwygIframe = iframe; - this.quickEditPort = port; - } - }, - ); - this._unsubscribeSelect = canvasBus.editorSelectState + }; + this.parentElement?.addEventListener('nx-canvas-editor-active', this._onCanvasEditorActive); + this._onWysiwygPortReady = (e) => { + const { port, iframe } = e.detail ?? {}; + if (port) { + this._wysiwygIframe = iframe; + this.quickEditPort = port; + } + }; + this.parentElement?.addEventListener('nx-wysiwyg-port-ready', this._onWysiwygPortReady); + this._unsubscribeSelect = editorSelectChange .subscribe(({ blockIndex, source }) => { if (source === 'doc') return; this._scrollDocToBlock(blockIndex); if (source === 'outline') this._broadcastSelectedNode(true); }); - this._unsubscribeProseSelect = canvasBus.editorProseSelectState - .subscribe(({ proseIndex, kind }) => this._scrollDocToProseIndex(proseIndex, kind)); - this._unsubscribeBlockEditRequest = canvasBus.blockEditRequest - .subscribe(({ pos } = {}) => this.enterBlockEdit(pos)); this._onCanvasHighlight = (e) => this._applyHighlight(e.detail); document.addEventListener(CHAT_EVENT.HIGHLIGHT_SELECTION, this._onCanvasHighlight); } @@ -372,79 +335,14 @@ export class EwEditorDoc extends LitElement { applyHighlight(this._proseContext?.view, detail); } - /** True while the single-block edit modal is open. */ - get blockEditMode() { - return !!this._blockEditMode; - } - - /** - * Open the single-block editor in a modal: focus the block at `pos` (hides every - * other block via blockFocus decorations) and render the doc mount inside a dialog. - * The live ProseMirror view stays put in this shadow root — only its container in - * the render output changes — so collab, cursors and the toolbars keep working. - */ - enterBlockEdit(pos) { - const view = this._proseContext?.view; - if (!view || pos == null) return; - const node = view.state.doc.nodeAt(pos); - if (!node || node.type.name !== 'table') return; - setBlockFocus(view, pos); - this._blockEditName = getTableBlockName(node); - this._blockEditMode = true; - // Un-hide the (layout-hidden) host, but collapse its box via `:host(.block-edit)` - // so the top-layer dialog doesn't claim a flex slot and shrink the preview. - this.hidden = false; - this.classList.add('block-edit'); - // The toolbar is a manual popover in the dialog's top layer and swallows Escape, so - // close the modal on Escape ourselves (unless a toolbar dropdown is handling it). - this._onBlockEditKeydown = (e) => { - if (e.key !== 'Escape' || getSelectionToolbar().isInteracting) return; - e.preventDefault(); - this.exitBlockEdit(); - }; - document.addEventListener('keydown', this._onBlockEditKeydown, true); - canvasBus.blockEditState.emit({ open: true }); - view.focus(); - } - - // Only the dialog's own `close` should exit block edit — not `close` events bubbling - // up from the toolbar's menus/pickers/dialogs hosted inside the modal (picking e.g. - // "Add row below" closes that menu and would otherwise close the whole modal). - _onModalClose(e) { - if (e.target !== e.currentTarget) return; - this.exitBlockEdit(); - } - - exitBlockEdit() { - if (!this._blockEditMode) return; - this._blockEditMode = false; - this._blockEditName = undefined; - this.classList.remove('block-edit'); - this.hidden = this._editorView === 'layout'; - if (this._onBlockEditKeydown) { - document.removeEventListener('keydown', this._onBlockEditKeydown, true); - this._onBlockEditKeydown = undefined; - } - hideSelectionToolbar(); - // Return the toolbar to the body before the modal DOM is torn down by re-render. - const toolbar = getSelectionToolbar(); - if (toolbar.parentElement && toolbar.parentElement !== document.body) { - document.body.appendChild(toolbar); - } - const view = this._proseContext?.view; - if (view) clearBlockFocus(view); - canvasBus.blockEditState.emit({ open: false }); - } - disconnectedCallback() { - this._unsubscribeEditorActive?.(); - this._unsubscribeWysiwygPortReady?.(); + this.parentElement?.removeEventListener('nx-canvas-editor-active', this._onCanvasEditorActive); + this.parentElement?.removeEventListener('nx-wysiwyg-port-ready', this._onWysiwygPortReady); document.removeEventListener(CHAT_EVENT.HIGHLIGHT_SELECTION, this._onCanvasHighlight); this._unsubscribeSelect?.(); - this._unsubscribeProseSelect?.(); - this._unsubscribeBlockEditRequest?.(); this._teardown(); setSelectionToolbarCtx(); + toolbarController.reset(); super.disconnectedCallback(); } @@ -464,13 +362,6 @@ export class EwEditorDoc extends LitElement { if (proseEl) { ensureProseMountedInShadow({ shadowRoot: this.shadowRoot, proseEl }); } - if (this._blockEditMode) { - // Host the selection toolbar inside the dialog so it sits in the dialog's top - // layer (a body-level toolbar would render behind the modal backdrop). - const host = this.shadowRoot.querySelector('.block-edit-toolbar-host'); - const toolbar = getSelectionToolbar(); - if (host && toolbar.parentElement !== host) host.appendChild(toolbar); - } } render() { @@ -497,39 +388,12 @@ export class EwEditorDoc extends LitElement { if (phase === 'loading') { return nothing; } - if (this._blockEditMode) { - return this._renderBlockEditModal(); - } return html`
`; } - - _renderBlockEditModal() { - const title = this._blockEditName - ? `Edit ${this._blockEditName}` : 'Edit block'; - return html` - this._onModalClose(e)}> -
-
- ${title} - -
-
-
-
-
-
- -
-
-
- `; - } } customElements.define('ew-editor-doc', EwEditorDoc); diff --git a/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js index c54b00eca..807019230 100644 --- a/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js +++ b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js @@ -2,7 +2,7 @@ import { LitElement, html } from 'da-lit'; 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 { toolbarController } from '../editor-utils/toolbar-controller.js'; import { MESSAGE_TYPES } from '../utils/quick-edit-messages.js'; import { canvasBus } from '../utils/canvas-bus.js'; @@ -104,7 +104,6 @@ export class EwEditorWysiwyg extends LitElement { const view = this._canvasActiveView ?? 'layout'; const showWysiwyg = view === 'layout' || view === 'split'; this.hidden = !showWysiwyg; - hideSelectionToolbar(); } _resetCookieStateForCtxChange() { @@ -189,6 +188,7 @@ export class EwEditorWysiwyg extends LitElement { const { org, repo, path } = this.ctx ?? {}; if (!iframe?.contentWindow || !org || !repo || !path) return; + toolbarController.setIframe(iframe); this.removeAttribute(WYSIWYG_PORT_READY_ATTR); this._clearQuickEditRetry(); this._syncCanvasVisibility(); @@ -211,8 +211,20 @@ export class EwEditorWysiwyg extends LitElement { this._scheduleQuickEditInitRetries(send); } + _onIframeFocus() { + const iframe = this.shadowRoot?.querySelector('iframe'); + toolbarController.activate('wysiwyg', { iframeEl: iframe }); + } + _onIframeBlur() { - hideSelectionToolbar(); + // Defer so focus can settle. If it landed on the toolbar (button/dialog), keep + // the wysiwyg surface active; otherwise the user has left the pane. + setTimeout(() => { + const tb = toolbarController.ensureToolbar(); + const active = document.activeElement; + if (active && (active === tb || tb.contains(active))) return; + toolbarController.deactivate('wysiwyg'); + }, 0); } render() { @@ -234,6 +246,7 @@ export class EwEditorWysiwyg extends LitElement { allow="local-network-access" class="ew-editor-wysiwyg-iframe" @load=${this._onIframeLoad} + @focus=${this._onIframeFocus} @blur=${this._onIframeBlur} > `; diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js index 872eb7006..28b9c2627 100644 --- a/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js +++ b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js @@ -1,21 +1,24 @@ import { TextSelection, NodeSelection, yUndo, yRedo } from 'da-y-wrapper'; import { - getSelectionToolbar, - canShowSelectionToolbar, NX_QUICK_EDIT_IFRAME_SELECTION_META, NX_QUICK_EDIT_CLEAR_IFRAME_SELECTION_ORIGIN_META, } from '../../editor-utils/selection-toolbar.js'; -import { canvasBus } from '../../utils/canvas-bus.js'; +import { editorSelectChange, dispatchWithFakeFocus } from '../../editor-utils/editor-utils.js'; +import { toolbarController } from '../../editor-utils/toolbar-controller.js'; +import { getActiveBlockIndex } from '../../editor-utils/blocks.js'; export function handleCursorMove({ cursorOffset, textCursorOffset }, ctx) { const { view, wsProvider } = ctx; if (!view || !wsProvider) return; if (cursorOffset == null || textCursorOffset == null) { - delete view.hasFocus; + // Per-block blur from the iframe — its documented purpose is clearing the + // remote cursor, NOT hiding the toolbar. The user is still in the pane while + // the iframe holds focus; toolbar deactivation comes from the iframe's blur. wsProvider.awareness.setLocalStateField('cursor', null); - const tb = getSelectionToolbar(); - if (!tb.isInteracting && !tb.linkDialogOpen) tb.hide?.(); + // Forget the last position so re-entering (even at the same offset) counts as + // a move and resets stored marks rather than preserving a stale queued mark. + ctx.lastCursorPos = null; return; } @@ -29,44 +32,45 @@ 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. + // Sync stored marks to the cursor's location. marksAcross() returns Mark.none + // when the cursor sits at the end of a mark run (nothing to the right), so the + // toolbar would show the mark inactive even though the text is marked; + // inspecting nodeBefore/nodeAfter covers both sides. 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); + // A real cursor move always resets stored marks to what's at the new location + // (which is nothing when the text there is unmarked). A toolbar-toggled mark is + // only queued for the next keystroke — it must survive the *same-position* + // cursor-move the iframe re-reports after the toggle, but not an actual move. + const cursorMoved = position !== ctx.lastCursorPos; if (marksAtCursor) { // Cursor is adjacent to marked text — use those marks (handles Cmd+B case). tr.setStoredMarks(marksAtCursor); + } else if (cursorMoved) { + // Moved onto unmarked text — clear so nothing lingers from the old position. + tr.setStoredMarks(null); } 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. + // Same position after a toolbar toggle — keep the queued mark until the user + // types or actually moves the cursor. tr.setStoredMarks(state.storedMarks); } + ctx.lastCursorPos = position; ctx.suppressRerender = true; - // dispatch() already triggers createTrackingPlugin's hook, which emits - // canvasBus.editorSelectState with the full payload (incl. proseIndex) — a second, - // blockIndex-only emit here would clobber that and collapse the outline. - view.dispatch(tr.scrollIntoView()); + dispatchWithFakeFocus(view, tr.scrollIntoView()); ctx.suppressRerender = false; - const tb = getSelectionToolbar(); - if (canShowSelectionToolbar() && !tb.linkDialogOpen && !tb.isInteracting) { - tb.view = view; - tb.show(); + toolbarController.setWysiwygSelection({ showable: true }); + const blockIndex = getActiveBlockIndex(view); + if (blockIndex !== ctx.lastBlockIndex) { + ctx.lastBlockIndex = blockIndex; + editorSelectChange.emit({ blockIndex, source: 'wysiwyg' }); } } catch (error) { // eslint-disable-next-line no-console @@ -78,23 +82,15 @@ 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 handleNewVersion() { - canvasBus.newVersionRequest.emit(); + document.dispatchEvent(new CustomEvent('nx-canvas-new-version', { bubbles: true, composed: true })); } export function handleStoredMarks({ marks }, ctx) { @@ -112,10 +108,9 @@ export function handleStoredMarks({ marks }, ctx) { const { tr } = state; tr.setStoredMarks(parsedMarks); ctx.suppressRerender = true; - view.dispatch(tr); + dispatchWithFakeFocus(view, tr); ctx.suppressRerender = false; - const tb = getSelectionToolbar(); - if (tb.open && !tb.isInteracting) tb.requestUpdate(); + toolbarController.refresh(); } catch (e) { // eslint-disable-next-line no-console console.error('[quick-edit-controller] handleStoredMarks failed', e?.message); @@ -133,7 +128,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) { @@ -143,35 +138,29 @@ export function handleSelectionChange({ anchor, head }, ctx, { fromQuickEditIfra } } -function showToolbarInIFrame(ctx) { - if (!canShowSelectionToolbar()) return; - 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 } = ctx; if (anchor === head) { - const tb = getSelectionToolbar(); - if (tb.isInteracting) return; - const { view } = ctx; + // Collapsed to a caret: clear the iframe-origin flag so subsequent doc + // transactions are read normally. Still a caret in the iframe, so the toolbar + // stays active/showable. 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; } + toolbarController.setWysiwygSelection({ showable: true }); return; } if (!handleSelectionChange(data, ctx, { fromQuickEditIframe: true })) return; - showToolbarInIFrame(ctx); + toolbarController.setWysiwygSelection({ showable: true }); } function srcFileName(src) { @@ -233,7 +222,7 @@ export function handleNodeSelect({ node }, ctx) { .setSelection(TextSelection.near(state.doc.resolve(state.selection.from), 1)) .setMeta('addToHistory', false); ctx.suppressRerender = true; - view.dispatch(tr); + dispatchWithFakeFocus(view, tr); ctx.suppressRerender = false; return; } @@ -244,8 +233,10 @@ export function handleNodeSelect({ node }, ctx) { .scrollIntoView() .setMeta('addToHistory', false); ctx.suppressRerender = true; - view.dispatch(tr); + dispatchWithFakeFocus(view, tr); ctx.suppressRerender = false; + // Tables have their own UI; the toolbar hides for them. Images keep it. + toolbarController.setWysiwygSelection({ showable: node.anchorType !== 'table' }); } catch (e) { // eslint-disable-next-line no-console console.error('[quick-edit-controller] handleNodeSelect failed', e?.message); diff --git a/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js index 598be7847..0bdf16ba2 100644 --- a/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js +++ b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js @@ -6,6 +6,7 @@ import { getLinkInfoInSelection, applyLink, } from '../editor-utils/command-helpers.js'; +import { toolbarController } from '../editor-utils/toolbar-controller.js'; const { loadStyle } = await import(`${getNx()}/utils/utils.js`); const { PANEL_EVENT } = await import(`${getNx()}/utils/panel.js`); @@ -41,6 +42,7 @@ function blockTypeLabelForRaw(raw) { class EwSelectionToolbar extends LitElement { static properties = { view: { attribute: false }, + activeSurface: { attribute: false }, org: { type: String }, site: { type: String }, sourceUrl: { type: String }, @@ -55,27 +57,11 @@ class EwSelectionToolbar extends LitElement { connectedCallback() { super.connectedCallback(); this.shadowRoot.adoptedStyleSheets = [styles]; - this._onOutsidePointerDown = (e) => { - if (!this.open) return; - const path = e.composedPath(); - if (path.includes(this)) return; - const editorDom = this.view?.dom; - if (editorDom && path.includes(editorDom)) return; - this.hide(); - }; - document.addEventListener('pointerdown', this._onOutsidePointerDown); - } - - disconnectedCallback() { - super.disconnectedCallback(); - document.removeEventListener('pointerdown', this._onOutsidePointerDown); } get _picker() { return this.shadowRoot?.querySelector('nx-picker'); } - get _menus() { return [...(this.shadowRoot?.querySelectorAll('nx-menu') ?? [])]; } - - get _wrap() { return this.shadowRoot?.querySelector('.toolbar-wrap'); } + get _imageMenu() { return this.shadowRoot?.querySelector('nx-menu'); } show() { const main = document.querySelector('main'); @@ -89,21 +75,7 @@ class EwSelectionToolbar extends LitElement { hide() { this.classList.remove('open'); - this._menus.forEach((m) => m.close()); - this.requestUpdate(); - } - - // The toolbar box is a top-layer popover so it renders above a modal (block - // edit); keep its open state in sync with the `.open` class after each render. - _syncPopover() { - const wrap = this._wrap; - if (!wrap?.showPopover) return; - const isOpen = wrap.matches(':popover-open'); - if (this.open && !isOpen) { - try { wrap.showPopover(); } catch { /* not yet connected */ } - } else if (!this.open && isOpen) { - try { wrap.hidePopover(); } catch { /* already hidden */ } - } + this._imageMenu?.close(); } get open() { @@ -113,7 +85,7 @@ class EwSelectionToolbar extends LitElement { get isInteracting() { return (this._picker?.open ?? false) || (this._altDialogOpen ?? false) - || this._menus.some((m) => m.open); + || (this._imageMenu?.open ?? false); } _icon(name) { @@ -141,7 +113,8 @@ class EwSelectionToolbar extends LitElement { if (cmd) { cmd.apply(this.view); this.requestUpdate(); - this.view.focus(); + toolbarController.restoreFocus(); + toolbarController.refresh(); } } @@ -179,7 +152,10 @@ class EwSelectionToolbar extends LitElement { if (!id) return; COMMAND_BY_ID.get(id)?.apply(this.view); this.requestUpdate(); - if (!this._linkDialogOpen && !this._altDialogOpen) this.view.focus(); + if (!this._linkDialogOpen && !this._altDialogOpen) { + toolbarController.restoreFocus(); + toolbarController.refresh(); + } } /* ---- Link dialog ---- */ @@ -202,14 +178,16 @@ class EwSelectionToolbar extends LitElement { _closeLinkDialog() { this._linkDialogOpen = false; - this.view?.focus(); + toolbarController.restoreFocus(); + toolbarController.refresh(); } _onLinkDialogSubmit(e) { const { href, text } = e.detail; this._closeLinkDialog(); applyLink(this.view, { href, text }); - this.view.focus(); + toolbarController.restoreFocus(); + toolbarController.refresh(); } get linkDialogOpen() { return this._linkDialogOpen ?? false; } @@ -225,7 +203,8 @@ class EwSelectionToolbar extends LitElement { _closeAltDialog() { this._altDialogOpen = false; - this.view?.focus(); + toolbarController.restoreFocus(); + toolbarController.refresh(); } _onAltDialogSubmit(e) { @@ -234,7 +213,8 @@ class EwSelectionToolbar extends LitElement { const { pos } = this.view.state.selection.$anchor; this._closeAltDialog(); this.view.dispatch(this.view.state.tr.setNodeAttribute(pos, 'alt', alt)); - this.view.focus(); + toolbarController.restoreFocus(); + toolbarController.refresh(); } get altDialogOpen() { return this._altDialogOpen ?? false; } @@ -294,7 +274,6 @@ class EwSelectionToolbar extends LitElement { if (changed.has('org') || changed.has('site')) { this._checkAemAssets(); } - this._syncPopover(); } _renderToolbarButton({ id, label, icon }) { @@ -340,28 +319,6 @@ class EwSelectionToolbar extends LitElement { return this._renderToolbarButton(item); } - _onTableMenuSelect(e) { - if (!this.view) return; - COMMAND_BY_ID.get(e.detail.id)?.apply(this.view); - this.requestUpdate(); - this.view.focus(); - } - - _renderTableMenu() { - const items = TABLE_ITEMS - .filter(({ id }) => this._isCommandVisible(id)) - .map(({ id, label, icon }) => ({ id, label, icon })); - return html` - this._onTableMenuSelect(e)}> - - - `; - } - _renderBlockTypePicker() { return html` @@ -381,16 +338,27 @@ class EwSelectionToolbar extends LitElement { const renderButtons = (items) => items.map((i) => this._renderToolbarButton(i)); const renderImageItems = (items) => items.map((i) => this._renderImageItem(i)); + const inWysiwyg = this.activeSurface === 'wysiwyg'; + // The iframe owns block insertion, so "add image" is doc-only; editing a + // selected image's alt text stays available in wysiwyg. + const imageItems = inWysiwyg + ? IMAGE_ITEMS.filter((i) => i.id !== 'image-add') + : IMAGE_ITEMS; + + // `wysiwyg` marks sections offered while editing in the WYSIWYG iframe. The + // iframe owns block-level structure (block type, lists, tables), so those are + // doc-only; inline marks, links, and image controls remain in both surfaces. const sections = [ - { items: PICKER_DEFS, render: () => this._renderBlockTypePicker() }, - { items: MARK_ITEMS, render: () => renderButtons(MARK_ITEMS) }, - { items: STRUCTURE_ITEMS, render: () => renderButtons(STRUCTURE_ITEMS) }, - { items: TABLE_ITEMS, render: () => this._renderTableMenu() }, - { items: LINK_ITEMS, render: () => renderButtons(LINK_ITEMS) }, - { items: IMAGE_ITEMS, render: () => renderImageItems(IMAGE_ITEMS) }, + { items: PICKER_DEFS, wysiwyg: false, render: () => this._renderBlockTypePicker() }, + { items: MARK_ITEMS, wysiwyg: true, render: () => renderButtons(MARK_ITEMS) }, + { items: STRUCTURE_ITEMS, wysiwyg: false, render: () => renderButtons(STRUCTURE_ITEMS) }, + { items: TABLE_ITEMS, wysiwyg: false, render: () => renderButtons(TABLE_ITEMS) }, + { items: LINK_ITEMS, wysiwyg: true, render: () => renderButtons(LINK_ITEMS) }, + { items: imageItems, wysiwyg: true, render: () => renderImageItems(imageItems) }, ]; - const visible = sections.filter(({ items }) => this._hasVisibleCommands(items)); + const allowed = inWysiwyg ? sections.filter((s) => s.wysiwyg) : sections; + const visible = allowed.filter(({ items }) => this._hasVisibleCommands(items)); return visible.flatMap(({ render }, i) => { const part = render(); return i === 0 ? [part] : [html``, part]; @@ -400,7 +368,7 @@ class EwSelectionToolbar extends LitElement { render() { const disabled = !this.view; return html` -
e.preventDefault()}> +
e.preventDefault()}>
this._onToolbarClick(e)}> ${this._renderSections()} diff --git a/docs/canvas-toolbar-architecture.md b/docs/canvas-toolbar-architecture.md new file mode 100644 index 000000000..2782189af --- /dev/null +++ b/docs/canvas-toolbar-architecture.md @@ -0,0 +1,303 @@ +# Canvas selection-toolbar architecture + +Status: design (pre-implementation) +Scope: the shared selection toolbar across the canvas doc editor and the WYSIWYG +iframe, in all three view modes (`layout`, `content`, `split`). + +This document is the **authority contract** for a redesign. It exists because the +current implementation, and a prior refactor attempt (PR #1018, closed), both +suffer from the toolbar intermittently failing to appear — most visibly on the +WYSIWYG (layout) side of split view. The contract below is grounded in three +investigation spikes whose findings are summarised at the end. + +--- + +## 1. Background: what exists today + +- There is **one** ProseMirror view — the doc editor's (`ew-editor-doc`). It is + always the command target. The WYSIWYG pane is a cross-origin iframe (the da-nx + "quick-edit" preview) running its own editors that mirror state to the doc view + over a `MessageChannel`. Keeping the doc view as the single command target is + correct and is **retained** by this design. +- The toolbar is a single global `` (`position: fixed`) + appended to `document.body`. +- Toolbar visibility is currently written from ~6 places by **two competing + drivers**: + - the ProseMirror plugin in `editor-utils/selection-toolbar.js`, which gates on + `view.hasFocus()`; and + - the iframe message handlers in `ew-editor-wysiwyg/utils/handlers.js`, which + fake `view.hasFocus = () => true` and call `show()/hide()` directly. + +The core defect: visibility is derived from `view.hasFocus()` **while that same +value is being faked**. The two ideas are mutually contradictory, so every fix +on top of the current model spawns a new race. + +--- + +## 2. Root causes (evidence-ranked) + +1. **Null `cursor-move` misused as "hide toolbar" (dominant).** da-nx sends a + `cursor-move` with no offsets as a **per-block blur** signal (v1 debounced + 150ms; v2 immediate). Its documented meaning is *"clear the remote cursor"* + (`nx/utils/message-types.js`). da-live's `handleCursorMove` overloaded it to + also hide the toolbar. Because it is per-block, ordinary actions inside the + iframe (clicking between blocks, clicking a non-editable region, a block + re-rendering after an edit) fire it even though focus never left the iframe — + hiding the toolbar until the next real cursor move. +2. **No coalescing.** A single range drag emits ~30 `selection-change` messages, + each triggering 1–2 visibility recomputes. +3. **The `view.hasFocus()` lie leaks into visibility.** The fake focus is required + for collaboration (see §6) but must not participate in the toolbar decision. + In PR #1018 it still did — a mirror dispatch that ran the visibility plugin + under faked focus could claim the wrong active surface. +4. **`document.activeElement` heuristics across the iframe boundary** are + timing- and browser-dependent (used by #1018's deferred blur handlers). + +Non-issue: a suspected "caret `selection-change` before `cursor-move`" ordering +race on first click **did not reproduce** in the current da-nx build. + +--- + +## 3. Key insight that drives the design + +**"Active surface = WYSIWYG" ⟺ focus is inside the iframe.** While the user clicks +between blocks *inside* the iframe, focus stays in the frame — only da-nx's +internal per-block editors change focus, which the parent never sees. The null +`cursor-move` (a per-block blur) does **not** mean focus left the frame, which is +exactly why it must not drive visibility. + +**How to detect it (corrected after testing).** The obvious signal — a `focus`/ +`blur` event on the `