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`