mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-07-15 21:31:36 +03:00
Merge pull request 'Page Editor Contents List View' (#6131) from page_editor_contents into development
Reviewed-on: https://codeberg.org/bookstack/bookstack/pulls/6131
This commit is contained in:
@@ -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',
|
||||
|
||||
1
resources/icons/contents.svg
Normal file
1
resources/icons/contents.svg
Normal 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 |
@@ -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';
|
||||
|
||||
152
resources/js/components/toolbox-contents.ts
Normal file
152
resources/js/components/toolbox-contents.ts
Normal file
@@ -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<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);
|
||||
|
||||
// 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<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),
|
||||
'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);
|
||||
}
|
||||
}
|
||||
@@ -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}(#+|<h[1-6][\s>])/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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"});
|
||||
}
|
||||
}
|
||||
|
||||
export function focusOnHeader(editor: LexicalEditor, headerIndex: number): void {
|
||||
editor.update(() => {
|
||||
const headers = $getAllNodesOfType($isHeadingNode);
|
||||
const target = headers[headerIndex];
|
||||
if (target) {
|
||||
target.selectEnd();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
9
resources/views/pages/parts/toolbox-contents.blade.php
Normal file
9
resources/views/pages/parts/toolbox-contents.blade.php
Normal 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>
|
||||
Reference in New Issue
Block a user