Page Editor: Added contents view click logic

Added jump-to-header logic for lexical WYSIWYG, and both codemirror &
plaintext markdown editor windows.
This commit is contained in:
Dan Brown
2026-05-20 12:12:38 +01:00
parent 49aa025012
commit c58eb91893
9 changed files with 107 additions and 5 deletions

View File

@@ -48,6 +48,24 @@ export class ToolboxContents extends Component {
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 {
@@ -118,7 +136,9 @@ export class ToolboxContents extends Component {
const headerItems = headers.map(header => {
return elem('li', {
'data-level': String(header.level),
id: header.id,
'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]),

View File

@@ -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)/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);
}

View File

@@ -1,4 +1,5 @@
import {MarkdownEditor} from "./index.mjs";
import {focusOnHeader} from "../wysiwyg/utils/actions";
export interface HtmlOrMarkdown {
html: string;
@@ -33,4 +34,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);
});
}

View File

@@ -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.
*/

View File

@@ -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.
*/

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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(() => {
@@ -102,3 +103,13 @@ 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.selectStart();
}
});
}