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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions blocks/canvas/ew-page-outline/ew-page-outline.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class EwPageOutline extends LitElement {
_hashState: { state: true },
_hasBlockLibrary: { state: true },
_expandedContent: { state: true },
_collapsedContent: { state: true },
_pendingDelete: { state: true },
_editingSection: { state: true },
_draftName: { state: true },
Expand All @@ -99,6 +100,7 @@ class EwPageOutline extends LitElement {
super.connectedCallback();
this.shadowRoot.adoptedStyleSheets = [baseStyle, formStyle, style];
this._expandedContent = new Set();
this._collapsedContent = new Set();
this._unsubHash = hashChange.subscribe((state) => { this._hashState = state; });
this._unsubscribeHtml = canvasBus.editorHtmlState.subscribe((aemHtml) => {
if (aemHtml.trim()) {
Expand All @@ -112,6 +114,7 @@ class EwPageOutline extends LitElement {
// 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();
this._collapsedContent = new Set();
}
} else {
this._sections = undefined;
Expand Down Expand Up @@ -190,11 +193,14 @@ class EwPageOutline extends LitElement {
canvasBus.editorProseSelectState.emit({ proseIndex, kind });
}

_toggleContentGroup(key) {
const next = new Set(this._expandedContent);
_toggleContentGroup(key, single = false) {
// Multi-item runs track which keys are open; single-item runs default open and track
// which have been explicitly collapsed. Either way, the click flips the key's membership.
const prop = single ? '_collapsedContent' : '_expandedContent';
const next = new Set(this[prop]);
if (next.has(key)) next.delete(key);
else next.add(key);
this._expandedContent = next;
this[prop] = next;
}

// Selection never collapses anything — it only ensures the run holding the new
Expand All @@ -204,6 +210,12 @@ class EwPageOutline extends LitElement {
const runKey = this._findRunKeyForProseIndex(proseIndex);
if (runKey == null) return;
this._expandedContent = new Set(this._expandedContent).add(runKey);
// Revealing a selection must also clear an explicit collapse on a single-item run.
if (this._collapsedContent?.has(runKey)) {
const next = new Set(this._collapsedContent);
next.delete(runKey);
this._collapsedContent = next;
}
}

_findRunKeyForProseIndex(proseIndex) {
Expand Down Expand Up @@ -547,13 +559,19 @@ class EwPageOutline extends LitElement {

_renderContentGroup(item, isFirst) {
const key = item.proseIndex;
const expanded = this._expandedContent?.has(key);
// A run holding a single item starts open: hiding one thing behind a collapsed chevron
// is a click with nothing to gain. It stays collapsible (chevron and toggle kept), so
// its state is tracked by _collapsedContent (default open) rather than _expandedContent.
const single = item.children.length === 1;
const expanded = single
? !this._collapsedContent?.has(key)
: this._expandedContent?.has(key);
return html`
<li class="content-group" role="none">
<div class="block-item content-item" role="treeitem"
tabindex="${isFirst ? '0' : '-1'}"
aria-expanded="${expanded}"
@click=${() => this._toggleContentGroup(key)}
@click=${() => this._toggleContentGroup(key, single)}
@dragover=${(e) => this._onContentGroupDragOver(e, item)}
@drop=${this._onDrop}>
<span class="block-name content-label">Default content</span>
Expand Down
75 changes: 65 additions & 10 deletions test/unit/blocks/canvas/ew-page-outline/ew-page-outline.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,15 @@ describe('ew-page-outline — expandable default content', () => {
blocks: [],
items: [contentGroupItem(1, [
{ type: 'content', kind: 'paragraph', proseIndex: 1, innerText: 'One', snippet: 'One' },
{ type: 'content', kind: 'paragraph', proseIndex: 5, innerText: 'One B', snippet: 'One B' },
])],
},
{
sectionIndex: 1,
blocks: [],
items: [contentGroupItem(20, [
{ type: 'content', kind: 'paragraph', proseIndex: 20, innerText: 'Two', snippet: 'Two' },
{ type: 'content', kind: 'paragraph', proseIndex: 24, innerText: 'Two B', snippet: 'Two B' },
])],
},
];
Expand All @@ -219,7 +221,7 @@ describe('ew-page-outline — expandable default content', () => {
// Group headers only — `.content-item` also matches rendered content-child rows.
const headers = () => el.shadowRoot.querySelectorAll('.content-group > .content-item');

// proseIndex 10 sits after section 0's only child (1) but well before section 1's
// proseIndex 10 sits after section 0's children (1, 5) but well before section 1's
// (20) — with no next item in section 0 to bound it, it's attributed to section 0's
// run (the trailing/unbounded case a fresh Enter-created node at the end lands in).
canvasBus.editorSelectState.emit({ blockIndex: -1, proseIndex: 10, source: 'doc' });
Expand All @@ -228,7 +230,7 @@ describe('ew-page-outline — expandable default content', () => {
expect(headers()[0].getAttribute('aria-expanded')).to.equal('true');
expect(headers()[1].getAttribute('aria-expanded')).to.equal('false');

// proseIndex 25, past section 1's only child with nothing after it, resolves there.
// proseIndex 25, past section 1's children with nothing after it, resolves there.
canvasBus.editorSelectState.emit({ blockIndex: -1, proseIndex: 25, source: 'doc' });
await el.updateComplete;

Expand All @@ -242,6 +244,7 @@ describe('ew-page-outline — expandable default content', () => {
const initialHtml = `<main><div>
<h2 data-prose-index="1">Title</h2>
<p data-prose-index="5">Para one</p>
<p data-prose-index="9">Para two</p>
</div></main>`;
canvasBus.editorHtmlState.emit(initialHtml);
await el.updateComplete;
Expand All @@ -258,11 +261,12 @@ describe('ew-page-outline — expandable default content', () => {

const changedHtml = `<main><div>
<h2 data-prose-index="1">Title</h2>
<p data-prose-index="5">Para one</p>
</div></main>`;
canvasBus.editorHtmlState.emit(changedHtml);
await el.updateComplete;

// Structural change (a child removed) — sectionsEqual fails, expansion resets.
// Structural change: a child removed but the run stays multi-item, so expansion resets.
expect(el.shadowRoot.querySelector('.content-item').getAttribute('aria-expanded')).to.equal('false');
});

Expand All @@ -282,6 +286,60 @@ describe('ew-page-outline — expandable default content', () => {
});
});

describe('ew-page-outline — single-item default content', () => {
let el;

beforeEach(async () => {
el = await createOutline();
el._sections = [{
sectionIndex: 0,
blocks: [],
items: [
contentGroupItem(1, [
{ type: 'content', kind: 'paragraph', proseIndex: 1, innerText: 'Hello World', snippet: 'Hello World' },
]),
],
}];
await el.updateComplete;
});

afterEach(() => { el.remove(); });

it('shows the lone child immediately under an expanded "Default content" header, chevron kept', () => {
const header = el.shadowRoot.querySelector('.content-group > .content-item');
expect(header).to.exist;
expect(header.textContent.trim()).to.equal('Default content');
// Chevron/disclosure kept — the header is a normal expandable treeitem, just open by default.
expect(header.getAttribute('aria-expanded')).to.equal('true');
const children = [...el.shadowRoot.querySelectorAll('.content-child')];
expect(children).to.have.lengthOf(1);
expect(children[0].textContent.replace(/\s+/g, ' ').trim()).to.equal('Paragraph Hello World');
});

it('stays collapsible: header click collapses the lone child, then re-expands it', async () => {
const header = el.shadowRoot.querySelector('.content-group > .content-item');
expect(el.shadowRoot.querySelectorAll('.content-child')).to.have.lengthOf(1);

header.click();
await el.updateComplete;
expect(header.getAttribute('aria-expanded')).to.equal('false');
expect(el.shadowRoot.querySelector('.content-children')).to.be.null;

header.click();
await el.updateComplete;
expect(header.getAttribute('aria-expanded')).to.equal('true');
expect(el.shadowRoot.querySelectorAll('.content-child')).to.have.lengthOf(1);
});

it('still selects the lone child on click, emitting its proseIndex and kind', async () => {
let received;
const unsub = canvasBus.editorProseSelectState.subscribe((detail) => { received = detail; });
el.shadowRoot.querySelector('.content-child').click();
unsub();
expect(received).to.deep.equal({ proseIndex: 1, kind: 'paragraph' });
});
});

describe('ew-page-outline — content drag & delete', () => {
let el;
let bridge;
Expand Down Expand Up @@ -312,9 +370,7 @@ describe('ew-page-outline — content drag & delete', () => {
items: [contentGroupItem(child.proseIndex, [child])],
}];
await el.updateComplete;
el.shadowRoot.querySelector('.content-item').click();
await el.updateComplete;

// A single-item run is open by default, so its child (and delete button) show without a click.
el.shadowRoot.querySelector('.content-child .delete-btn').click();
await el.updateComplete;
expect(el.shadowRoot.querySelector('nx-dialog.ew-po-delete')).to.exist;
Expand All @@ -340,9 +396,7 @@ describe('ew-page-outline — content drag & delete', () => {
items: [contentGroupItem(child.proseIndex, [child])],
}];
await el.updateComplete;
el.shadowRoot.querySelector('.content-item').click();
await el.updateComplete;

// A single-item run is open by default, so its child (and delete button) show without a click.
el.shadowRoot.querySelector('.content-child .delete-btn').click();
await el.updateComplete;

Expand All @@ -359,6 +413,7 @@ describe('ew-page-outline — content drag & delete', () => {
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'C' }] },
],
});

Expand All @@ -374,7 +429,7 @@ describe('ew-page-outline — content drag & delete', () => {
el._confirmDelete();
await el.updateComplete;

expect(docSeq(bridge.view.state.doc)).to.deep.equal(['B']);
expect(docSeq(bridge.view.state.doc)).to.deep.equal(['B', 'C']);
// No sibling-select workaround needed — the run survived the reparse-driven reset
// because _onDelete re-expands it by array position (see _findRunLocation).
expect(el.shadowRoot.querySelector('.content-item').getAttribute('aria-expanded')).to.equal('true');
Expand Down
Loading