Page Editor: Started contents view in toolbox

Added visual system, not yet added on-click logic.
Related to #4218
This commit is contained in:
Dan Brown
2026-05-19 17:56:48 +01:00
parent 5ebfa65a46
commit 49aa025012
9 changed files with 165 additions and 2 deletions

View File

@@ -331,6 +331,9 @@ return [
// Editor Sidebar
'toggle_sidebar' => 'Toggle Sidebar',
'page_contents' => 'Page Contents',
'page_contents_none' => 'No headings were found in the page content.',
'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.',
'page_tags' => 'Page Tags',
'chapter_tags' => 'Chapter Tags',
'book_tags' => 'Book Tags',

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M280-600v-80h560v80H280Zm0 160v-80h560v80H280Zm0 160v-80h560v80H280ZM160-600q-17 0-28.5-11.5T120-640q0-17 11.5-28.5T160-680q17 0 28.5 11.5T200-640q0 17-11.5 28.5T160-600Zm0 160q-17 0-28.5-11.5T120-480q0-17 11.5-28.5T160-520q17 0 28.5 11.5T200-480q0 17-11.5 28.5T160-440Zm0 160q-17 0-28.5-11.5T120-320q0-17 11.5-28.5T160-360q17 0 28.5 11.5T200-320q0 17-11.5 28.5T160-280Z"/></svg>

After

Width:  |  Height:  |  Size: 480 B

View File

@@ -58,6 +58,7 @@ export {Tabs} from './tabs';
export {TagManager} from './tag-manager';
export {TemplateManager} from './template-manager';
export {ToggleSwitch} from './toggle-switch';
export {ToolboxContents} from './toolbox-contents';
export {TriLayout} from './tri-layout';
export {UserSelect} from './user-select';
export {WebhookEvents} from './webhook-events';

View File

@@ -0,0 +1,132 @@
import {Component} from "./component";
import {debounce} from "../services/util";
import {EditorToolboxChangeEventData} from "./editor-toolbox";
import {PageEditor} from "./page-editor";
import {MarkdownEditor} from "./markdown-editor";
import {WysiwygEditorTinymce} from "./wysiwyg-editor-tinymce";
import {WysiwygEditor} from "./wysiwyg-editor";
import {elem} from "../services/dom";
import {patchDomFromDom} from "../services/vdom";
interface ToolboxContentHeader {
// The text shown for the header
text: string;
// The level/depth of the header
level: number;
// The index of the header relative to all other headers in the content
index: number;
// The id set for the header (if at all)
id: string;
}
export class ToolboxContents extends Component {
protected container!: HTMLElement;
protected noneEl!: HTMLElement;
protected display!: HTMLElement;
protected isActive: boolean = false;
setup() {
this.container = this.$el as HTMLLinkElement;
this.noneEl = this.$refs.none;
this.display = this.$refs.display;
// Listen to when visible in the editor toolbox so we only update when visible
window.addEventListener('editor-toolbox-change', ((event: CustomEvent<EditorToolboxChangeEventData>) => {
const tabName: string = event.detail.tab;
const isOpen = event.detail.open;
if (tabName === 'contents' && isOpen) {
if (this.isActive !== true) {
this.render();
}
this.isActive = true;
} else {
this.isActive = false;
}
}) as EventListener);
// Listen to content changes from the editor
const onContentChangeDebounced = debounce(this.onContentChange.bind(this), 500, false);
window.$events.listen('editor-html-change', onContentChangeDebounced);
window.$events.listen('editor-markdown-change', onContentChangeDebounced);
}
protected onContentChange(): void {
if (!this.isActive) {
return;
}
this.render();
}
protected async render(): Promise<void> {
const editorHtml = await this.getEditorHtml();
const headers = this.parseHeadersFromHtml(editorHtml);
this.rebaseHeaders(headers);
const headerDom = this.headersToDom(headers);
let displayChild = this.display.firstElementChild;
if (!displayChild) {
displayChild = document.createElement("ul");
this.display.appendChild(displayChild);
}
patchDomFromDom(displayChild, headerDom);
this.noneEl.hidden = headers.length > 0;
}
protected async getEditorHtml(): Promise<string> {
const pageEditorComponent = window.$components.first('page-editor') as PageEditor;
const editor = pageEditorComponent.getEditorComponent() as (MarkdownEditor|WysiwygEditorTinymce|WysiwygEditor);
return (await editor.getContent()).html;
}
protected parseHeadersFromHtml(html: string): ToolboxContentHeader[] {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const headerNodes = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
const headers: ToolboxContentHeader[] = [];
for (let i = 0; i < headerNodes.length; i++) {
const headerNode = headerNodes[i];
const level = Number(headerNode.tagName.replace('H', ''));
headers.push({
text: headerNode.textContent,
level,
id: headerNode.id || '',
index: i,
});
}
return headers;
}
protected rebaseHeaders(headers: ToolboxContentHeader[]): void {
if (headers.length === 0) {
return;
}
do {
var minLevel = Math.min(...headers.map(h => h.level));
if (minLevel > 1) {
for (const header of headers) {
header.level--;
}
}
} while (minLevel > 1);
}
protected headersToDom(headers: ToolboxContentHeader[]): HTMLElement {
const headerItems = headers.map(header => {
return elem('li', {
'data-level': String(header.level),
id: header.id,
class: `page-nav-item h${header.level}`,
}, [
elem('a', {class: 'text-limit-lines-1 block', href: `#${header.id}`}, [header.text]),
elem('div', {class: 'link-background sidebar-page-nav-bullet'}),
]);
});
return elem('ul', {
class: 'sidebar-page-nav menu'
}, headerItems);
}
}

View File

@@ -24,3 +24,7 @@ export function patchDomFromHtmlString(domTarget: Element, html: string): void {
contentDom.innerHTML = html;
getPatcher()(toVNode(domTarget), toVNode(contentDom));
}
export function patchDomFromDom(domTarget: Element, domSource: Element): void {
getPatcher()(toVNode(domTarget), toVNode(domSource));
}

View File

@@ -93,6 +93,8 @@
list-style: none;
@include mixins.margin(vars.$s, 0, vars.$m, vars.$xs);
position: relative;
padding-inline: 0;
display: block;
&:after {
content: '';
display: block;
@@ -153,6 +155,14 @@
}
}
}
.toolbox-tab-content .sidebar-page-nav {
.sidebar-page-nav-bullet {
box-shadow: 0 0 0 6px #FFFFFF;
}
li {
font-size: 1em;
}
}
// Sidebar list
.book-tree .sidebar-page-list {

View File

@@ -11,6 +11,7 @@
@if($comments->enabled())
<button type="button" refs="editor-toolbox@tab-button" data-tab="comments" title="{{ trans('entities.comments') }}">@icon('comment')</button>
@endif
<button type="button" refs="editor-toolbox@tab-button" data-tab="contents" title="{{ trans('entities.page_contents') }}">@icon('contents')</button>
</div>
</div>
@@ -37,4 +38,6 @@
@include('pages.parts.toolbox-comments')
@endif
@include('pages.parts.toolbox-contents')
</div>

View File

@@ -2,14 +2,14 @@
<nav id="page-navigation" class="mb-xl" aria-label="{{ trans('entities.pages_navigation') }}">
<h5>{{ trans('entities.pages_navigation') }}</h5>
<div class="body">
<div class="sidebar-page-nav menu">
<ul class="sidebar-page-nav menu">
@foreach($pageNav as $navItem)
<li class="page-nav-item h{{ $navItem['level'] }}">
<a href="{{ $navItem['link'] }}" class="text-limit-lines-1 block">{{ $navItem['text'] }}</a>
<div class="link-background sidebar-page-nav-bullet"></div>
</li>
@endforeach
</div>
</ul>
</div>
</nav>
@endif

View File

@@ -0,0 +1,9 @@
<div refs="editor-toolbox@tab-content" data-tab-content="contents" class="toolbox-tab-content">
<h4>{{ trans('entities.page_contents') }}</h4>
<div component="toolbox-contents" class="comment-container-compact px-l">
<p class="text-muted small mb-m">{{ trans('entities.page_contents_info') }}</p>
<p class="text-muted small mb-m" refs="toolbox-contents@none" hidden="hidden">{{ trans('entities.page_contents_none') }}</p>
<div refs="toolbox-contents@display"></div>
</div>
</div>