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`