]> BookStack Code Mirror - bookstack/commitdiff
MD Editor: Finished conversion to Typescript
authorDan Brown <redacted>
Sun, 20 Jul 2025 14:05:19 +0000 (15:05 +0100)
committerDan Brown <redacted>
Sun, 20 Jul 2025 14:05:19 +0000 (15:05 +0100)
package-lock.json
package.json
resources/js/markdown/codemirror.ts [moved from resources/js/markdown/codemirror.js with 68% similarity]
resources/js/markdown/display.ts [moved from resources/js/markdown/display.js with 52% similarity]
resources/js/markdown/index.mts
resources/js/markdown/markdown.ts [moved from resources/js/markdown/markdown.js with 68% similarity]
resources/js/markdown/settings.js [deleted file]
resources/js/markdown/settings.ts [new file with mode: 0644]
resources/js/markdown/shortcuts.ts [moved from resources/js/markdown/shortcuts.js with 85% similarity]

index 926a6d9e3f53c85eace2ff248bebe47427eac31f..0348fd1ed42df023fc6cf548ba3353e3f1132236 100644 (file)
@@ -32,6 +32,7 @@
       "devDependencies": {
         "@eslint/js": "^9.21.0",
         "@lezer/generator": "^1.7.2",
+        "@types/markdown-it": "^14.1.2",
         "@types/sortablejs": "^1.15.8",
         "chokidar-cli": "^3.0",
         "esbuild": "^0.25.0",
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/markdown-it": {
+      "version": "14.1.2",
+      "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+      "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/linkify-it": "^5",
+        "@types/mdurl": "^2"
+      }
+    },
+    "node_modules/@types/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/node": {
       "version": "22.15.21",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
index 5d94537d140090563e1268bcf49e066bb4a35dcd..151338d8c6eed203a55c016f54d19310c47c182a 100644 (file)
@@ -21,6 +21,7 @@
   "devDependencies": {
     "@eslint/js": "^9.21.0",
     "@lezer/generator": "^1.7.2",
+    "@types/markdown-it": "^14.1.2",
     "@types/sortablejs": "^1.15.8",
     "chokidar-cli": "^3.0",
     "esbuild": "^0.25.0",
similarity index 68%
rename from resources/js/markdown/codemirror.js
rename to resources/js/markdown/codemirror.ts
index 61b2e84573d8c2730cc8810d1962f2fbbc5320c9..a3b81418f9f5128aebf6779fbae5da8291427fce 100644 (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;
similarity index 52%
rename from resources/js/markdown/display.js
rename to resources/js/markdown/display.ts
index 60be26b5fb705491ab5a5712977ac90291382421..3eb7e5c6aa212addd327eca2aeede1943b81db66 100644 (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'
+        });
     }
-
-}
+}
\ No newline at end of file
index 46345ccfd6f89c624ac3fa3f4096b58b0bb86614..d487b7972e878b4130a0a3072a8d212264e69438 100644 (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);
similarity index 68%
rename from resources/js/markdown/markdown.js
rename to resources/js/markdown/markdown.ts
index e63184accaf141a8caa78414f8bcb4cfafa2e7d2..07ea09e9113fb0d78ab30b2b5039feaaa8e0419a 100644 (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);
     }
 
diff --git a/resources/js/markdown/settings.js b/resources/js/markdown/settings.js
deleted file mode 100644 (file)
index e2e1fce..0000000
+++ /dev/null
@@ -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;
-    }
-
-}
diff --git a/resources/js/markdown/settings.ts b/resources/js/markdown/settings.ts
new file mode 100644 (file)
index 0000000..c446cbe
--- /dev/null
@@ -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;
+    }
+}
\ No newline at end of file
similarity index 85%
rename from resources/js/markdown/shortcuts.js
rename to resources/js/markdown/shortcuts.ts
index 543e6dcdde67dd26ab8beb8f2987a71932151fbc..c746b52e703525b8b0a34f2b475b8a3f29cc8f02 100644 (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;
     };