From 2815eaca2dfa78ed1e76050bc6207fea1f19c52a Mon Sep 17 00:00:00 2001 From: Cedric Huesler Date: Fri, 28 Aug 2026 12:10:56 -0700 Subject: [PATCH 1/2] feat: let canvas extensions select a section A panel extension can read the document and insert into it, but has no way to move the selection to a known place. That blocks two things a structural authoring plugin needs: jumping the canvas to a section the way the outline rail does, and editing a section at all -- insertHTML already replaces the current selection, so the edit loop works as soon as something can position that selection. Without it the only route left is writing source via admin.da.live underneath a live collab session, which risks clobbering or being clobbered by the y.js document. selectSection reuses the same "split top-level nodes on horizontal_rule" walk that deleteSection and moveSection already use, and ends in TextSelection plus scrollIntoView. Selecting the whole section rather than only scrolling to it is deliberate: it gives the jump and leaves a replaceable range under sendHTML, so editing needs no second API. Refs #1281 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EmqV8wmTVrzTsEYCd5y5rT --- blocks/canvas/editor-utils/blocks.js | 32 ++++++++++++++++++- .../ew-panel-extensions/iframe-protocol.js | 7 ++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/blocks/canvas/editor-utils/blocks.js b/blocks/canvas/editor-utils/blocks.js index 992ed3074..9148383b6 100644 --- a/blocks/canvas/editor-utils/blocks.js +++ b/blocks/canvas/editor-utils/blocks.js @@ -1,4 +1,4 @@ -import { DOMParser as PMDOMParser, NodeSelection } from 'da-y-wrapper'; +import { DOMParser as PMDOMParser, NodeSelection, TextSelection } from 'da-y-wrapper'; const NON_BLOCK_TABLE_NAMES = new Set(['metadata', 'section metadata', 'section-metadata']); @@ -223,6 +223,36 @@ export function appendBlockRow(view, tablePos, rowDom) { view.dispatch(tr.scrollIntoView()); } +/** Doc range of each section, split on horizontal rules (see deleteSection). */ +export function getSectionRanges(view) { + const { doc, schema } = view.state; + const ranges = []; + let current = null; + doc.forEach((node, offset) => { + if (node.type === schema.nodes.horizontal_rule) { + current = null; + return; + } + if (!current) { + current = { from: offset, to: offset }; + ranges.push(current); + } + current.to = offset + node.nodeSize; + }); + return ranges; +} + +/** Select a whole section and scroll it into view. */ +export function selectSection(view, sectionIndex) { + if (!view) return false; + const range = getSectionRanges(view)[sectionIndex]; + if (!range) return false; + const { tr, doc } = view.state; + view.dispatch(tr.setSelection(TextSelection.create(doc, range.from, range.to)).scrollIntoView()); + view.focus(); + return true; +} + export function deleteSection(view, sectionIndex) { if (!view) return; const { doc, schema } = view.state; diff --git a/blocks/canvas/ew-panel-extensions/iframe-protocol.js b/blocks/canvas/ew-panel-extensions/iframe-protocol.js index a2af96bbe..d21eed65e 100644 --- a/blocks/canvas/ew-panel-extensions/iframe-protocol.js +++ b/blocks/canvas/ew-panel-extensions/iframe-protocol.js @@ -1,4 +1,5 @@ import { insertText, insertHTML, getEditorSelection } from './helpers.js'; +import { selectSection } from '../editor-utils/blocks.js'; import { getNx } from '../../../scripts/utils.js'; import { getPostMessageTargetOrigin, isValidHref } from '../../shared/utils.js'; @@ -61,6 +62,12 @@ export async function setupIframeChannel({ iframe, hashState, getView, onClose } ); } + if (action === 'selectSection') { + const ok = editorView ? selectSection(editorView, details?.index) : false; + channel.port1.postMessage({ action: 'selectSectionResult', details: { ok } }); + return; + } + if (action === 'getSelection') { if (!editorView) { channel.port1.postMessage({ action: 'error', details: 'No editor view' }); From b7a8fc08527262b75c0459a4c4dd6eb608ab78f4 Mon Sep 17 00:00:00 2001 From: Cedric Huesler Date: Fri, 28 Aug 2026 12:37:34 -0700 Subject: [PATCH 2/2] fix: route plugin navigation through the outline's own path Jumping and range-selecting are different asks, and only the first has precedent. The outline addresses blocks and prose items, never sections -- its section row is a drag handle with add/delete, not a click target -- and the jump it performs is canvasBus.editorSelectState -> _scrollDocToBlock. Driving the view directly, as the first version did, works but goes around all of that: the outline highlight stays stale and _broadcastSelectedNode never runs, so the wysiwyg side never learns about the selection. scrollToBlock now emits on the bus with the same blockIndex address instead. selectSection stays, for a different reason than navigation: getBlockPositions excludes NON_BLOCK_TABLE_NAMES, so a section's metadata table has no blockIndex and cannot be addressed at all. The section range is what makes it reachable, and leaves something for sendHTML to replace. Refs #1281 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EmqV8wmTVrzTsEYCd5y5rT --- .../canvas/ew-panel-extensions/iframe-protocol.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/blocks/canvas/ew-panel-extensions/iframe-protocol.js b/blocks/canvas/ew-panel-extensions/iframe-protocol.js index d21eed65e..852131dc8 100644 --- a/blocks/canvas/ew-panel-extensions/iframe-protocol.js +++ b/blocks/canvas/ew-panel-extensions/iframe-protocol.js @@ -1,5 +1,6 @@ import { insertText, insertHTML, getEditorSelection } from './helpers.js'; import { selectSection } from '../editor-utils/blocks.js'; +import { canvasBus } from '../utils/canvas-bus.js'; import { getNx } from '../../../scripts/utils.js'; import { getPostMessageTargetOrigin, isValidHref } from '../../shared/utils.js'; @@ -62,6 +63,19 @@ export async function setupIframeChannel({ iframe, hashState, getView, onClose } ); } + // Navigation reuses the outline's own path — same blockIndex address, same + // scroll — rather than driving the view directly, so the outline highlight + // and doc state stay in step. + if (action === 'scrollToBlock') { + const ok = Number.isInteger(details?.blockIndex) && details.blockIndex >= 0; + if (ok) canvasBus.editorSelectState.emit({ blockIndex: details.blockIndex, source: 'plugin' }); + channel.port1.postMessage({ action: 'scrollToBlockResult', details: { ok } }); + return; + } + + // Separate from navigation: a section range is the only way to reach section + // metadata, which getBlockPositions deliberately excludes from blockIndex. + // Leaves a replaceable range under sendHTML. if (action === 'selectSection') { const ok = editorView ? selectSection(editorView, details?.index) : false; channel.port1.postMessage({ action: 'selectSectionResult', details: { ok } });