diff --git a/lang/en/entities.php b/lang/en/entities.php index 5501d2bc2..58c00ec4b 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -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', diff --git a/resources/icons/contents.svg b/resources/icons/contents.svg new file mode 100644 index 000000000..3a315b431 --- /dev/null +++ b/resources/icons/contents.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index 736d93f05..688877227 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -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'; diff --git a/resources/js/components/toolbox-contents.ts b/resources/js/components/toolbox-contents.ts new file mode 100644 index 000000000..e09f2b5ac --- /dev/null +++ b/resources/js/components/toolbox-contents.ts @@ -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) => { + 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 { + 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 { + 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); + } +} \ No newline at end of file diff --git a/resources/js/services/vdom.ts b/resources/js/services/vdom.ts index c32f328f5..051b1a42a 100644 --- a/resources/js/services/vdom.ts +++ b/resources/js/services/vdom.ts @@ -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)); +} diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index d9a1195aa..249dca0d5 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -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 { diff --git a/resources/views/pages/parts/editor-toolbox.blade.php b/resources/views/pages/parts/editor-toolbox.blade.php index 8b7a68a9a..8ef259b91 100644 --- a/resources/views/pages/parts/editor-toolbox.blade.php +++ b/resources/views/pages/parts/editor-toolbox.blade.php @@ -11,6 +11,7 @@ @if($comments->enabled()) @endif + @@ -37,4 +38,6 @@ @include('pages.parts.toolbox-comments') @endif + @include('pages.parts.toolbox-contents') + diff --git a/resources/views/pages/parts/show-sidebar-section-page-nav.blade.php b/resources/views/pages/parts/show-sidebar-section-page-nav.blade.php index 88db87e64..d046a8ca1 100644 --- a/resources/views/pages/parts/show-sidebar-section-page-nav.blade.php +++ b/resources/views/pages/parts/show-sidebar-section-page-nav.blade.php @@ -2,14 +2,14 @@ @endif \ No newline at end of file diff --git a/resources/views/pages/parts/toolbox-contents.blade.php b/resources/views/pages/parts/toolbox-contents.blade.php new file mode 100644 index 000000000..6234cdf43 --- /dev/null +++ b/resources/views/pages/parts/toolbox-contents.blade.php @@ -0,0 +1,9 @@ +
+

{{ trans('entities.page_contents') }}

+ +
+

{{ trans('entities.page_contents_info') }}

+ +
+
+
\ No newline at end of file