Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions blocks/canvas/editor-utils/editor-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
16 changes: 11 additions & 5 deletions blocks/canvas/ew-editor-doc/ew-editor-doc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this could be coming from an extension, maybe we could validate the blockindex prior to doing the broadcast?

}

// 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.
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions blocks/canvas/ew-panel-extensions/iframe-protocol.js
Original file line number Diff line number Diff line change
@@ -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`);
Expand Down Expand Up @@ -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') {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the section branch duplicates the block/content emits from right above. why don't we resolve section into a { type, ... } target and run block/section/content through one shared emit path?

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';
Expand Down
42 changes: 42 additions & 0 deletions test/unit/blocks/canvas/editor-utils/editor-utils.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -315,3 +318,42 @@ describe('parseSections', () => {
]);
});
});

describe('resolveSectionTarget', () => {
it('resolves to the section\'s first block', () => {
const html = '<main><div><div class="hero" data-block-index="0">Hero</div></div></main>';
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 = `<main><div>
<h2 data-prose-index="1">Title</h2>
<p data-prose-index="5">Para</p>
</div></main>`;
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('<main><div></div></main>');
expect(resolveSectionTarget(0)).to.equal(undefined);
});

it('returns undefined for an out-of-range section index', () => {
canvasBus.editorHtmlState.emit(
'<main><div><div class="hero" data-block-index="0">Hero</div></div></main>',
);
expect(resolveSectionTarget(5)).to.equal(undefined);
});

it('clears the cache when the html is emptied', () => {
canvasBus.editorHtmlState.emit(
'<main><div><div class="hero" data-block-index="0">Hero</div></div></main>',
);
expect(resolveSectionTarget(0)).to.deep.equal({ type: 'block', blockIndex: 0 });

canvasBus.editorHtmlState.emit('');
expect(resolveSectionTarget(0)).to.equal(undefined);
});
});
44 changes: 44 additions & 0 deletions test/unit/blocks/canvas/ew-editor-doc/ew-editor-doc.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]]);
});
});
116 changes: 116 additions & 0 deletions test/unit/blocks/canvas/ew-panel-extensions/iframe-protocol.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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); });

Expand Down Expand Up @@ -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(
'<main><div><div class="hero" data-block-index="0">Hero</div></div></main>',
);

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('<main><div></div></main>');

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({
Expand Down
Loading