From ed773310bdf2915afe4a99a8071d44d5a0ec3b98 Mon Sep 17 00:00:00 2001 From: Markus Haack Date: Mon, 31 Aug 2026 17:04:00 +0200 Subject: [PATCH 1/3] feat(canvas): resolve a section index to its first block/content target Co-Authored-By: Claude Sonnet 5 --- blocks/canvas/editor-utils/editor-utils.js | 27 ++++++++++-- .../canvas/editor-utils/editor-utils.test.js | 42 +++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) 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/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 = '
Hero
'; + 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 = `
+

Title

+

Para

+
`; + 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( + '
Hero
', + ); + expect(resolveSectionTarget(5)).to.equal(undefined); + }); + + it('clears the cache when the html is emptied', () => { + canvasBus.editorHtmlState.emit( + '
Hero
', + ); + expect(resolveSectionTarget(0)).to.deep.equal({ type: 'block', blockIndex: 0 }); + + canvasBus.editorHtmlState.emit(''); + expect(resolveSectionTarget(0)).to.equal(undefined); + }); +}); From 814ebacc718f791d85612a241c7ecadb5542a6ba Mon Sep 17 00:00:00 2001 From: Markus Haack Date: Mon, 31 Aug 2026 17:08:46 +0200 Subject: [PATCH 2/3] feat(canvas): relay extension scrollTo actions onto canvasBus Co-Authored-By: Claude Sonnet 5 --- .../ew-panel-extensions/iframe-protocol.js | 24 ++++ .../iframe-protocol.test.js | 116 ++++++++++++++++++ 2 files changed, 140 insertions(+) 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/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( + '
Hero
', + ); + + 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({ From e32f0a9af0cef2241a35a8cb611668331bce14f5 Mon Sep 17 00:00:00 2001 From: Markus Haack Date: Mon, 31 Aug 2026 17:13:30 +0200 Subject: [PATCH 3/3] refactor(canvas): sync WYSIWYG overlay for any non-doc select source Co-Authored-By: Claude Sonnet 5 --- blocks/canvas/ew-editor-doc/ew-editor-doc.js | 16 ++++--- .../ew-editor-doc/ew-editor-doc.test.js | 44 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) 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/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]]); + }); +});