1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-07-28 17:02:04 +03:00

MD Editor: Finished conversion to Typescript

This commit is contained in:
Dan Brown
2025-07-20 15:05:19 +01:00
parent 7bbf591a7f
commit 61adc735c8
9 changed files with 188 additions and 138 deletions

View File

@ -1,19 +1,16 @@
import {provideKeyBindings} from './shortcuts';
import {debounce} from '../services/util.ts';
import {Clipboard} from '../services/clipboard.ts';
import {debounce} from '../services/util';
import {Clipboard} from '../services/clipboard';
import {EditorView, ViewUpdate} from "@codemirror/view";
import {MarkdownEditor} from "./index.mjs";
/**
* Initiate the codemirror instance for the markdown editor.
* @param {MarkdownEditor} editor
* @returns {Promise<EditorView>}
*/
export async function init(editor) {
const Code = await window.importVersioned('code');
export async function init(editor: MarkdownEditor): Promise<EditorView> {
const Code = await window.importVersioned('code') as (typeof import('../code/index.mjs'));
/**
* @param {ViewUpdate} v
*/
function onViewUpdate(v) {
function onViewUpdate(v: ViewUpdate) {
if (v.docChanged) {
editor.actions.updateAndRender();
}
@ -27,9 +24,13 @@ export async function init(editor) {
const domEventHandlers = {
// Handle scroll to sync display view
scroll: event => syncActive && onScrollDebounced(event),
scroll: (event: Event) => syncActive && onScrollDebounced(event),
// Handle image & content drag n drop
drop: event => {
drop: (event: DragEvent) => {
if (!event.dataTransfer) {
return;
}
const templateId = event.dataTransfer.getData('bookstack/template');
if (templateId) {
event.preventDefault();
@ -45,12 +46,16 @@ export async function init(editor) {
}
},
// Handle dragover event to allow as drop-target in chrome
dragover: event => {
dragover: (event: DragEvent) => {
event.preventDefault();
},
// Handle image paste
paste: event => {
const clipboard = new Clipboard(event.clipboardData || event.dataTransfer);
paste: (event: ClipboardEvent) => {
if (!event.clipboardData) {
return;
}
const clipboard = new Clipboard(event.clipboardData);
// Don't handle the event ourselves if no items exist of contains table-looking data
if (!clipboard.hasItems() || clipboard.containsTabularData()) {
@ -71,8 +76,9 @@ export async function init(editor) {
provideKeyBindings(editor),
);
// Add editor view to window for easy access/debugging.
// Add editor view to the window for easy access/debugging.
// Not part of official API/Docs
// @ts-ignore
window.mdEditorView = cm;
return cm;

View File

@ -1,35 +1,36 @@
import {patchDomFromHtmlString} from '../services/vdom.ts';
import { patchDomFromHtmlString } from '../services/vdom';
import {MarkdownEditor} from "./index.mjs";
export class Display {
protected editor: MarkdownEditor;
protected container: HTMLIFrameElement;
protected doc: Document | null = null;
protected lastDisplayClick: number = 0;
/**
* @param {MarkdownEditor} editor
*/
constructor(editor) {
constructor(editor: MarkdownEditor) {
this.editor = editor;
this.container = editor.config.displayEl;
this.doc = null;
this.lastDisplayClick = 0;
if (this.container.contentDocument.readyState === 'complete') {
if (this.container.contentDocument?.readyState === 'complete') {
this.onLoad();
} else {
this.container.addEventListener('load', this.onLoad.bind(this));
}
this.updateVisibility(editor.settings.get('showPreview'));
editor.settings.onChange('showPreview', show => this.updateVisibility(show));
this.updateVisibility(Boolean(editor.settings.get('showPreview')));
editor.settings.onChange('showPreview', (show) => this.updateVisibility(Boolean(show)));
}
updateVisibility(show) {
const wrap = this.container.closest('.markdown-editor-wrap');
wrap.style.display = show ? null : 'none';
protected updateVisibility(show: boolean): void {
const wrap = this.container.closest('.markdown-editor-wrap') as HTMLElement;
wrap.style.display = show ? '' : 'none';
}
onLoad() {
protected onLoad(): void {
this.doc = this.container.contentDocument;
if (!this.doc) return;
this.loadStylesIntoDisplay();
this.doc.body.className = 'page-content';
@ -37,20 +38,20 @@ export class Display {
this.doc.addEventListener('click', this.onDisplayClick.bind(this));
}
/**
* @param {MouseEvent} event
*/
onDisplayClick(event) {
protected onDisplayClick(event: MouseEvent): void {
const isDblClick = Date.now() - this.lastDisplayClick < 300;
const link = event.target.closest('a');
const link = (event.target as Element).closest('a');
if (link !== null) {
event.preventDefault();
window.open(link.getAttribute('href'));
const href = link.getAttribute('href');
if (href) {
window.open(href);
}
return;
}
const drawing = event.target.closest('[drawio-diagram]');
const drawing = (event.target as Element).closest('[drawio-diagram]') as HTMLElement;
if (drawing !== null && isDblClick) {
this.editor.actions.editDrawing(drawing);
return;
@ -59,10 +60,12 @@ export class Display {
this.lastDisplayClick = Date.now();
}
loadStylesIntoDisplay() {
protected loadStylesIntoDisplay(): void {
if (!this.doc) return;
this.doc.documentElement.classList.add('markdown-editor-display');
// Set display to be dark mode if parent is
// Set display to be dark mode if the parent is
if (document.documentElement.classList.contains('dark-mode')) {
this.doc.documentElement.style.backgroundColor = '#222';
this.doc.documentElement.classList.add('dark-mode');
@ -71,24 +74,25 @@ export class Display {
this.doc.head.innerHTML = '';
const styles = document.head.querySelectorAll('style,link[rel=stylesheet]');
for (const style of styles) {
const copy = style.cloneNode(true);
const copy = style.cloneNode(true) as HTMLElement;
this.doc.head.appendChild(copy);
}
}
/**
* Patch the display DOM with the given HTML content.
* @param {String} html
*/
patchWithHtml(html) {
const {body} = this.doc;
public patchWithHtml(html: string): void {
if (!this.doc) return;
const { body } = this.doc;
if (body.children.length === 0) {
const wrap = document.createElement('div');
this.doc.body.append(wrap);
}
const target = body.children[0];
const target = body.children[0] as HTMLElement;
patchDomFromHtmlString(target, html);
}
@ -96,14 +100,16 @@ export class Display {
/**
* Scroll to the given block index within the display content.
* Will scroll to the end if the index is -1.
* @param {Number} index
*/
scrollToIndex(index) {
const elems = this.doc.body?.children[0]?.children;
if (elems && elems.length <= index) return;
public scrollToIndex(index: number): void {
const elems = this.doc?.body?.children[0]?.children;
if (!elems || elems.length <= index) return;
const topElem = (index === -1) ? elems[elems.length - 1] : elems[index];
topElem.scrollIntoView({block: 'start', inline: 'nearest', behavior: 'smooth'});
(topElem as Element).scrollIntoView({
block: 'start',
inline: 'nearest',
behavior: 'smooth'
});
}
}
}

View File

@ -9,7 +9,7 @@ import {EditorView} from "@codemirror/view";
export interface MarkdownEditorConfig {
pageId: string;
container: Element;
displayEl: Element;
displayEl: HTMLIFrameElement;
inputEl: HTMLTextAreaElement;
drawioUrl: string;
settingInputs: HTMLInputElement[];
@ -27,18 +27,13 @@ export interface MarkdownEditor {
/**
* Initiate a new Markdown editor instance.
* @param {MarkdownEditorConfig} config
* @returns {Promise<MarkdownEditor>}
*/
export async function init(config) {
/**
* @type {MarkdownEditor}
*/
export async function init(config: MarkdownEditorConfig): Promise<MarkdownEditor> {
const editor: MarkdownEditor = {
config,
markdown: new Markdown(),
settings: new Settings(config.settingInputs),
};
} as MarkdownEditor;
editor.actions = new Actions(editor);
editor.display = new Display(editor);

View File

@ -1,7 +1,9 @@
import MarkdownIt from 'markdown-it';
// @ts-ignore
import mdTasksLists from 'markdown-it-task-lists';
export class Markdown {
protected renderer: MarkdownIt;
constructor() {
this.renderer = new MarkdownIt({html: true});
@ -9,19 +11,16 @@ export class Markdown {
}
/**
* Get the front-end render used to convert markdown to HTML.
* @returns {MarkdownIt}
* Get the front-end render used to convert Markdown to HTML.
*/
getRenderer() {
getRenderer(): MarkdownIt {
return this.renderer;
}
/**
* Convert the given Markdown to HTML.
* @param {String} markdown
* @returns {String}
*/
render(markdown) {
render(markdown: string): string {
return this.renderer.render(markdown);
}

View File

@ -1,64 +0,0 @@
export class Settings {
constructor(settingInputs) {
this.settingMap = {
scrollSync: true,
showPreview: true,
editorWidth: 50,
plainEditor: false,
};
this.changeListeners = {};
this.loadFromLocalStorage();
this.applyToInputs(settingInputs);
this.listenToInputChanges(settingInputs);
}
applyToInputs(inputs) {
for (const input of inputs) {
const name = input.getAttribute('name').replace('md-', '');
input.checked = this.settingMap[name];
}
}
listenToInputChanges(inputs) {
for (const input of inputs) {
input.addEventListener('change', () => {
const name = input.getAttribute('name').replace('md-', '');
this.set(name, input.checked);
});
}
}
loadFromLocalStorage() {
const lsValString = window.localStorage.getItem('md-editor-settings');
if (!lsValString) {
return;
}
const lsVals = JSON.parse(lsValString);
for (const [key, value] of Object.entries(lsVals)) {
if (value !== null && this.settingMap[key] !== undefined) {
this.settingMap[key] = value;
}
}
}
set(key, value) {
this.settingMap[key] = value;
window.localStorage.setItem('md-editor-settings', JSON.stringify(this.settingMap));
for (const listener of (this.changeListeners[key] || [])) {
listener(value);
}
}
get(key) {
return this.settingMap[key] || null;
}
onChange(key, callback) {
const listeners = this.changeListeners[key] || [];
listeners.push(callback);
this.changeListeners[key] = listeners;
}
}

View File

@ -0,0 +1,82 @@
type ChangeListener = (value: boolean|number) => void;
export class Settings {
protected changeListeners: Record<string, ChangeListener[]> = {};
protected settingMap: Record<string, boolean|number> = {
scrollSync: true,
showPreview: true,
editorWidth: 50,
plainEditor: false,
};
constructor(settingInputs: HTMLInputElement[]) {
this.loadFromLocalStorage();
this.applyToInputs(settingInputs);
this.listenToInputChanges(settingInputs);
}
protected applyToInputs(inputs: HTMLInputElement[]): void {
for (const input of inputs) {
const name = input.getAttribute('name')?.replace('md-', '');
if (name && name in this.settingMap) {
const value = this.settingMap[name];
if (typeof value === 'boolean') {
input.checked = value;
} else {
input.value = value.toString();
}
}
}
}
protected listenToInputChanges(inputs: HTMLInputElement[]): void {
for (const input of inputs) {
input.addEventListener('change', () => {
const name = input.getAttribute('name')?.replace('md-', '');
if (name && name in this.settingMap) {
let value = (input.type === 'checkbox') ? input.checked : Number(input.value);
this.set(name, value);
}
});
}
}
protected loadFromLocalStorage(): void {
const lsValString = window.localStorage.getItem('md-editor-settings');
if (!lsValString) {
return;
}
try {
const lsVals = JSON.parse(lsValString);
for (const [key, value] of Object.entries(lsVals)) {
if (value !== null && value !== undefined && key in this.settingMap) {
this.settingMap[key] = value as boolean|number;
}
}
} catch (error) {
console.warn('Failed to parse settings from localStorage:', error);
}
}
public set(key: string, value: boolean|number): void {
this.settingMap[key] = value;
window.localStorage.setItem('md-editor-settings', JSON.stringify(this.settingMap));
const listeners = this.changeListeners[key] || [];
for (const listener of listeners) {
listener(value);
}
}
public get(key: string): number|boolean|null {
return this.settingMap[key] ?? null;
}
public onChange(key: string, callback: ChangeListener): void {
const listeners = this.changeListeners[key] || [];
listeners.push(callback);
this.changeListeners[key] = listeners;
}
}

View File

@ -1,10 +1,11 @@
import {MarkdownEditor} from "./index.mjs";
import {KeyBinding} from "@codemirror/view";
/**
* Provide shortcuts for the editor instance.
* @param {MarkdownEditor} editor
* @returns {Object<String, Function>}
*/
function provide(editor) {
const shortcuts = {};
function provide(editor: MarkdownEditor): Record<string, () => void> {
const shortcuts: Record<string, () => void> = {};
// Insert Image shortcut
shortcuts['Shift-Mod-i'] = () => editor.actions.insertImage();
@ -42,14 +43,12 @@ function provide(editor) {
/**
* Get the editor shortcuts in CodeMirror keybinding format.
* @param {MarkdownEditor} editor
* @return {{key: String, run: function, preventDefault: boolean}[]}
*/
export function provideKeyBindings(editor) {
export function provideKeyBindings(editor: MarkdownEditor): KeyBinding[] {
const shortcuts = provide(editor);
const keyBindings = [];
const wrapAction = action => () => {
const wrapAction = (action: ()=>void) => () => {
action();
return true;
};