Skip to content
16 changes: 7 additions & 9 deletions blocks/canvas/canvas.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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';
Expand All @@ -15,12 +16,13 @@
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';

Check failure on line 19 in blocks/canvas/canvas.js

View workflow job for this annotation

GitHub Actions / Running tests (22)

sourceUrlFromEditorCtx not found in './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,
Expand All @@ -38,6 +40,7 @@

function notifyCanvasEditorActive(view) {
const v = normalizeCanvasEditorView(view);
toolbarController.setEditorMode(v);
canvasBus.editorViewState.emit({ view: v });
}

Expand Down Expand Up @@ -103,11 +106,10 @@
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);
Expand All @@ -116,15 +118,11 @@
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 });
Expand Down Expand Up @@ -271,7 +269,7 @@
}

// 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;
Expand Down
29 changes: 25 additions & 4 deletions blocks/canvas/editor-utils/editor-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
114 changes: 13 additions & 101 deletions blocks/canvas/editor-utils/selection-toolbar.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -16,108 +12,34 @@ 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 }) {
return selection instanceof NodeSelection
&& 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,
Expand All @@ -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();
},
};
},
Expand Down
Loading
Loading