]> BookStack Code Mirror - bookstack/commitdiff
MD Editor: Added plaintext input implementation
authorDan Brown <redacted>
Mon, 21 Jul 2025 17:53:22 +0000 (18:53 +0100)
committerDan Brown <redacted>
Mon, 21 Jul 2025 17:53:22 +0000 (18:53 +0100)
resources/js/markdown/codemirror.ts
resources/js/markdown/dom-handlers.ts [new file with mode: 0644]
resources/js/markdown/index.mts
resources/js/markdown/inputs/textarea.ts [new file with mode: 0644]
resources/js/markdown/shortcuts.ts
resources/sass/_forms.scss

index 1b54c58196b4372fa27ded2b70098bb088c4aa0a..82aeb11418f3c3141e947e6ae25227d0c1c42488 100644 (file)
@@ -1,72 +1,19 @@
 import {provideKeyBindings} from './shortcuts';
-import {debounce} from '../services/util';
-import {Clipboard} from '../services/clipboard';
 import {EditorView, ViewUpdate} from "@codemirror/view";
 import {MarkdownEditor} from "./index.mjs";
 import {CodeModule} from "../global";
+import {MarkdownEditorEventMap} from "./dom-handlers";
 
 /**
- * Initiate the codemirror instance for the MarkDown editor.
+ * Initiate the codemirror instance for the Markdown editor.
  */
-export function init(editor: MarkdownEditor, Code: CodeModule): EditorView {
+export function init(editor: MarkdownEditor, Code: CodeModule, domEventHandlers: MarkdownEditorEventMap): EditorView {
     function onViewUpdate(v: ViewUpdate) {
         if (v.docChanged) {
             editor.actions.updateAndRender();
         }
     }
 
-    const onScrollDebounced = debounce(editor.actions.syncDisplayPosition.bind(editor.actions), 100, false);
-    let syncActive = editor.settings.get('scrollSync');
-    editor.settings.onChange('scrollSync', val => {
-        syncActive = val;
-    });
-
-    const domEventHandlers = {
-        // Handle scroll to sync display view
-        scroll: (event: Event) => syncActive && onScrollDebounced(event),
-        // Handle image & content drag n drop
-        drop: (event: DragEvent) => {
-            if (!event.dataTransfer) {
-                return;
-            }
-
-            const templateId = event.dataTransfer.getData('bookstack/template');
-            if (templateId) {
-                event.preventDefault();
-                editor.actions.insertTemplate(templateId, event.pageX, event.pageY);
-            }
-
-            const clipboard = new Clipboard(event.dataTransfer);
-            const clipboardImages = clipboard.getImages();
-            if (clipboardImages.length > 0) {
-                event.stopPropagation();
-                event.preventDefault();
-                editor.actions.insertClipboardImages(clipboardImages, event.pageX, event.pageY);
-            }
-        },
-        // Handle dragover event to allow as drop-target in chrome
-        dragover: (event: DragEvent) => {
-            event.preventDefault();
-        },
-        // Handle image paste
-        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()) {
-                return;
-            }
-
-            const images = clipboard.getImages();
-            for (const image of images) {
-                editor.actions.uploadImage(image);
-            }
-        },
-    };
 
     const cm = Code.markdownEditor(
         editor.config.inputEl,
diff --git a/resources/js/markdown/dom-handlers.ts b/resources/js/markdown/dom-handlers.ts
new file mode 100644 (file)
index 0000000..db3f2b5
--- /dev/null
@@ -0,0 +1,62 @@
+import {Clipboard} from "../services/clipboard";
+import {MarkdownEditor} from "./index.mjs";
+import {debounce} from "../services/util";
+
+
+export type MarkdownEditorEventMap = Record<string, (event: any) => void>;
+
+export function getMarkdownDomEventHandlers(editor: MarkdownEditor): MarkdownEditorEventMap {
+
+    const onScrollDebounced = debounce(editor.actions.syncDisplayPosition.bind(editor.actions), 100, false);
+    let syncActive = editor.settings.get('scrollSync');
+    editor.settings.onChange('scrollSync', val => {
+        syncActive = val;
+    });
+
+    return {
+        // Handle scroll to sync display view
+        scroll: (event: Event) => syncActive && onScrollDebounced(event),
+        // Handle image & content drag n drop
+        drop: (event: DragEvent) => {
+            if (!event.dataTransfer) {
+                return;
+            }
+
+            const templateId = event.dataTransfer.getData('bookstack/template');
+            if (templateId) {
+                event.preventDefault();
+                editor.actions.insertTemplate(templateId, event.pageX, event.pageY);
+            }
+
+            const clipboard = new Clipboard(event.dataTransfer);
+            const clipboardImages = clipboard.getImages();
+            if (clipboardImages.length > 0) {
+                event.stopPropagation();
+                event.preventDefault();
+                editor.actions.insertClipboardImages(clipboardImages, event.pageX, event.pageY);
+            }
+        },
+        // Handle dragover event to allow as drop-target in chrome
+        dragover: (event: DragEvent) => {
+            event.preventDefault();
+        },
+        // Handle image paste
+        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()) {
+                return;
+            }
+
+            const images = clipboard.getImages();
+            for (const image of images) {
+                editor.actions.uploadImage(image);
+            }
+        },
+    };
+}
\ No newline at end of file
index 5385e27cc462f57cb003686343e3e3c4c95b3a94..7edf80d4fb401dfa3caf9816437557fde972053f 100644 (file)
@@ -7,6 +7,9 @@ import {init as initCodemirror} from './codemirror';
 import {CodeModule} from "../global";
 import {MarkdownEditorInput} from "./inputs/interface";
 import {CodemirrorInput} from "./inputs/codemirror";
+import {TextareaInput} from "./inputs/textarea";
+import {provideShortcutMap} from "./shortcuts";
+import {getMarkdownDomEventHandlers} from "./dom-handlers";
 
 export interface MarkdownEditorConfig {
     pageId: string;
@@ -31,7 +34,7 @@ export interface MarkdownEditor {
  * Initiate a new Markdown editor instance.
  */
 export async function init(config: MarkdownEditorConfig): Promise<MarkdownEditor> {
-    const Code = await window.importVersioned('code') as CodeModule;
+    // const Code = await window.importVersioned('code') as CodeModule;
 
     const editor: MarkdownEditor = {
         config,
@@ -42,8 +45,17 @@ export async function init(config: MarkdownEditorConfig): Promise<MarkdownEditor
     editor.actions = new Actions(editor);
     editor.display = new Display(editor);
 
-    const codeMirror = initCodemirror(editor, Code);
-    editor.input = new CodemirrorInput(codeMirror);
+    const eventHandlers = getMarkdownDomEventHandlers(editor);
+    // TODO - Switching
+    // const codeMirror = initCodemirror(editor, Code);
+    // editor.input = new CodemirrorInput(codeMirror);
+    editor.input = new TextareaInput(
+        config.inputEl,
+        provideShortcutMap(editor),
+        eventHandlers
+    );
+
+    // window.devinput = editor.input;
 
     listenToCommonEvents(editor);
 
diff --git a/resources/js/markdown/inputs/textarea.ts b/resources/js/markdown/inputs/textarea.ts
new file mode 100644 (file)
index 0000000..d1eabd2
--- /dev/null
@@ -0,0 +1,137 @@
+import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface";
+import {MarkdownEditorShortcutMap} from "../shortcuts";
+import {MarkdownEditorEventMap} from "../dom-handlers";
+
+
+export class TextareaInput implements MarkdownEditorInput {
+
+    protected input: HTMLTextAreaElement;
+    protected shortcuts: MarkdownEditorShortcutMap;
+    protected events: MarkdownEditorEventMap;
+
+    constructor(input: HTMLTextAreaElement, shortcuts: MarkdownEditorShortcutMap, events: MarkdownEditorEventMap) {
+        this.input = input;
+        this.shortcuts = shortcuts;
+        this.events = events;
+
+        this.onKeyDown = this.onKeyDown.bind(this);
+        this.configureListeners();
+    }
+
+    configureListeners(): void {
+        // TODO - Teardown handling
+        this.input.addEventListener('keydown', this.onKeyDown);
+
+        for (const [name, listener] of Object.entries(this.events)) {
+            this.input.addEventListener(name, listener);
+        }
+    }
+
+    onKeyDown(e: KeyboardEvent) {
+        const isApple = navigator.platform.startsWith("Mac") || navigator.platform === "iPhone";
+        const keyParts = [
+            e.shiftKey ? 'Shift' : null,
+            isApple && e.metaKey ? 'Mod' : null,
+            !isApple && e.ctrlKey ? 'Mod' : null,
+            e.key,
+        ];
+
+        const keyString = keyParts.filter(Boolean).join('-');
+        if (this.shortcuts[keyString]) {
+            e.preventDefault();
+            this.shortcuts[keyString]();
+        }
+    }
+
+    appendText(text: string): void {
+        this.input.value += `\n${text}`;
+    }
+
+    coordsToSelection(x: number, y: number): MarkdownEditorInputSelection {
+        // TODO
+        return this.getSelection();
+    }
+
+    focus(): void {
+        this.input.focus();
+    }
+
+    getLineRangeFromPosition(position: number): MarkdownEditorInputSelection {
+        const lines = this.getText().split('\n');
+        let lineStart = 0;
+        for (let i = 0; i < lines.length; i++) {
+            const line = lines[i];
+            const newEnd = lineStart + line.length + 1;
+            if (position < newEnd) {
+                return {from: lineStart, to: newEnd};
+            }
+            lineStart = newEnd;
+        }
+
+        return {from: 0, to: 0};
+    }
+
+    getLineText(lineIndex: number): string {
+        const text = this.getText();
+        const lines = text.split("\n");
+        return lines[lineIndex] || '';
+    }
+
+    getSelection(): MarkdownEditorInputSelection {
+        return {from: this.input.selectionStart, to: this.input.selectionEnd};
+    }
+
+    getSelectionText(selection?: MarkdownEditorInputSelection): string {
+        const text = this.getText();
+        const range = selection || this.getSelection();
+        return text.slice(range.from, range.to);
+    }
+
+    getText(): string {
+        return this.input.value;
+    }
+
+    getTextAboveView(): string {
+        const scrollTop = this.input.scrollTop;
+        const computedStyles = window.getComputedStyle(this.input);
+        const lines = this.getText().split('\n');
+        const paddingTop = Number(computedStyles.paddingTop.replace('px', ''));
+        const paddingBottom = Number(computedStyles.paddingBottom.replace('px', ''));
+
+        const avgLineHeight = (this.input.scrollHeight - paddingBottom - paddingTop) / lines.length;
+        const roughLinePos = Math.max(Math.floor((scrollTop - paddingTop) / avgLineHeight), 0);
+        const linesAbove = this.getText().split('\n').slice(0, roughLinePos);
+        return linesAbove.join('\n');
+    }
+
+    searchForLineContaining(text: string): MarkdownEditorInputSelection | null {
+        const textPosition = this.getText().indexOf(text);
+        if (textPosition > -1) {
+            return this.getLineRangeFromPosition(textPosition);
+        }
+
+        return null;
+    }
+
+    setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean): void {
+        this.input.selectionStart = selection.from;
+        this.input.selectionEnd = selection.to;
+    }
+
+    setText(text: string, selection?: MarkdownEditorInputSelection): void {
+        this.input.value = text;
+        if (selection) {
+            this.setSelection(selection, false);
+        }
+    }
+
+    spliceText(from: number, to: number, newText: string, selection: Partial<MarkdownEditorInputSelection> | null): void {
+        const text = this.getText();
+        const updatedText = text.slice(0, from) + newText + text.slice(to);
+        this.setText(updatedText);
+        if (selection && selection.from) {
+            const newSelection = {from: selection.from, to: selection.to || selection.from};
+            this.setSelection(newSelection, false);
+        }
+    }
+}
\ No newline at end of file
index c746b52e703525b8b0a34f2b475b8a3f29cc8f02..734160f29f0353de95140bdc2ada6f6d32ca5b41 100644 (file)
@@ -1,11 +1,13 @@
 import {MarkdownEditor} from "./index.mjs";
 import {KeyBinding} from "@codemirror/view";
 
+export type MarkdownEditorShortcutMap = Record<string, () => void>;
+
 /**
  * Provide shortcuts for the editor instance.
  */
-function provide(editor: MarkdownEditor): Record<string, () => void> {
-    const shortcuts: Record<string, () => void> = {};
+export function provideShortcutMap(editor: MarkdownEditor): MarkdownEditorShortcutMap {
+    const shortcuts: MarkdownEditorShortcutMap = {};
 
     // Insert Image shortcut
     shortcuts['Shift-Mod-i'] = () => editor.actions.insertImage();
@@ -45,7 +47,7 @@ function provide(editor: MarkdownEditor): Record<string, () => void> {
  * Get the editor shortcuts in CodeMirror keybinding format.
  */
 export function provideKeyBindings(editor: MarkdownEditor): KeyBinding[] {
-    const shortcuts = provide(editor);
+    const shortcuts = provideShortcutMap(editor);
     const keyBindings = [];
 
     const wrapAction = (action: ()=>void) => () => {
index b66688f8d202a1abc9081ac6499dd82fd9b68016..e71edc1d75776dd7e9c9d0027aa91f4500e980ea 100644 (file)
@@ -57,6 +57,9 @@
     padding: vars.$xs vars.$m;
     color: #444;
     border-radius: 0;
+    height: 100%;
+    font-size: 14px;
+    line-height: 1.2;
     max-height: 100%;
     flex: 1;
     border: 0;