From bd11e116c468000e60af5e7d6852d35204f76fd6 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Wed, 22 Jul 2026 16:32:58 -0700 Subject: [PATCH] feat(canvas): add "Add section" button to outline panel Adds insertSection(view) to editor-utils/blocks.js, appending a single horizontal_rule node at the end of the doc to create a new empty section. Wires an "Add section" button into ew-page-outline.js's outline list, dispatched via the extensions bridge like other outline mutations. Co-Authored-By: Claude Sonnet 5 --- blocks/canvas/editor-utils/blocks.js | 7 +++ .../ew-page-outline/ew-page-outline.css | 25 ++++++++++ .../canvas/ew-page-outline/ew-page-outline.js | 20 +++++++- .../blocks/canvas/editor-utils/blocks.test.js | 48 +++++++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 test/unit/blocks/canvas/editor-utils/blocks.test.js diff --git a/blocks/canvas/editor-utils/blocks.js b/blocks/canvas/editor-utils/blocks.js index 40f228505..37baf5b01 100644 --- a/blocks/canvas/editor-utils/blocks.js +++ b/blocks/canvas/editor-utils/blocks.js @@ -135,6 +135,13 @@ export function deleteSection(view, sectionIndex) { view.dispatch(view.state.tr.replaceWith(0, doc.content.size, newNodes)); } +export function insertSection(view) { + if (!view) return; + const { doc, schema } = view.state; + const hrNode = schema.nodes.horizontal_rule.create(); + view.dispatch(view.state.tr.insert(doc.content.size, hrNode)); +} + export function moveSection(view, fromSectionIndex, toSectionIndex, dropPosition) { if (!view) return; if (isSamePosition(fromSectionIndex, toSectionIndex, dropPosition)) return; diff --git a/blocks/canvas/ew-page-outline/ew-page-outline.css b/blocks/canvas/ew-page-outline/ew-page-outline.css index 926fb37f6..ed3897df1 100644 --- a/blocks/canvas/ew-page-outline/ew-page-outline.css +++ b/blocks/canvas/ew-page-outline/ew-page-outline.css @@ -158,6 +158,31 @@ visibility: visible; } +.add-section-btn { + display: flex; + align-items: center; + gap: var(--s2-spacing-75); + width: 100%; + padding: var(--s2-spacing-75) var(--s2-spacing-100); + border: none; + background: transparent; + color: var(--s2-gray-700); + font-family: inherit; + font-size: var(--s2-body-size-s); + font-weight: 500; + cursor: pointer; + + &:hover { + background: var(--s2-gray-75); + } + + &:focus-visible { + outline: 2px solid var(--s2-blue-700); + outline-offset: -2px; + border-radius: var(--s2-corner-radius-75); + } +} + .placeholder { padding: var(--s2-spacing-300) var(--s2-spacing-100); font-size: var(--s2-body-size-s); diff --git a/blocks/canvas/ew-page-outline/ew-page-outline.js b/blocks/canvas/ew-page-outline/ew-page-outline.js index cebfda450..9f023ac50 100644 --- a/blocks/canvas/ew-page-outline/ew-page-outline.js +++ b/blocks/canvas/ew-page-outline/ew-page-outline.js @@ -7,6 +7,7 @@ import { deleteBlock, deleteSection, insertBlockAtSectionStart, + insertSection, moveBlock, moveSection, } from '../editor-utils/blocks.js'; @@ -14,6 +15,7 @@ import { fetchExtensions } from '../ew-panel-extensions/helpers.js'; const DELETE_ICON_SRC = '/img/icons/s2-icon-delete-20-n.svg'; const ADD_BLOCK_ICON_SRC = '/img/icons/s2-icon-tableadd-20-n.svg'; +const ADD_SECTION_ICON_SRC = '/img/icons/s2-icon-addcircle-20-n.svg'; const DRAG_ICON_SRC = '/img/icons/s2-icon-draghandle-20-n.svg'; const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`); @@ -210,6 +212,14 @@ class EwPageOutline extends LitElement { openBlockLibraryModal({ onInsert }); } + _onAddSection(e) { + e.stopPropagation(); + e.preventDefault(); + const { view } = getExtensionsBridge(); + if (!view) return; + insertSection(view); + } + _onDelete(e, type, index) { e.stopPropagation(); e.preventDefault(); @@ -304,7 +314,15 @@ class EwPageOutline extends LitElement { : html``} + + `} `; } diff --git a/test/unit/blocks/canvas/editor-utils/blocks.test.js b/test/unit/blocks/canvas/editor-utils/blocks.test.js new file mode 100644 index 000000000..a139168e2 --- /dev/null +++ b/test/unit/blocks/canvas/editor-utils/blocks.test.js @@ -0,0 +1,48 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +const { createTestEditor, destroyEditor } = await import('../../edit/prose/test-helpers.js'); +const { insertSection } = await import('../../../../../blocks/canvas/editor-utils/blocks.js'); + +function countSections(view) { + const { doc, schema } = view.state; + const sections = [[]]; + doc.forEach((node) => { + if (node.type === schema.nodes.horizontal_rule) { + sections.push([]); + } else { + sections[sections.length - 1].push(node); + } + }); + return sections.length; +} + +describe('insertSection', () => { + let editor; + beforeEach(async () => { editor = await createTestEditor(); }); + afterEach(() => destroyEditor(editor)); + + it('appends a horizontal_rule node to the end of the doc and adds a new section', () => { + const { schema } = editor.view.state; + const paragraph = schema.nodes.paragraph.create(null, schema.text('hello')); + const { tr } = editor.view.state; + editor.view.dispatch(tr.replaceWith(0, tr.doc.content.size, [paragraph])); + + const sectionCountBefore = countSections(editor.view); + + insertSection(editor.view); + + const { doc } = editor.view.state; + const lastNode = doc.lastChild; + expect(lastNode.type.name).to.equal('horizontal_rule'); + + const sectionCountAfter = countSections(editor.view); + expect(sectionCountAfter).to.equal(sectionCountBefore + 1); + }); + + it('does nothing when view is falsy', () => { + expect(() => insertSection(null)).to.not.throw(); + }); +});