Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c4b35f0
feat(canvas): show loose page content in outline panel
shsteimer Jul 22, 2026
9475d4e
feat(canvas): expand default content into paragraph/heading/image/lis…
shsteimer Jul 23, 2026
20939cd
fix(canvas): rerender on block identity changes; add code blocks to o…
shsteimer Jul 23, 2026
3e6f0d2
fix(canvas): address outline default-content review feedback
shsteimer Jul 23, 2026
834b718
feat(canvas): arrow-key expand/collapse for outline default-content g…
shsteimer Jul 23, 2026
7ce9d57
Merge branch 'main' into outldflt
hannessolo Jul 24, 2026
ccd7fab
Merge branch 'main' into outldflt
shsteimer Jul 24, 2026
0ec880b
feat(canvas): drag-reorder and delete for default-content items
shsteimer Jul 24, 2026
97e6f9f
Merge branch 'outldflt' of github.com:adobe/da-live into outldflt
shsteimer Jul 24, 2026
605da1e
fix(canvas): correct default-content proseIndex resolution; let block…
shsteimer Jul 24, 2026
2ced92c
chore(canvas): tighten verbose why-comments
shsteimer Jul 24, 2026
547a727
fix(canvas): highlight the selected default-content item in the outline
shsteimer Jul 24, 2026
9f0e9c3
Merge branch 'main' into outldflt
shsteimer Jul 28, 2026
8fb9891
fix(canvas): restore grab cursor on default-content drag handles
shsteimer Jul 28, 2026
ea77556
Merge branch 'main' into outldflt
shsteimer Jul 29, 2026
b59aa59
fix(canvas): align outline rows and show content-child text previews
shsteimer Jul 29, 2026
fed0e5e
feat(canvas): sync default-content selection to layout view and outline
shsteimer Jul 29, 2026
f1b9b54
fix(canvas): use blue text for selected content items in outline
shsteimer Jul 29, 2026
be5eda9
fix(canvas): show first-line snippet for outline content children
shsteimer Jul 29, 2026
0c43d66
feat(canvas): selection-driven outline expansion, empty-node visibili…
shsteimer Jul 29, 2026
bcc8119
docs(canvas): trim PR #1167 comments to minimum load-bearing wording
shsteimer Jul 29, 2026
c6c9413
fix(canvas): keep prose2aem free of canvas-only empty-paragraph handling
shsteimer Jul 29, 2026
02e6522
fix(canvas): remove empty-node highlight/select behavior from outline
shsteimer Jul 29, 2026
a83584a
feat(canvas): simplify outline expansion state; keep runs open across…
shsteimer Jul 29, 2026
a42c94d
fix(canvas): restore image detection in getDefaultContentKind, drop d…
shsteimer Jul 29, 2026
e534243
fix(canvas): suppress redundant null broadcast on content selection
shsteimer Jul 29, 2026
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
138 changes: 124 additions & 14 deletions blocks/canvas/editor-utils/blocks.js
Original file line number Diff line number Diff line change
@@ -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']);

Expand Down Expand Up @@ -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;
Expand All @@ -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,
);
}

Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
119 changes: 110 additions & 9 deletions blocks/canvas/editor-utils/editor-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(', ');

Expand Down Expand Up @@ -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 <p> wrapping an image keeps its own data-prose-index, but only the nested
// data-image-index resolves to the image node (prose2aem leaves the <p> 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 <p> wraps only an image, as does a bare <picture>/<img> — but a text-less
// <p> 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 <h2> 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 };
});
}

Expand Down Expand Up @@ -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);
Expand Down
17 changes: 16 additions & 1 deletion blocks/canvas/editor-utils/prose-diff.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function findChangedNodes(oldDoc, newDoc) {
pos,
oldAttrs: oldNode.attrs,
newAttrs: newNode.attrs,
nodeType: newNode.type.name,
});
}

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 });
Expand Down
Loading
Loading