mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-07-15 21:31:36 +03:00
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:
@@ -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]),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.selectStart();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user