From: Dan Brown Date: Sun, 20 Jul 2025 14:05:19 +0000 (+0100) Subject: MD Editor: Finished conversion to Typescript X-Git-Url: http://source.bookstackapp.com/bookstack/commitdiff_plain/61adc735c8d525907dbb8b1b554c24d303cec229 MD Editor: Finished conversion to Typescript --- diff --git a/package-lock.json b/package-lock.json index 926a6d9e3..0348fd1ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -2508,6 +2509,31 @@ "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", diff --git a/package.json b/package.json index 5d94537d1..151338d8c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/js/markdown/codemirror.js b/resources/js/markdown/codemirror.ts similarity index 68% rename from resources/js/markdown/codemirror.js rename to resources/js/markdown/codemirror.ts index 61b2e8457..a3b81418f 100644 --- a/resources/js/markdown/codemirror.js +++ b/resources/js/markdown/codemirror.ts @@ -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} */ -export async function init(editor) { - const Code = await window.importVersioned('code'); +export async function init(editor: MarkdownEditor): Promise { + 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; diff --git a/resources/js/markdown/display.js b/resources/js/markdown/display.ts similarity index 52% rename from resources/js/markdown/display.js rename to resources/js/markdown/display.ts index 60be26b5f..3eb7e5c6a 100644 --- a/resources/js/markdown/display.js +++ b/resources/js/markdown/display.ts @@ -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 diff --git a/resources/js/markdown/index.mts b/resources/js/markdown/index.mts index 46345ccfd..d487b7972 100644 --- a/resources/js/markdown/index.mts +++ b/resources/js/markdown/index.mts @@ -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} */ -export async function init(config) { - /** - * @type {MarkdownEditor} - */ +export async function init(config: MarkdownEditorConfig): Promise { const editor: MarkdownEditor = { config, markdown: new Markdown(), settings: new Settings(config.settingInputs), - }; + } as MarkdownEditor; editor.actions = new Actions(editor); editor.display = new Display(editor); diff --git a/resources/js/markdown/markdown.js b/resources/js/markdown/markdown.ts similarity index 68% rename from resources/js/markdown/markdown.js rename to resources/js/markdown/markdown.ts index e63184acc..07ea09e91 100644 --- a/resources/js/markdown/markdown.js +++ b/resources/js/markdown/markdown.ts @@ -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 index e2e1fce5e..000000000 --- a/resources/js/markdown/settings.js +++ /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 index 000000000..c446cbe05 --- /dev/null +++ b/resources/js/markdown/settings.ts @@ -0,0 +1,82 @@ +type ChangeListener = (value: boolean|number) => void; + +export class Settings { + protected changeListeners: Record = {}; + + protected settingMap: Record = { + 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 diff --git a/resources/js/markdown/shortcuts.js b/resources/js/markdown/shortcuts.ts similarity index 85% rename from resources/js/markdown/shortcuts.js rename to resources/js/markdown/shortcuts.ts index 543e6dcdd..c746b52e7 100644 --- a/resources/js/markdown/shortcuts.js +++ b/resources/js/markdown/shortcuts.ts @@ -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} */ -function provide(editor) { - const shortcuts = {}; +function provide(editor: MarkdownEditor): Record void> { + const shortcuts: Record 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; };