diff --git a/blocks/canvas/editor-utils/blocks.js b/blocks/canvas/editor-utils/blocks.js
index 40f228505..608998742 100644
--- a/blocks/canvas/editor-utils/blocks.js
+++ b/blocks/canvas/editor-utils/blocks.js
@@ -1,4 +1,4 @@
-import { DOMParser as PMDOMParser } from 'da-y-wrapper';
+import { DOMParser as PMDOMParser, NodeSelection } from 'da-y-wrapper';
const NON_BLOCK_TABLE_NAMES = new Set(['metadata', 'section metadata', 'section-metadata']);
@@ -44,6 +44,18 @@ export function getActiveBlockIndex(view) {
return -1;
}
+// Shared by every single-node move; adjusts insertPos for the shift the delete causes,
+// and selects the moved node at its new position.
+function spliceNode(view, from, insertPos) {
+ const adjustedInsertPos = insertPos > from.pos ? insertPos - from.size : insertPos;
+ if (adjustedInsertPos === from.pos) return;
+ const tr = view.state.tr
+ .delete(from.pos, from.pos + from.size)
+ .insert(adjustedInsertPos, from.node);
+ tr.setSelection(NodeSelection.create(tr.doc, adjustedInsertPos));
+ view.dispatch(tr);
+}
+
export function moveBlock(view, fromIndex, toIndex, dropPosition) {
if (!view) return;
if (isSamePosition(fromIndex, toIndex, dropPosition)) return;
@@ -60,20 +72,14 @@ export function moveBlock(view, fromIndex, toIndex, dropPosition) {
if (!fromBlockNode || !toBlockNode) return;
- const fromBlockSize = fromBlockNode.nodeSize;
- const toBlockSize = toBlockNode.nodeSize;
-
const insertPos = dropPosition === 'before'
? toBlockPos
- : toBlockPos + toBlockSize;
- const adjustedInsertPos = insertPos > fromBlockPos
- ? insertPos - fromBlockSize
- : insertPos;
-
- view.dispatch(
- view.state.tr
- .delete(fromBlockPos, fromBlockPos + fromBlockSize)
- .insert(adjustedInsertPos, fromBlockNode),
+ : toBlockPos + toBlockNode.nodeSize;
+
+ spliceNode(
+ view,
+ { pos: fromBlockPos, size: fromBlockNode.nodeSize, node: fromBlockNode },
+ insertPos,
);
}
@@ -87,6 +93,21 @@ export function deleteBlock(view, blockIndex) {
view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize));
}
+// proseIndex sits inside the node's content, not at its own start; depth-1 recovers
+// the whole node regardless of kind or nesting (e.g. a blockquote's nested paragraph).
+export function getContentItemRange(doc, child) {
+ const pos = doc.resolve(child.proseIndex).before(1);
+ const node = doc.nodeAt(pos);
+ return node ? { pos, size: node.nodeSize, node } : null;
+}
+
+export function deleteContentItem(view, child) {
+ if (!view) return;
+ const range = getContentItemRange(view.state.doc, child);
+ if (!range) return;
+ view.dispatch(view.state.tr.delete(range.pos, range.pos + range.size));
+}
+
function getSectionStartOffset(view, sectionIndex) {
const { doc, schema } = view.state;
if (sectionIndex === 0) return 0;
@@ -160,10 +181,99 @@ export function moveSection(view, fromSectionIndex, toSectionIndex, dropPosition
const hrNode = schema.nodes.horizontal_rule.create();
const newNodes = [];
+ let movedSectionStart;
reordered.forEach((sectionNodes, i) => {
if (i > 0) newNodes.push(hrNode);
+ if (sectionNodes === moved) {
+ movedSectionStart = newNodes.reduce((size, node) => size + node.nodeSize, 0);
+ }
newNodes.push(...sectionNodes);
});
- view.dispatch(view.state.tr.replaceWith(0, doc.content.size, newNodes));
+ const tr = view.state.tr.replaceWith(0, doc.content.size, newNodes);
+ if (movedSectionStart != null && moved.length) {
+ tr.setSelection(NodeSelection.create(tr.doc, movedSectionStart));
+ }
+ view.dispatch(tr);
+}
+
+// Counterpart to getSectionStartOffset — the hr position bounding the previous section.
+function getSectionEndOffset(view, sectionIndex) {
+ if (sectionIndex === 0) return 0;
+ const { doc, schema } = view.state;
+ let hrCount = 0;
+ let result = 0;
+ doc.forEach((node, offset) => {
+ if (node.type === schema.nodes.horizontal_rule) {
+ hrCount += 1;
+ if (hrCount === sectionIndex) result = offset;
+ }
+ });
+ return result;
+}
+
+export function moveContentItem(view, fromChild, target, dropPosition) {
+ if (!view) return;
+ const { doc } = view.state;
+ const from = getContentItemRange(doc, fromChild);
+ if (!from) return;
+
+ let insertPos;
+ if (target.type === 'content') {
+ const to = getContentItemRange(doc, target.child);
+ if (!to || to.pos === from.pos) return;
+ insertPos = dropPosition === 'before' ? to.pos : to.pos + to.size;
+ } else if (target.type === 'block') {
+ const positions = getBlockPositions(view);
+ if (target.blockIndex >= positions.length) return;
+ const toPos = positions[target.blockIndex];
+ const toNode = doc.nodeAt(toPos);
+ if (!toNode) return;
+ insertPos = dropPosition === 'before' ? toPos : toPos + toNode.nodeSize;
+ } else if (target.type === 'section') {
+ // before the header = last item of the previous section, after = first of this one
+ insertPos = dropPosition === 'before'
+ ? getSectionEndOffset(view, target.sectionIndex)
+ : getSectionStartOffset(view, target.sectionIndex);
+ } else {
+ return;
+ }
+
+ spliceNode(view, from, insertPos);
+}
+
+function getBlockRange(view, blockIndex) {
+ const { doc } = view.state;
+ const positions = getBlockPositions(view);
+ if (blockIndex >= positions.length) return null;
+ const pos = positions[blockIndex];
+ const node = doc.nodeAt(pos);
+ return node ? { pos, size: node.nodeSize, node } : null;
+}
+
+// Reverse of moveContentItem's 'content' target — a block landing next to a content item.
+export function moveBlockToContentItem(view, blockIndex, targetChild, dropPosition) {
+ if (!view) return;
+ const from = getBlockRange(view, blockIndex);
+ if (!from) return;
+
+ const to = getContentItemRange(view.state.doc, targetChild);
+ if (!to) return;
+ const insertPos = dropPosition === 'before' ? to.pos : to.pos + to.size;
+
+ spliceNode(view, from, insertPos);
+}
+
+// Reverse of moveContentItem's 'section' target — a lone block landing at the boundary
+// of a section with no blocks to anchor on.
+export function moveBlockToSection(view, blockIndex, sectionIndex, dropPosition) {
+ if (!view) return;
+ const from = getBlockRange(view, blockIndex);
+ if (!from) return;
+
+ const insertPos = dropPosition === 'before'
+ ? getSectionEndOffset(view, sectionIndex)
+ : getSectionStartOffset(view, sectionIndex);
+
+ spliceNode(view, from, insertPos);
}
diff --git a/blocks/canvas/editor-utils/editor-utils.js b/blocks/canvas/editor-utils/editor-utils.js
index 42bf61e5b..6b7ca0225 100644
--- a/blocks/canvas/editor-utils/editor-utils.js
+++ b/blocks/canvas/editor-utils/editor-utils.js
@@ -133,6 +133,8 @@ const EDITABLES = [
{ selector: 'p', nodeName: 'P' },
{ selector: 'ol', nodeName: 'OL' },
{ selector: 'ul', nodeName: 'UL' },
+ { selector: 'pre', nodeName: 'PRE' },
+ { selector: 'blockquote', nodeName: 'BLOCKQUOTE' },
];
const EDITABLE_SELECTORS = EDITABLES.map((edit) => edit.selector).join(', ');
@@ -261,22 +263,106 @@ export function getInstrumentedHTML(view) {
const SKIP_BLOCK_CLASSES = new Set(['default-content-wrapper', 'metadata', 'block-marker']);
+function hasDefaultContent(el) {
+ if (el.textContent?.trim()) return true;
+ return el.matches?.('img') || !!el.querySelector?.('img');
+}
+
+function getDefaultContentProseIndex(el, kind) {
+ // A
wrapping an image keeps its own data-prose-index, but only the nested
+ // data-image-index resolves to the image node (prose2aem leaves the
unless the
+ // image is the section's sole child), so for kind 'image' it must win.
+ if (kind === 'image') {
+ const nestedImage = el.querySelector('[data-image-index]');
+ if (nestedImage) return Number(nestedImage.getAttribute('data-image-index'));
+ }
+ const own = el.getAttribute('data-prose-index') ?? el.getAttribute('data-image-index');
+ if (own != null) return Number(own);
+ const nested = el.querySelector('[data-prose-index], [data-image-index]');
+ if (!nested) return undefined;
+ const attr = nested.getAttribute('data-prose-index') ?? nested.getAttribute('data-image-index');
+ return attr != null ? Number(attr) : undefined;
+}
+
+function firstLineText(el) {
+ const clone = el.cloneNode(true);
+ clone.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
+ return clone.textContent.trim().split('\n')[0].trim();
+}
+
+function getContentSnippet(el, kind) {
+ if (kind === 'list') return firstLineText(el.querySelector(':scope > li') ?? el);
+ if (kind === 'quote') return firstLineText(el.querySelector(':scope > p') ?? el);
+ return firstLineText(el);
+}
+
+function getDefaultContentKind(el) {
+ const tag = el.tagName;
+ if (/^H[1-6]$/.test(tag)) return { kind: 'heading', level: Number(tag[1]) };
+ if (tag === 'OL') return { kind: 'list', ordered: true };
+ if (tag === 'UL') return { kind: 'list', ordered: false };
+ if (tag === 'PRE') return { kind: 'code' };
+ if (tag === 'BLOCKQUOTE') return { kind: 'quote' };
+ if (el.textContent?.trim()) return { kind: 'paragraph' };
+ // A text-less
wraps only an image, as does a bare /
— but a text-less
+ // with no image at all is just an empty paragraph, not an image wrapper.
+ if (el.matches?.('img') || el.querySelector?.('img')) return { kind: 'image' };
+ return { kind: 'paragraph' };
+}
+
export function parseSections(htmlText) {
const doc = new DOMParser().parseFromString(htmlText, 'text/html');
const container = doc.querySelector('main') ?? doc.body;
let flatIndex = 0;
return Array.from(container.querySelectorAll(':scope > div'), (section, sectionIndex) => {
const blocks = [];
- Array.from(section.querySelectorAll(':scope > div[class]')).forEach((el) => {
- const name = el.classList[0];
- if (!name || SKIP_BLOCK_CLASSES.has(name)) return;
- const rawProseIndex = el.getAttribute('data-block-index');
- const proseIndex = rawProseIndex != null ? Number(rawProseIndex) : undefined;
- const innerText = el.textContent?.trim() ?? '';
- blocks.push({ name, blockIndex: flatIndex, proseIndex, innerText });
- flatIndex += 1;
+ const items = [];
+ let currentRun = [];
+
+ const flushRun = () => {
+ if (currentRun.length) {
+ items.push({
+ type: 'content',
+ proseIndex: getDefaultContentProseIndex(currentRun[0]),
+ innerText: currentRun.map((el) => el.textContent.trim()).filter(Boolean).join(' '),
+ children: currentRun.map((el) => {
+ const kindInfo = getDefaultContentKind(el);
+ return {
+ type: 'content',
+ ...kindInfo,
+ proseIndex: getDefaultContentProseIndex(el, kindInfo.kind),
+ innerText: el.textContent.trim(),
+ snippet: getContentSnippet(el, kindInfo.kind),
+ };
+ }),
+ });
+ }
+ currentRun = [];
+ };
+
+ Array.from(section.children).forEach((el) => {
+ const name = el.tagName === 'DIV' ? el.classList[0] : undefined;
+ const isBlock = name && !SKIP_BLOCK_CLASSES.has(name);
+
+ if (isBlock) {
+ flushRun();
+ const rawProseIndex = el.getAttribute('data-block-index');
+ const proseIndex = rawProseIndex != null ? Number(rawProseIndex) : undefined;
+ const innerText = el.textContent?.trim() ?? '';
+ const block = { name, blockIndex: flatIndex, proseIndex, innerText };
+ blocks.push(block);
+ items.push({ type: 'block', ...block });
+ flatIndex += 1;
+ return;
+ }
+
+ // Skip empty nodes — prose2aem doesn't always strip them (e.g. an empty
can
+ // survive serialization) — so they neither break nor join a run.
+ if (hasDefaultContent(el)) currentRun.push(el);
});
- return { sectionIndex, blocks };
+ flushRun();
+
+ return { sectionIndex, blocks, items };
});
}
@@ -333,6 +419,21 @@ export const editorSelectChange = (() => {
};
})();
+// Event observable — no replay on subscribe. See docs/canvas-events.md.
+// Carries a raw ProseMirror position, not a block index, for the outline's default-content entries.
+export const editorProseSelectChange = (() => {
+ const listeners = new Set();
+ return {
+ emit(detail) {
+ listeners.forEach((fn) => fn(detail));
+ },
+ subscribe(fn) {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+ },
+ };
+})();
+
export function updateDocument(ctx) {
if (ctx.suppressRerender) return undefined;
const body = getInstrumentedHTML(ctx.view);
diff --git a/blocks/canvas/editor-utils/prose-diff.js b/blocks/canvas/editor-utils/prose-diff.js
index fe8a9259a..7c7df61fa 100644
--- a/blocks/canvas/editor-utils/prose-diff.js
+++ b/blocks/canvas/editor-utils/prose-diff.js
@@ -54,6 +54,7 @@ export function findChangedNodes(oldDoc, newDoc) {
pos,
oldAttrs: oldNode.attrs,
newAttrs: newNode.attrs,
+ nodeType: newNode.type.name,
});
}
@@ -103,6 +104,12 @@ export function findChangedNodes(oldDoc, newDoc) {
export const EDITABLE_TYPES = ['heading', 'paragraph', 'ordered_list', 'bullet_list'];
+function changedNodeType(change) {
+ if (change.type === 'attrs') return change.nodeType;
+ if (change.type === 'replaced') return change.newNode?.type.name ?? change.oldNode?.type.name;
+ return undefined;
+}
+
export function findCommonEditableAncestor(view, changes, prevState) {
if (changes.length === 0) return null;
@@ -163,7 +170,15 @@ export function createTrackingPlugin(rerenderPage, updateCursors, getEditor, onS
const changes = findChangedNodes(prevState.doc, view.state.doc);
if (changes.length > 0) {
- const commonEditable = findCommonEditableAncestor(view, changes, prevState);
+ // Only an EDITABLE_TYPES node changing its own attrs/type (heading level,
+ // list-type swap) needs a full outline re-parse; the same change on e.g. an
+ // image's src does not, so it takes the in-place text sync instead.
+ const identityChanged = changes.some((c) => (
+ (c.type === 'attrs' || c.type === 'replaced') && EDITABLE_TYPES.includes(changedNodeType(c))
+ ));
+ const commonEditable = identityChanged
+ ? null
+ : findCommonEditableAncestor(view, changes, prevState);
if (commonEditable) {
getEditor?.({ cursorOffset: commonEditable.pos + 1 });
diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js
index 425224868..f567ed6b3 100644
--- a/blocks/canvas/ew-editor-doc/ew-editor-doc.js
+++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js
@@ -1,9 +1,9 @@
import { LitElement, html, nothing } from 'da-lit';
-import { yUndo, yRedo, NodeSelection } from 'da-y-wrapper';
+import { yUndo, yRedo, NodeSelection, TextSelection } from 'da-y-wrapper';
import { getNx } from '../../../scripts/utils.js';
import {
updateDocument, updateCursors, getInstrumentedHTML,
- editorHtmlChange, editorSelectChange, getEditor,
+ editorHtmlChange, editorSelectChange, editorProseSelectChange, getEditor,
} from '../editor-utils/editor-utils.js';
import { getActiveBlockIndex, getBlockPositions } from '../editor-utils/blocks.js';
import {
@@ -13,7 +13,7 @@ import {
editorDocRenderPhase,
} from './utils/ctx.js';
import { subscribeCollabUserList } from './utils/awareness-users.js';
-import { describeDocSelection, applyHighlight, SEL_BLOCK, selectedNodePayload } from './utils/selection.js';
+import { describeDocSelection, applyHighlight, SEL_BLOCK, selectedNodePayload, activeContentProseIndex } from './utils/selection.js';
import {
prefetchWysiwygCookiesIfSignedIn,
wireQuickEditControllerPort,
@@ -29,6 +29,16 @@ import { hideSelectionToolbar, setSelectionToolbarCtx } from '../editor-utils/se
import { createExtensionsBridgePlugin } from '../editor-utils/extensions-bridge.js';
import { MESSAGE_TYPES } from '../utils/quick-edit-messages.js';
+// Maps ew-page-outline's default-content `kind` to the PM node type(s) it can back,
+// so a matching node at proseIndex can be selected as a whole (see _scrollDocToProseIndex).
+const CONTENT_KIND_NODE_NAMES = {
+ paragraph: ['paragraph'],
+ heading: ['heading'],
+ list: ['bullet_list', 'ordered_list'],
+ code: ['code_block'],
+ quote: ['blockquote'],
+};
+
const { loadStyle } = await import(`${getNx()}/utils/utils.js`);
const { CHAT_EVENT } = await import(`${getNx()}/blocks/chat/constants.js`);
@@ -120,11 +130,54 @@ export class EwEditorDoc extends LitElement {
view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
}
- _broadcastSelectedNode(scrollIntoView = false) {
+ // 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.
+ _scrollDocToProseIndex(proseIndex, kind) {
+ if (proseIndex == null || proseIndex < 0) return;
+ const { view } = this._proseContext ?? {};
+ if (!view) return;
+ const { doc } = view.state;
+ if (proseIndex > doc.content.size) return;
+
+ // The dispatch below runs the tracking plugin's onSelectionChange synchronously, which
+ // would otherwise broadcast its own (null, for non-image/table selections) node payload
+ // an instant before the correct one just below overwrites it.
+ this._suppressAutoBroadcast = true;
+ if (kind === 'image' && doc.nodeAt(proseIndex)?.type.name === 'image') {
+ const sel = NodeSelection.create(doc, proseIndex);
+ view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
+ this._suppressAutoBroadcast = false;
+ this._broadcastSelectedNode(true);
+ return;
+ }
+
+ // Non-image content's proseIndex is one position inside the node's own start
+ // (see activeContentProseIndex in utils/selection.js) — step back one for the anchor.
+ const nodeStart = proseIndex - 1;
+ const nodeNames = CONTENT_KIND_NODE_NAMES[kind];
+ if (nodeStart >= 0 && nodeNames?.includes(doc.nodeAt(nodeStart)?.type.name)) {
+ const sel = NodeSelection.create(doc, nodeStart);
+ view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
+ this._suppressAutoBroadcast = false;
+ this._broadcastSelectedNode(true, { anchorType: 'content', proseIndex });
+ return;
+ }
+
+ const sel = TextSelection.near(doc.resolve(proseIndex));
+ view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
+ this._suppressAutoBroadcast = false;
+ this._broadcastSelectedNode(true, { anchorType: 'content', proseIndex });
+ }
+
+ // overrideNode lets content navigation (a TextSelection selectedNodePayload can't
+ // classify) broadcast an explicit anchorType/proseIndex instead of a derived one.
+ _broadcastSelectedNode(scrollIntoView = false, overrideNode = undefined) {
+ if (this._suppressAutoBroadcast && overrideNode === undefined) return;
const port = this._controllerCtx?.port;
const { view } = this._proseContext ?? {};
if (!port || !view) return;
- const node = selectedNodePayload(view);
+ const node = overrideNode !== undefined ? overrideNode : selectedNodePayload(view);
const key = node ? `${node.anchorType}:${node.proseIndex}` : 'null';
const forceScroll = scrollIntoView && Boolean(node);
if (!forceScroll && key === this._lastBroadcastNodeKey) return;
@@ -171,7 +224,6 @@ export class EwEditorDoc extends LitElement {
port: this.quickEditPort,
iframe: this._wysiwygIframe,
suppressRerender: false,
- lastBlockIndex: undefined,
owner: org,
repo,
path: controllerPathnameFromEditorCtx(this.ctx),
@@ -248,6 +300,7 @@ export class EwEditorDoc extends LitElement {
(data) => { if (this._controllerCtx) getEditor(data, this._controllerCtx); },
(pmView) => {
const blockIndex = getActiveBlockIndex(pmView);
+ const proseIndex = activeContentProseIndex(pmView);
const { kind, ...descriptor } = describeDocSelection(pmView);
const selKey = `${descriptor.selFrom}|${descriptor.selTo}|${kind}`;
if (blockIndex === this._lastDocBlockIndex && selKey === this._lastDocSelKey) return;
@@ -255,6 +308,7 @@ export class EwEditorDoc extends LitElement {
this._lastDocSelKey = selKey;
editorSelectChange.emit({
blockIndex,
+ proseIndex,
source: 'doc',
explicit: descriptor.selectionType === SEL_BLOCK,
...descriptor,
@@ -309,6 +363,8 @@ export class EwEditorDoc extends LitElement {
this._scrollDocToBlock(blockIndex);
if (source === 'outline') this._broadcastSelectedNode(true);
});
+ this._unsubscribeProseSelect = editorProseSelectChange
+ .subscribe(({ proseIndex, kind }) => this._scrollDocToProseIndex(proseIndex, kind));
this._onCanvasHighlight = (e) => this._applyHighlight(e.detail);
document.addEventListener(CHAT_EVENT.HIGHLIGHT_SELECTION, this._onCanvasHighlight);
}
@@ -322,6 +378,7 @@ export class EwEditorDoc extends LitElement {
this.parentElement?.removeEventListener('nx-wysiwyg-port-ready', this._onWysiwygPortReady);
document.removeEventListener(CHAT_EVENT.HIGHLIGHT_SELECTION, this._onCanvasHighlight);
this._unsubscribeSelect?.();
+ this._unsubscribeProseSelect?.();
this._teardown();
setSelectionToolbarCtx();
super.disconnectedCallback();
diff --git a/blocks/canvas/ew-editor-doc/utils/selection.js b/blocks/canvas/ew-editor-doc/utils/selection.js
index dee1d2775..6dca0d935 100644
--- a/blocks/canvas/ew-editor-doc/utils/selection.js
+++ b/blocks/canvas/ew-editor-doc/utils/selection.js
@@ -68,6 +68,23 @@ export function selectedNodePayload(view) {
return null;
}
+// Mirrors data-prose-index/getDefaultContentProseIndex: non-image content is indexed
+// one position *inside* its own start (posAtDOM(el, 0)), since da-nx's inline-editor
+// bootstrap depends on that exact value as its cursorOffset (see prose-diff.js/da-nx's
+// prose.js). Images use their own start directly; tables are excluded (tracked via blockIndex).
+export function activeContentProseIndex(view) {
+ const sel = view?.state?.selection;
+ if (!sel) return undefined;
+ if (sel instanceof NodeSelection) {
+ const name = sel.node?.type?.name;
+ if (name === 'table') return undefined;
+ return name === 'image' ? sel.from : sel.from + 1;
+ }
+ const { $from } = sel;
+ if ($from.depth < 1) return undefined;
+ return $from.node(1).type.name === 'table' ? undefined : $from.before(1) + 1;
+}
+
export function applyHighlight(view, { selFrom, selTo, selectionType } = {}) {
if (!view || typeof selFrom !== 'number' || typeof selTo !== 'number') return;
const { doc } = view.state;
diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js
index 37b6a59fe..4fe644e1d 100644
--- a/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js
+++ b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js
@@ -5,8 +5,6 @@ import {
NX_QUICK_EDIT_IFRAME_SELECTION_META,
NX_QUICK_EDIT_CLEAR_IFRAME_SELECTION_ORIGIN_META,
} from '../../editor-utils/selection-toolbar.js';
-import { editorSelectChange } from '../../editor-utils/editor-utils.js';
-import { getActiveBlockIndex } from '../../editor-utils/blocks.js';
export function handleCursorMove({ cursorOffset, textCursorOffset }, ctx) {
const { view, wsProvider } = ctx;
@@ -59,6 +57,9 @@ export function handleCursorMove({ cursorOffset, textCursorOffset }, ctx) {
}
ctx.suppressRerender = true;
+ // dispatch() already triggers createTrackingPlugin's hook, which emits editorSelectChange
+ // with the full payload (incl. proseIndex) — a second, blockIndex-only emit here would
+ // clobber that and collapse the outline.
view.dispatch(tr.scrollIntoView());
ctx.suppressRerender = false;
const tb = getSelectionToolbar();
@@ -66,11 +67,6 @@ export function handleCursorMove({ cursorOffset, textCursorOffset }, ctx) {
tb.view = view;
tb.show();
}
- const blockIndex = getActiveBlockIndex(view);
- if (blockIndex !== ctx.lastBlockIndex) {
- ctx.lastBlockIndex = blockIndex;
- editorSelectChange.emit({ blockIndex, source: 'wysiwyg' });
- }
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error moving cursor:', error);
diff --git a/blocks/canvas/ew-page-outline/ew-page-outline.css b/blocks/canvas/ew-page-outline/ew-page-outline.css
index 926fb37f6..5910f491a 100644
--- a/blocks/canvas/ew-page-outline/ew-page-outline.css
+++ b/blocks/canvas/ew-page-outline/ew-page-outline.css
@@ -106,6 +106,11 @@
border-bottom: 2px solid var(--s2-blue-600, #147af3);
}
+/* Blocks have no chevron — indent to align with expandable content-item labels */
+.block-item:not(.content-item) {
+ padding-inline-start: calc(var(--s2-spacing-300) + 1rem);
+}
+
.block-empty {
cursor: default;
}
@@ -116,6 +121,70 @@
font-weight: 400;
}
+.content-group {
+ margin: 0;
+ padding: 0;
+}
+
+.content-item:not(.content-child) {
+ cursor: pointer;
+}
+
+.content-item[aria-expanded]::before {
+ content: '›';
+ display: inline-block;
+ flex-shrink: 0;
+ width: 1rem;
+ text-align: center;
+ transition: transform 0.15s;
+}
+
+.content-item[aria-expanded="true"]::before {
+ transform: rotate(90deg);
+}
+
+.content-children {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.content-child {
+ padding-inline-start: var(--s2-spacing-500);
+ min-height: 44px;
+}
+
+.content-label {
+ font-weight: 400;
+ font-style: italic;
+ color: var(--s2-gray-700);
+}
+
+.selected .content-label {
+ color: var(--s2-blue-900);
+}
+
+.content-label-stack {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.content-snippet {
+ font-size: var(--s2-body-size-xs, 0.75rem);
+ font-weight: 400;
+ color: var(--s2-gray-600);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.selected .content-snippet {
+ color: var(--s2-blue-800);
+}
+
.block-name {
flex: 1;
overflow: hidden;
diff --git a/blocks/canvas/ew-page-outline/ew-page-outline.js b/blocks/canvas/ew-page-outline/ew-page-outline.js
index cebfda450..f58194530 100644
--- a/blocks/canvas/ew-page-outline/ew-page-outline.js
+++ b/blocks/canvas/ew-page-outline/ew-page-outline.js
@@ -1,13 +1,17 @@
import { LitElement, html, nothing } from 'da-lit';
import { getNx } from '../../../scripts/utils.js';
import { treeKeydown } from '../utils/tree-nav.js';
-import { editorHtmlChange, editorSelectChange, parseSections } from '../editor-utils/editor-utils.js';
+import { editorHtmlChange, editorSelectChange, editorProseSelectChange, parseSections } from '../editor-utils/editor-utils.js';
import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js';
import {
deleteBlock,
+ deleteContentItem,
deleteSection,
insertBlockAtSectionStart,
moveBlock,
+ moveBlockToContentItem,
+ moveBlockToSection,
+ moveContentItem,
moveSection,
} from '../editor-utils/blocks.js';
import { fetchExtensions } from '../ew-panel-extensions/helpers.js';
@@ -23,6 +27,7 @@ const style = await loadStyle(import.meta.url);
const OUTLINE_TYPES = {
SECTION: 'section',
BLOCK: 'block',
+ CONTENT: 'content',
};
const DROP_POSITIONS = {
@@ -30,13 +35,39 @@ const DROP_POSITIONS = {
AFTER: 'after',
};
+function contentChildEqual(child, other) {
+ return child.proseIndex === other.proseIndex && child.innerText === other.innerText
+ && child.kind === other.kind && child.level === other.level && child.ordered === other.ordered;
+}
+
+function contentChildLabel(child) {
+ switch (child.kind) {
+ case 'heading': return `Heading ${child.level}`;
+ case 'list': return child.ordered ? 'Numbered list' : 'Bullet list';
+ case 'image': return 'Image';
+ case 'code': return 'Code block';
+ case 'quote': return 'Blockquote';
+ default: return 'Paragraph';
+ }
+}
+
+function itemsEqual(item, other) {
+ if (!other || item.type !== other.type) return false;
+ if (item.type === 'block') return item.blockIndex === other.blockIndex && item.name === other.name;
+ if (item.proseIndex !== other.proseIndex || item.innerText !== other.innerText) return false;
+ const children = item.children ?? [];
+ const otherChildren = other.children ?? [];
+ return children.length === otherChildren.length
+ && children.every((child, i) => contentChildEqual(child, otherChildren[i]));
+}
+
function sectionsEqual(a, b) {
if (!a || !b || a.length !== b.length) return false;
return a.every((sec, i) => {
const other = b[i];
return sec.sectionIndex === other.sectionIndex
- && sec.blocks.length === other.blocks.length
- && sec.blocks.every((blk, j) => blk.name === other.blocks[j].name);
+ && sec.items.length === other.items.length
+ && sec.items.every((item, j) => itemsEqual(item, other.items[j]));
});
}
@@ -44,27 +75,39 @@ class EwPageOutline extends LitElement {
static properties = {
_sections: { state: true },
_selectedBlockIndex: { state: true },
+ _selectedProseIndex: { state: true },
_hashState: { state: true },
_hasBlockLibrary: { state: true },
+ _expandedContent: { state: true },
};
connectedCallback() {
super.connectedCallback();
this.shadowRoot.adoptedStyleSheets = [style];
+ this._expandedContent = new Set();
this._unsubHash = hashChange.subscribe((state) => { this._hashState = state; });
this._unsubscribeHtml = editorHtmlChange.subscribe((aemHtml) => {
if (aemHtml.trim()) {
const next = parseSections(aemHtml);
- if (!sectionsEqual(next, this._sections)) this._sections = next;
+ if (!sectionsEqual(next, this._sections)) {
+ this._sections = next;
+ // A structural edit is the only time proseIndex-keyed expansion state can go
+ // stale (positions shift), so this is the one point where it's safe to drop —
+ // selection changes never do (see _expandRunForProse).
+ this._expandedContent = new Set();
+ }
} else {
this._sections = undefined;
this._selectedBlockIndex = undefined;
+ this._selectedProseIndex = undefined;
}
});
this._unsubscribeSelect = editorSelectChange
- .subscribe(({ blockIndex, source }) => {
+ .subscribe(({ blockIndex, proseIndex, source }) => {
if (source === 'outline') return;
this._selectedBlockIndex = blockIndex;
+ this._selectedProseIndex = proseIndex;
+ if (proseIndex != null) this._expandRunForProse(proseIndex);
});
}
@@ -85,6 +128,7 @@ class EwPageOutline extends LitElement {
if (this._prevSelectedPath !== undefined && sp !== this._prevSelectedPath) {
this._sections = undefined;
this._selectedBlockIndex = undefined;
+ this._selectedProseIndex = undefined;
}
this._prevSelectedPath = sp;
@@ -105,9 +149,54 @@ class EwPageOutline extends LitElement {
_select(blockIndex) {
this._selectedBlockIndex = blockIndex;
+ this._selectedProseIndex = undefined;
editorSelectChange.emit({ blockIndex, source: 'outline' });
}
+ _selectProse(proseIndex, kind) {
+ this._selectedProseIndex = proseIndex;
+ this._selectedBlockIndex = undefined;
+ this._expandRunForProse(proseIndex);
+ editorProseSelectChange.emit({ proseIndex, kind });
+ }
+
+ _toggleContentGroup(key) {
+ const next = new Set(this._expandedContent);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ this._expandedContent = next;
+ }
+
+ // Selection never collapses anything — it only ensures the run holding the new
+ // selection is visible, adding it alongside whatever's already expanded. Expansion is
+ // only ever cleared wholesale on a reparse (see the editorHtmlChange subscription).
+ _expandRunForProse(proseIndex) {
+ const runKey = this._findRunKeyForProseIndex(proseIndex);
+ if (runKey == null) return;
+ this._expandedContent = new Set(this._expandedContent).add(runKey);
+ }
+
+ _findRunKeyForProseIndex(proseIndex) {
+ const sections = this._sections ?? [];
+ for (const [secIdx, sec] of sections.entries()) {
+ for (const [itemIdx, item] of sec.items.entries()) {
+ if (item.type === 'content') {
+ const hasChild = item.children.some((child) => child.proseIndex === proseIndex);
+ if (hasChild) return item.proseIndex;
+
+ // A node invisible in the outline (e.g. a fresh empty paragraph from pressing
+ // Enter) still belongs to this run if its position falls between the run's own
+ // start and whatever comes next — the next item in this section, the next
+ // section's first item, or unbounded if this is the very last item overall.
+ const nextItem = sec.items[itemIdx + 1] ?? sections[secIdx + 1]?.items[0];
+ const upperBound = nextItem ? nextItem.proseIndex : Infinity;
+ if (proseIndex >= item.proseIndex && proseIndex < upperBound) return item.proseIndex;
+ }
+ }
+ }
+ return undefined;
+ }
+
_clearDropIndicator() {
this.shadowRoot.querySelector('[data-drop-position]')?.removeAttribute('data-drop-position');
}
@@ -135,11 +224,12 @@ class EwPageOutline extends LitElement {
}
_onSectionDragOver(e, sec) {
+ const type = this._dragging?.type;
const rect = e.currentTarget.getBoundingClientRect();
const dropPosition = e.clientY < rect.top + rect.height / 2
? DROP_POSITIONS.BEFORE : DROP_POSITIONS.AFTER;
- if (this._dragging?.type === OUTLINE_TYPES.SECTION) {
+ if (type === OUTLINE_TYPES.SECTION) {
if (this._dragging.index === sec.sectionIndex) return;
e.preventDefault();
@@ -148,20 +238,42 @@ class EwPageOutline extends LitElement {
: e.currentTarget;
this._setDropIndicator(el, { sectionIndex: sec.sectionIndex, dropPosition });
+ } else if (type === OUTLINE_TYPES.CONTENT) {
+ // Bubbles here from anywhere unclaimed in the section; only the header is before/after-aware.
+ e.preventDefault();
+ const headerEl = e.currentTarget.querySelector('[data-section-header]');
+ const onHeader = headerEl?.contains(e.target);
+ const contentDropPosition = onHeader ? dropPosition : DROP_POSITIONS.AFTER;
+ this._setDropIndicator(
+ headerEl,
+ { sectionIndex: sec.sectionIndex, dropPosition: contentDropPosition },
+ );
} else {
- if (!sec.blocks.length) return;
if (sec.blocks.some((b) => b.blockIndex === this._dragging?.index)) return;
- const { blockIndex } = sec.blocks[sec.blocks.length - 1];
- e.preventDefault();
+ if (sec.blocks.length) {
+ const { blockIndex } = sec.blocks[sec.blocks.length - 1];
+ e.preventDefault();
+
+ const lastBlockEl = this.shadowRoot.querySelector(`[data-block-index="${blockIndex}"]`);
+ if (!lastBlockEl) return;
+ this._setDropIndicator(lastBlockEl, { blockIndex, dropPosition: DROP_POSITIONS.AFTER });
+ return;
+ }
- const lastBlockEl = this.shadowRoot.querySelector(`[data-block-index="${blockIndex}"]`);
- if (!lastBlockEl) return;
- this._setDropIndicator(lastBlockEl, { blockIndex, dropPosition: DROP_POSITIONS.AFTER });
+ // No blocks to anchor on — fall back to the section boundary itself.
+ e.preventDefault();
+ const headerEl = e.currentTarget.querySelector('[data-section-header]');
+ this._setDropIndicator(
+ headerEl,
+ { sectionIndex: sec.sectionIndex, dropPosition: DROP_POSITIONS.AFTER },
+ );
}
}
_onBlockDragOver(e, blockIndex) {
- if (this._dragging?.type !== OUTLINE_TYPES.BLOCK || this._dragging.index === blockIndex) return;
+ const type = this._dragging?.type;
+ if (![OUTLINE_TYPES.BLOCK, OUTLINE_TYPES.CONTENT].includes(type)) return;
+ if (type === OUTLINE_TYPES.BLOCK && this._dragging.index === blockIndex) return;
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
@@ -170,6 +282,33 @@ class EwPageOutline extends LitElement {
this._setDropIndicator(e.currentTarget, { blockIndex, dropPosition });
}
+ _onContentDragOver(e, child) {
+ const type = this._dragging?.type;
+ if (![OUTLINE_TYPES.CONTENT, OUTLINE_TYPES.BLOCK].includes(type)) return;
+ const isSameChild = type === OUTLINE_TYPES.CONTENT
+ && this._dragging.index.proseIndex === child.proseIndex;
+ if (isSameChild) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const rect = e.currentTarget.getBoundingClientRect();
+ const dropPosition = e.clientY < rect.top + rect.height / 2
+ ? DROP_POSITIONS.BEFORE : DROP_POSITIONS.AFTER;
+ this._setDropIndicator(e.currentTarget, { contentChild: child, dropPosition });
+ }
+
+ _onContentGroupDragOver(e, item) {
+ if (![OUTLINE_TYPES.CONTENT, OUTLINE_TYPES.BLOCK].includes(this._dragging?.type)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const rect = e.currentTarget.getBoundingClientRect();
+ const dropPosition = e.clientY < rect.top + rect.height / 2
+ ? DROP_POSITIONS.BEFORE : DROP_POSITIONS.AFTER;
+ const targetChild = dropPosition === DROP_POSITIONS.BEFORE
+ ? item.children[0]
+ : item.children[item.children.length - 1];
+ this._setDropIndicator(e.currentTarget, { contentChild: targetChild, dropPosition });
+ }
+
_onDrop = (e) => {
e.preventDefault();
e.stopPropagation();
@@ -177,7 +316,20 @@ class EwPageOutline extends LitElement {
this._clearDragState();
if (!_dropTarget || !_dragging) return;
const { view } = getExtensionsBridge();
- if (_dropTarget.blockIndex != null) {
+
+ if (_dragging.type === OUTLINE_TYPES.CONTENT) {
+ let target;
+ if (_dropTarget.contentChild) target = { type: 'content', child: _dropTarget.contentChild };
+ else if (_dropTarget.blockIndex != null) target = { type: 'block', blockIndex: _dropTarget.blockIndex };
+ else if (_dropTarget.sectionIndex != null) target = { type: 'section', sectionIndex: _dropTarget.sectionIndex };
+ else return;
+ moveContentItem(view, _dragging.index, target, _dropTarget.dropPosition);
+ } else if (_dragging.type === OUTLINE_TYPES.BLOCK && _dropTarget.contentChild) {
+ const { contentChild, dropPosition } = _dropTarget;
+ moveBlockToContentItem(view, _dragging.index, contentChild, dropPosition);
+ } else if (_dragging.type === OUTLINE_TYPES.BLOCK && _dropTarget.sectionIndex != null) {
+ moveBlockToSection(view, _dragging.index, _dropTarget.sectionIndex, _dropTarget.dropPosition);
+ } else if (_dropTarget.blockIndex != null) {
if (_dragging.type !== OUTLINE_TYPES.BLOCK) return;
moveBlock(view, _dragging.index, _dropTarget.blockIndex, _dropTarget.dropPosition);
} else if (_dropTarget.sectionIndex != null) {
@@ -197,7 +349,18 @@ class EwPageOutline extends LitElement {
}
};
- _onTreeKeydown = (e) => treeKeydown(e, this.shadowRoot);
+ _onTreeKeydown = (e) => {
+ const item = this.shadowRoot.activeElement;
+ if (item?.matches('.content-item[aria-expanded]')) {
+ const expanded = item.getAttribute('aria-expanded') === 'true';
+ if ((e.key === 'ArrowRight' && !expanded) || (e.key === 'ArrowLeft' && expanded)) {
+ e.preventDefault();
+ item.click();
+ return;
+ }
+ }
+ treeKeydown(e, this.shadowRoot);
+ };
async _openAddBlockModal(e, sectionIndex) {
e.stopPropagation();
@@ -210,6 +373,19 @@ class EwPageOutline extends LitElement {
openBlockLibraryModal({ onInsert });
}
+ // Array position (not proseIndex) of the run holding this child, so a delete can find
+ // it again afterward without comparing positions across the edit (see _onDelete).
+ _findRunLocation(proseIndex) {
+ for (const [sectionIndex, sec] of (this._sections ?? []).entries()) {
+ const itemIndex = sec.items.findIndex(
+ (item) => item.type === 'content'
+ && item.children.some((child) => child.proseIndex === proseIndex),
+ );
+ if (itemIndex !== -1) return { sectionIndex, itemIndex };
+ }
+ return undefined;
+ }
+
_onDelete(e, type, index) {
e.stopPropagation();
e.preventDefault();
@@ -217,14 +393,24 @@ class EwPageOutline extends LitElement {
if (!view) return;
if (type === OUTLINE_TYPES.BLOCK) {
deleteBlock(view, index);
+ } else if (type === OUTLINE_TYPES.CONTENT) {
+ const location = this._findRunLocation(index.proseIndex);
+ deleteContentItem(view, index);
+ // A content-child delete never reorders/merges runs, only shrinks or removes the
+ // deleted-from one — so the same array position still identifies it, if it survived.
+ const survivingRun = location
+ && this._sections?.[location.sectionIndex]?.items[location.itemIndex];
+ if (survivingRun?.type === 'content') this._expandRunForProse(survivingRun.proseIndex);
} else {
deleteSection(view, index);
}
}
_renderDeleteButton(type, index) {
- const label = type === OUTLINE_TYPES.SECTION
- ? `Delete section ${index + 1}` : 'Delete block';
+ let noun = 'block';
+ if (type === OUTLINE_TYPES.SECTION) noun = `section ${index + 1}`;
+ else if (type === OUTLINE_TYPES.CONTENT) noun = contentChildLabel(index).toLowerCase();
+ const label = `Delete ${noun}`;
return html`