])/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') }}
+
+
+
\ No newline at end of file
{{ trans('entities.page_contents_info') }}
+{{ trans('entities.page_contents_none') }}
+ +