From c58eb918939c9ef24998e3da9379c29969d75f0b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 May 2026 12:12:38 +0100 Subject: [PATCH] Page Editor: Added contents view click logic Added jump-to-header logic for lexical WYSIWYG, and both codemirror & plaintext markdown editor windows. --- resources/js/components/toolbox-contents.ts | 22 ++++++++++++- resources/js/markdown/actions.ts | 31 +++++++++++++++++-- resources/js/markdown/common-events.ts | 5 +++ resources/js/markdown/inputs/codemirror.ts | 13 ++++++++ resources/js/markdown/inputs/interface.ts | 6 ++++ resources/js/markdown/inputs/textarea.ts | 14 +++++++++ resources/js/services/vdom.ts | 2 ++ .../js/wysiwyg/services/common-events.ts | 6 +++- resources/js/wysiwyg/utils/actions.ts | 13 +++++++- 9 files changed, 107 insertions(+), 5 deletions(-) diff --git a/resources/js/components/toolbox-contents.ts b/resources/js/components/toolbox-contents.ts index e09f2b5ac..569b574ee 100644 --- a/resources/js/components/toolbox-contents.ts +++ b/resources/js/components/toolbox-contents.ts @@ -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]), diff --git a/resources/js/markdown/actions.ts b/resources/js/markdown/actions.ts index 36d21ab1d..ff1f0bc94 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}(#+| { + 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..e8cbe561c 100644 --- a/resources/js/markdown/common-events.ts +++ b/resources/js/markdown/common-events.ts @@ -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); + }); } 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 051b1a42a..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; 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..f5b2db6aa 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"}); +} + +export function focusOnHeader(editor: LexicalEditor, headerIndex: number): void { + editor.update(() => { + const headers = $getAllNodesOfType($isHeadingNode); + const target = headers[headerIndex]; + if (target) { + target.selectStart(); + } + }); } \ No newline at end of file