diff --git a/blocks/canvas/editor-utils/editor-utils.js b/blocks/canvas/editor-utils/editor-utils.js
index c92162562..f9fd2f9ef 100644
--- a/blocks/canvas/editor-utils/editor-utils.js
+++ b/blocks/canvas/editor-utils/editor-utils.js
@@ -366,18 +366,29 @@ export function parseSections(htmlText) {
}
let selectBlockMeta = new Map();
+let sectionFirstItemMeta = new Map();
canvasBus.editorHtmlState.subscribe((html) => {
if (!html.trim()) {
selectBlockMeta = new Map();
+ sectionFirstItemMeta = new Map();
return;
}
- const next = new Map();
- for (const { blocks } of parseSections(html)) {
+ const nextBlockMeta = new Map();
+ const nextSectionMeta = new Map();
+ for (const { sectionIndex, blocks, items } of parseSections(html)) {
for (const { name, blockIndex, proseIndex, innerText } of blocks) {
- next.set(blockIndex, { name, proseIndex, innerText });
+ nextBlockMeta.set(blockIndex, { name, proseIndex, innerText });
+ }
+ const first = items[0];
+ if (first?.type === 'block') {
+ nextSectionMeta.set(sectionIndex, { type: 'block', blockIndex: first.blockIndex });
+ } else if (first?.type === 'content' && first.children[0]) {
+ const { proseIndex, kind } = first.children[0];
+ nextSectionMeta.set(sectionIndex, { type: 'content', proseIndex, kind });
}
}
- selectBlockMeta = next;
+ selectBlockMeta = nextBlockMeta;
+ sectionFirstItemMeta = nextSectionMeta;
});
// Runs once, at load, so canvas-bus.js's editorSelectState.emit is enriched for
@@ -389,6 +400,14 @@ registerEditorSelectEnricher((detail) => {
return { ...detail, blockName, proseIndex, innerText };
});
+// The "section" jump target for extensions (see
+// ew-panel-extensions/iframe-protocol.js) — a section has no selectable
+// identity of its own, so it resolves to its first block or content item.
+// Kept in sync by the same editorHtmlState subscription above.
+export function resolveSectionTarget(sectionIndex) {
+ return sectionFirstItemMeta.get(sectionIndex);
+}
+
export function updateDocument(ctx) {
if (ctx.suppressRerender) return undefined;
const body = getInstrumentedHTML(ctx.view);
diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js
index 9eaf2ed0b..638dcd284 100644
--- a/blocks/canvas/ew-editor-doc/ew-editor-doc.js
+++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js
@@ -128,6 +128,16 @@ export class EwEditorDoc extends LitElement {
view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
}
+ // Any non-'doc' source (outline click, extension scrollTo, ...) also syncs the
+ // WYSIWYG overlay — only the doc's own selection changes (source: 'doc') are
+ // excluded, since those already scroll/broadcast via the tracking-plugin callback
+ // in _loadEditor.
+ _onEditorSelectState({ blockIndex, source }) {
+ if (source === 'doc') return;
+ this._scrollDocToBlock(blockIndex);
+ this._broadcastSelectedNode(true);
+ }
+
// 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.
@@ -355,11 +365,7 @@ export class EwEditorDoc extends LitElement {
},
);
this._unsubscribeSelect = canvasBus.editorSelectState
- .subscribe(({ blockIndex, source }) => {
- if (source === 'doc') return;
- this._scrollDocToBlock(blockIndex);
- if (source === 'outline') this._broadcastSelectedNode(true);
- });
+ .subscribe((detail) => this._onEditorSelectState(detail));
this._unsubscribeProseSelect = canvasBus.editorProseSelectState
.subscribe(({ proseIndex, kind }) => this._scrollDocToProseIndex(proseIndex, kind));
this._unsubscribeBlockEditRequest = canvasBus.blockEditRequest
diff --git a/blocks/canvas/ew-panel-extensions/iframe-protocol.js b/blocks/canvas/ew-panel-extensions/iframe-protocol.js
index a2af96bbe..d2ea787dd 100644
--- a/blocks/canvas/ew-panel-extensions/iframe-protocol.js
+++ b/blocks/canvas/ew-panel-extensions/iframe-protocol.js
@@ -1,6 +1,8 @@
import { insertText, insertHTML, getEditorSelection } from './helpers.js';
import { getNx } from '../../../scripts/utils.js';
import { getPostMessageTargetOrigin, isValidHref } from '../../shared/utils.js';
+import { canvasBus } from '../utils/canvas-bus.js';
+import { resolveSectionTarget } from '../editor-utils/editor-utils.js';
const { CHAT_EVENT } = await import(`${getNx()}/utils/chat.js`);
const { PANEL_EVENT } = await import(`${getNx()}/utils/panel.js`);
@@ -76,6 +78,28 @@ export async function setupIframeChannel({ iframe, hashState, getView, onClose }
targetOrigin,
);
}
+
+ if (action === 'scrollTo') {
+ const { type, blockIndex, sectionIndex, proseIndex, kind } = details || {};
+ if (type === 'block') {
+ canvasBus.editorSelectState.emit({ blockIndex, source: 'extension' });
+ } else if (type === 'content') {
+ canvasBus.editorProseSelectState.emit({ proseIndex, kind });
+ } else if (type === 'section') {
+ const resolved = resolveSectionTarget(sectionIndex);
+ if (resolved?.type === 'block') {
+ canvasBus.editorSelectState.emit({
+ blockIndex: resolved.blockIndex,
+ source: 'extension',
+ });
+ } else if (resolved?.type === 'content') {
+ canvasBus.editorProseSelectState.emit({
+ proseIndex: resolved.proseIndex,
+ kind: resolved.kind,
+ });
+ }
+ }
+ }
};
const ref = new URLSearchParams(window.location.search).get('ref') || 'main';
diff --git a/test/unit/blocks/canvas/editor-utils/editor-utils.test.js b/test/unit/blocks/canvas/editor-utils/editor-utils.test.js
index 3067c9c3e..cd6f36c28 100644
--- a/test/unit/blocks/canvas/editor-utils/editor-utils.test.js
+++ b/test/unit/blocks/canvas/editor-utils/editor-utils.test.js
@@ -1,17 +1,20 @@
import { expect } from '@esm-bundle/chai';
import { setNx } from '../../../../../scripts/utils.js';
+import { canvasBus } from '../../../../../blocks/canvas/utils/canvas-bus.js';
setNx('/test/fixtures/nx', { hostname: 'example.com' });
let getPreviewOrigin;
let fetchWysiwygBranch;
let parseSections;
+let resolveSectionTarget;
before(async () => {
const mod = await import('../../../../../blocks/canvas/editor-utils/editor-utils.js');
getPreviewOrigin = mod.getPreviewOrigin;
fetchWysiwygBranch = mod.fetchWysiwygBranch;
parseSections = mod.parseSections;
+ resolveSectionTarget = mod.resolveSectionTarget;
});
describe('getPreviewOrigin', () => {
@@ -315,3 +318,42 @@ describe('parseSections', () => {
]);
});
});
+
+describe('resolveSectionTarget', () => {
+ it('resolves to the section\'s first block', () => {
+ const html = '';
+ canvasBus.editorHtmlState.emit(html);
+ expect(resolveSectionTarget(0)).to.deep.equal({ type: 'block', blockIndex: 0 });
+ });
+
+ it('resolves to the section\'s first content item (a heading before a paragraph)', () => {
+ const html = ``;
+ canvasBus.editorHtmlState.emit(html);
+ expect(resolveSectionTarget(0)).to.deep.equal({ type: 'content', proseIndex: 1, kind: 'heading' });
+ });
+
+ it('returns undefined for an empty section', () => {
+ canvasBus.editorHtmlState.emit('');
+ expect(resolveSectionTarget(0)).to.equal(undefined);
+ });
+
+ it('returns undefined for an out-of-range section index', () => {
+ canvasBus.editorHtmlState.emit(
+ '',
+ );
+ expect(resolveSectionTarget(5)).to.equal(undefined);
+ });
+
+ it('clears the cache when the html is emptied', () => {
+ canvasBus.editorHtmlState.emit(
+ '',
+ );
+ expect(resolveSectionTarget(0)).to.deep.equal({ type: 'block', blockIndex: 0 });
+
+ canvasBus.editorHtmlState.emit('');
+ expect(resolveSectionTarget(0)).to.equal(undefined);
+ });
+});
diff --git a/test/unit/blocks/canvas/ew-editor-doc/ew-editor-doc.test.js b/test/unit/blocks/canvas/ew-editor-doc/ew-editor-doc.test.js
index 257395d23..a10099a27 100644
--- a/test/unit/blocks/canvas/ew-editor-doc/ew-editor-doc.test.js
+++ b/test/unit/blocks/canvas/ew-editor-doc/ew-editor-doc.test.js
@@ -124,3 +124,47 @@ describe('EwEditorDoc — _scrollDocToProseIndex', () => {
});
});
});
+
+describe('EwEditorDoc — _onEditorSelectState', () => {
+ let el;
+
+ beforeEach(() => {
+ el = document.createElement('ew-editor-doc');
+ });
+
+ it('does nothing when source is doc', () => {
+ const scrollCalls = [];
+ const broadcastCalls = [];
+ el._scrollDocToBlock = (...args) => scrollCalls.push(args);
+ el._broadcastSelectedNode = (...args) => broadcastCalls.push(args);
+
+ el._onEditorSelectState({ blockIndex: 2, source: 'doc' });
+
+ expect(scrollCalls).to.have.lengthOf(0);
+ expect(broadcastCalls).to.have.lengthOf(0);
+ });
+
+ it('scrolls and broadcasts for an outline-sourced selection', () => {
+ const scrollCalls = [];
+ const broadcastCalls = [];
+ el._scrollDocToBlock = (...args) => scrollCalls.push(args);
+ el._broadcastSelectedNode = (...args) => broadcastCalls.push(args);
+
+ el._onEditorSelectState({ blockIndex: 2, source: 'outline' });
+
+ expect(scrollCalls).to.deep.equal([[2]]);
+ expect(broadcastCalls).to.deep.equal([[true]]);
+ });
+
+ it('scrolls and broadcasts for an extension-sourced selection', () => {
+ const scrollCalls = [];
+ const broadcastCalls = [];
+ el._scrollDocToBlock = (...args) => scrollCalls.push(args);
+ el._broadcastSelectedNode = (...args) => broadcastCalls.push(args);
+
+ el._onEditorSelectState({ blockIndex: 5, source: 'extension' });
+
+ expect(scrollCalls).to.deep.equal([[5]]);
+ expect(broadcastCalls).to.deep.equal([[true]]);
+ });
+});
diff --git a/test/unit/blocks/canvas/ew-panel-extensions/iframe-protocol.test.js b/test/unit/blocks/canvas/ew-panel-extensions/iframe-protocol.test.js
index 215eb87d7..4df0b9107 100644
--- a/test/unit/blocks/canvas/ew-panel-extensions/iframe-protocol.test.js
+++ b/test/unit/blocks/canvas/ew-panel-extensions/iframe-protocol.test.js
@@ -7,6 +7,7 @@ setNx('/test/fixtures/nx', { hostname: 'example.com' });
const { setupIframeChannel } = await import('../../../../../blocks/canvas/ew-panel-extensions/iframe-protocol.js');
const { CHAT_EVENT } = await import(`${getNx()}/utils/chat.js`);
const { PANEL_EVENT } = await import(`${getNx()}/utils/panel.js`);
+const { canvasBus } = await import('../../../../../blocks/canvas/utils/canvas-bus.js');
const wait = (ms = 50) => new Promise((resolve) => { setTimeout(resolve, ms); });
@@ -123,6 +124,121 @@ describe('setupIframeChannel', () => {
destroy();
});
+ it('emits editorSelectState for a block scrollTo action', async () => {
+ const iframe = makeIframe();
+ const { channel, destroy } = await setupIframeChannel({
+ iframe,
+ hashState: { org: 'myorg', site: 'mysite' },
+ getView: () => null,
+ onClose: () => {},
+ });
+
+ let received;
+ const unsubscribe = canvasBus.editorSelectState.subscribe((detail) => { received = detail; });
+
+ channel.port2.postMessage({ action: 'scrollTo', details: { type: 'block', blockIndex: 3 } });
+ await wait();
+
+ unsubscribe();
+ expect(received).to.deep.include({ blockIndex: 3, source: 'extension' });
+ destroy();
+ });
+
+ it('emits editorProseSelectState for a content scrollTo action', async () => {
+ const iframe = makeIframe();
+ const { channel, destroy } = await setupIframeChannel({
+ iframe,
+ hashState: { org: 'myorg', site: 'mysite' },
+ getView: () => null,
+ onClose: () => {},
+ });
+
+ let received;
+ const unsubscribe = canvasBus.editorProseSelectState.subscribe((detail) => {
+ received = detail;
+ });
+
+ channel.port2.postMessage({
+ action: 'scrollTo',
+ details: { type: 'content', proseIndex: 7, kind: 'heading' },
+ });
+ await wait();
+
+ unsubscribe();
+ expect(received).to.deep.equal({ proseIndex: 7, kind: 'heading' });
+ destroy();
+ });
+
+ it('resolves a section scrollTo action to its first block', async () => {
+ canvasBus.editorHtmlState.emit(
+ '',
+ );
+
+ const iframe = makeIframe();
+ const { channel, destroy } = await setupIframeChannel({
+ iframe,
+ hashState: { org: 'myorg', site: 'mysite' },
+ getView: () => null,
+ onClose: () => {},
+ });
+
+ let received;
+ const unsubscribe = canvasBus.editorSelectState.subscribe((detail) => { received = detail; });
+
+ channel.port2.postMessage({ action: 'scrollTo', details: { type: 'section', sectionIndex: 0 } });
+ await wait();
+
+ unsubscribe();
+ expect(received).to.deep.include({ blockIndex: 0, source: 'extension' });
+ destroy();
+ });
+
+ it('is a no-op for an out-of-range section scrollTo action', async () => {
+ canvasBus.editorHtmlState.emit('');
+
+ const iframe = makeIframe();
+ const { channel, destroy } = await setupIframeChannel({
+ iframe,
+ hashState: { org: 'myorg', site: 'mysite' },
+ getView: () => null,
+ onClose: () => {},
+ });
+
+ let calls = 0;
+ const unsubscribe = canvasBus.editorSelectState.subscribe(() => { calls += 1; });
+
+ channel.port2.postMessage({ action: 'scrollTo', details: { type: 'section', sectionIndex: 9 } });
+ await wait();
+
+ unsubscribe();
+ expect(calls).to.equal(0);
+ destroy();
+ });
+
+ it('is a no-op for an unrecognized scrollTo target type', async () => {
+ const iframe = makeIframe();
+ const { channel, destroy } = await setupIframeChannel({
+ iframe,
+ hashState: { org: 'myorg', site: 'mysite' },
+ getView: () => null,
+ onClose: () => {},
+ });
+
+ let selectCalls = 0;
+ let proseCalls = 0;
+ const unsubSelect = canvasBus.editorSelectState.subscribe(() => { selectCalls += 1; });
+ const unsubProse = canvasBus.editorProseSelectState.subscribe(() => { proseCalls += 1; });
+
+ channel.port2.postMessage({ action: 'scrollTo', details: { type: 'bogus' } });
+ await wait();
+
+ unsubSelect();
+ unsubProse();
+ expect(selectCalls).to.equal(0);
+ expect(proseCalls).to.equal(0);
+ destroy();
+ });
+
it('opens the tools panel for a showPanel action', async () => {
const iframe = makeIframe();
const { channel, destroy } = await setupIframeChannel({