"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",
"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",
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();
}
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();
}
},
// 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()) {
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;
-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';
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;
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');
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);
}
/**
* 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
export interface MarkdownEditorConfig {
pageId: string;
container: Element;
- displayEl: Element;
+ displayEl: HTMLIFrameElement;
inputEl: HTMLTextAreaElement;
drawioUrl: string;
settingInputs: HTMLInputElement[];
/**
* 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);
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});
}
/**
- * 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);
}
+++ /dev/null
-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;
- }
-
-}
--- /dev/null
+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
+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();
/**
* 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;
};