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..1cbaeda5d --- /dev/null +++ b/resources/js/components/toolbox-contents.ts @@ -0,0 +1,152 @@ +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); + + // Listen to header click + this.container.addEventListener('click', (event) => { + const header = (event.target as HTMLElement).closest('li[data-index]'); + if (header instanceof HTMLElement) { + event.preventDefault(); + this.onHeaderSelect(header); + } + }); + } + + protected onHeaderSelect(headerElement: HTMLElement): void { + const id = headerElement.getAttribute('data-id') || ''; + const index = Number(headerElement.getAttribute('data-index') || ''); + window.$events.emit('editor::focus-heading', { + index, + id, + }); + } + + 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), + 'data-index': String(header.index), + 'data-id': header.id, + id: `page-contents-${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); + } +} diff --git a/resources/js/markdown/actions.ts b/resources/js/markdown/actions.ts index 36d21ab1d..2fd02c8c8 100644 --- a/resources/js/markdown/actions.ts +++ b/resources/js/markdown/actions.ts @@ -184,14 +184,41 @@ export class Actions { } } - focus() { + /** + * Set user focus on the editor edit area. + */ + focus(): void { this.editor.input.focus(); } + /** + * Focus on the text for a specific header in the content. + */ + focusOnHeader(index: number): void { + const headerPattern = /^\s{0,3}(#+|])/i; + const codeBoundary = /^\s{0,3}```/i; + let currentIndex = -1; + let inCodeBoundary = false; + this.editor.input.forEachLine((num: number, content: string, range: MarkdownEditorInputSelection) => { + const isHeader = headerPattern.test(content); + if (isHeader && !inCodeBoundary) { + currentIndex++; + if (currentIndex === index) { + this.editor.input.setSelection({from: range.to, to: range.to}, true); + return false; + } + } else if (codeBoundary.test(content)) { + inCodeBoundary = !inCodeBoundary; + } + }); + + this.focus(); + } + /** * Insert content into the editor. */ - insertContent(content: string) { + insertContent(content: string): void { this.#replaceSelection(content, content.length); } diff --git a/resources/js/markdown/common-events.ts b/resources/js/markdown/common-events.ts index 4bfc4bb46..e24f39dab 100644 --- a/resources/js/markdown/common-events.ts +++ b/resources/js/markdown/common-events.ts @@ -33,4 +33,8 @@ export function listenToCommonEvents(editor: MarkdownEditor): void { window.$events.listen('editor::focus', () => { editor.actions.focus(); }); + + window.$events.listen<{id: string, index: number}>('editor::focus-heading', ({index}) => { + editor.actions.focusOnHeader(index); + }); } diff --git a/resources/js/markdown/inputs/codemirror.ts b/resources/js/markdown/inputs/codemirror.ts index 827068238..8cc7f409c 100644 --- a/resources/js/markdown/inputs/codemirror.ts +++ b/resources/js/markdown/inputs/codemirror.ts @@ -102,6 +102,19 @@ export class CodemirrorInput implements MarkdownEditorInput { return {from: line.from, to: line.to}; } + forEachLine(callback: (lineNum: number, content: string, range: MarkdownEditorInputSelection) => false | void): void { + const docText = this.cm.state.doc; + let lineCount = 0; + for (const line of docText.iterLines()) { + lineCount++; + const lineInfo = docText.line(lineCount); + const result = callback(lineCount, line, {from: lineInfo.from, to: lineInfo.to}); + if (result === false) { + break; + } + } + } + /** * Dispatch changes to the editor. */ diff --git a/resources/js/markdown/inputs/interface.ts b/resources/js/markdown/inputs/interface.ts index 1f7474a50..d39f7dc14 100644 --- a/resources/js/markdown/inputs/interface.ts +++ b/resources/js/markdown/inputs/interface.ts @@ -74,6 +74,12 @@ export interface MarkdownEditorInput { */ searchForLineContaining(text: string): MarkdownEditorInputSelection|null; + /** + * Run the provided callback against each line of content within the input. + * Callback can return false to stop further processing. + */ + forEachLine(callback: (lineNum: number, content: string, range: MarkdownEditorInputSelection) => false|void): void; + /** * Tear down the input. */ diff --git a/resources/js/markdown/inputs/textarea.ts b/resources/js/markdown/inputs/textarea.ts index e0c3ac37c..c232a24bf 100644 --- a/resources/js/markdown/inputs/textarea.ts +++ b/resources/js/markdown/inputs/textarea.ts @@ -214,6 +214,20 @@ export class TextareaInput implements MarkdownEditorInput { return null; } + forEachLine(callback: (lineNum: number, content: string, range: MarkdownEditorInputSelection) => false | void): void { + const lines = this.getText().split('\n'); + let lineStart = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineEnd = lineStart + line.length; + const result = callback(i + 1, line, {from: lineStart, to: lineEnd}); + if (result === false) { + break; + } + lineStart = lineEnd + 1; + } + } + setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean): void { this.input.selectionStart = selection.from; this.input.selectionEnd = selection.to; diff --git a/resources/js/services/vdom.ts b/resources/js/services/vdom.ts index c32f328f5..a83686778 100644 --- a/resources/js/services/vdom.ts +++ b/resources/js/services/vdom.ts @@ -1,6 +1,7 @@ import { init, attributesModule, + datasetModule, toVNode, } from 'snabbdom'; import {VNode} from "snabbdom/build/vnode"; @@ -14,6 +15,7 @@ function getPatcher(): vDomPatcher { patcher = init([ attributesModule, + datasetModule, ]); return patcher; @@ -24,3 +26,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/js/wysiwyg-tinymce/common-events.js b/resources/js/wysiwyg-tinymce/common-events.js index d0a5acdc2..6f3d34f1f 100644 --- a/resources/js/wysiwyg-tinymce/common-events.js +++ b/resources/js/wysiwyg-tinymce/common-events.js @@ -1,3 +1,5 @@ +import {scrollToHeader} from "./scrolling"; + /** * @param {Editor} editor */ @@ -30,4 +32,9 @@ export function listen(editor) { editor.focus(); } }); + + // Focus on a specific heading + window.$events.listen('editor::focus-heading', ({index}) => { + scrollToHeader(editor, index); + }); } diff --git a/resources/js/wysiwyg-tinymce/scrolling.js b/resources/js/wysiwyg-tinymce/scrolling.js index 92f8f1583..b4f07984e 100644 --- a/resources/js/wysiwyg-tinymce/scrolling.js +++ b/resources/js/wysiwyg-tinymce/scrolling.js @@ -15,6 +15,23 @@ function scrollToText(editor, scrollId) { editor.focus(); } +/** + * Scroll to a specific header of the given index, relative to all headers in the content. + * @param {Editor} editor + * @param {Number} index + */ +export function scrollToHeader(editor, index) { + const headers = editor.dom.select('h1, h2, h3, h4, h5, h6'); + const targetHeader = headers[index]; + + if (targetHeader) { + targetHeader.scrollIntoView(); + editor.selection.select(targetHeader, true); + editor.selection.collapse(false); + editor.focus(); + } +} + /** * Scroll to a section dictated by the current URL query string, if present. * Used when directly editing a specific section of the page. diff --git a/resources/js/wysiwyg/services/common-events.ts b/resources/js/wysiwyg/services/common-events.ts index f7fc81cb3..9c6a91787 100644 --- a/resources/js/wysiwyg/services/common-events.ts +++ b/resources/js/wysiwyg/services/common-events.ts @@ -1,7 +1,7 @@ import {LexicalEditor} from "lexical"; import { appendHtmlToEditor, - focusEditor, + focusEditor, focusOnHeader, insertHtmlIntoEditor, prependHtmlToEditor, setEditorContentFromHtml @@ -41,6 +41,10 @@ export function listen(editor: LexicalEditor): void { focusEditor(editor); }); + window.$events.listen<{id: string, index: number}>('editor::focus-heading', ({index}) => { + focusOnHeader(editor, index); + }); + let changeFromLoading = true; editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => { // Emit change event to component system (for draft detection) on actual user content change diff --git a/resources/js/wysiwyg/utils/actions.ts b/resources/js/wysiwyg/utils/actions.ts index 465d44d31..c1d819f22 100644 --- a/resources/js/wysiwyg/utils/actions.ts +++ b/resources/js/wysiwyg/utils/actions.ts @@ -1,6 +1,7 @@ import {$createParagraphNode, $getRoot, $getSelection, $insertNodes, $isBlockElementNode, LexicalEditor} from "lexical"; import {$generateHtmlFromNodes} from "@lexical/html"; -import {$getNearestNodeBlockParent, $htmlToBlockNodes, $htmlToNodes} from "./nodes"; +import {$getAllNodesOfType, $getNearestNodeBlockParent, $htmlToBlockNodes, $htmlToNodes} from "./nodes"; +import {$isHeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; export function setEditorContentFromHtml(editor: LexicalEditor, html: string) { editor.update(() => { @@ -101,4 +102,14 @@ export function focusEditor(editor: LexicalEditor): void { }); editor.commitUpdates(); editor.focus(() => {}, {defaultSelection: "rootStart"}); -} \ No newline at end of file +} + +export function focusOnHeader(editor: LexicalEditor, headerIndex: number): void { + editor.update(() => { + const headers = $getAllNodesOfType($isHeadingNode); + const target = headers[headerIndex]; + if (target) { + target.selectEnd(); + } + }); +} 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