mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-07-28 17:02:04 +03:00
MD Editor: Added plaintext input implementation
This commit is contained in:
@ -1,72 +1,19 @@
|
|||||||
import {provideKeyBindings} from './shortcuts';
|
import {provideKeyBindings} from './shortcuts';
|
||||||
import {debounce} from '../services/util';
|
|
||||||
import {Clipboard} from '../services/clipboard';
|
|
||||||
import {EditorView, ViewUpdate} from "@codemirror/view";
|
import {EditorView, ViewUpdate} from "@codemirror/view";
|
||||||
import {MarkdownEditor} from "./index.mjs";
|
import {MarkdownEditor} from "./index.mjs";
|
||||||
import {CodeModule} from "../global";
|
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) {
|
function onViewUpdate(v: ViewUpdate) {
|
||||||
if (v.docChanged) {
|
if (v.docChanged) {
|
||||||
editor.actions.updateAndRender();
|
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(
|
const cm = Code.markdownEditor(
|
||||||
editor.config.inputEl,
|
editor.config.inputEl,
|
||||||
|
62
resources/js/markdown/dom-handlers.ts
Normal file
62
resources/js/markdown/dom-handlers.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
@ -7,6 +7,9 @@ import {init as initCodemirror} from './codemirror';
|
|||||||
import {CodeModule} from "../global";
|
import {CodeModule} from "../global";
|
||||||
import {MarkdownEditorInput} from "./inputs/interface";
|
import {MarkdownEditorInput} from "./inputs/interface";
|
||||||
import {CodemirrorInput} from "./inputs/codemirror";
|
import {CodemirrorInput} from "./inputs/codemirror";
|
||||||
|
import {TextareaInput} from "./inputs/textarea";
|
||||||
|
import {provideShortcutMap} from "./shortcuts";
|
||||||
|
import {getMarkdownDomEventHandlers} from "./dom-handlers";
|
||||||
|
|
||||||
export interface MarkdownEditorConfig {
|
export interface MarkdownEditorConfig {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -31,7 +34,7 @@ export interface MarkdownEditor {
|
|||||||
* Initiate a new Markdown editor instance.
|
* Initiate a new Markdown editor instance.
|
||||||
*/
|
*/
|
||||||
export async function init(config: MarkdownEditorConfig): Promise<MarkdownEditor> {
|
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 = {
|
const editor: MarkdownEditor = {
|
||||||
config,
|
config,
|
||||||
@ -42,8 +45,17 @@ export async function init(config: MarkdownEditorConfig): Promise<MarkdownEditor
|
|||||||
editor.actions = new Actions(editor);
|
editor.actions = new Actions(editor);
|
||||||
editor.display = new Display(editor);
|
editor.display = new Display(editor);
|
||||||
|
|
||||||
const codeMirror = initCodemirror(editor, Code);
|
const eventHandlers = getMarkdownDomEventHandlers(editor);
|
||||||
editor.input = new CodemirrorInput(codeMirror);
|
// 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);
|
listenToCommonEvents(editor);
|
||||||
|
|
||||||
|
137
resources/js/markdown/inputs/textarea.ts
Normal file
137
resources/js/markdown/inputs/textarea.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,13 @@
|
|||||||
import {MarkdownEditor} from "./index.mjs";
|
import {MarkdownEditor} from "./index.mjs";
|
||||||
import {KeyBinding} from "@codemirror/view";
|
import {KeyBinding} from "@codemirror/view";
|
||||||
|
|
||||||
|
export type MarkdownEditorShortcutMap = Record<string, () => void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provide shortcuts for the editor instance.
|
* Provide shortcuts for the editor instance.
|
||||||
*/
|
*/
|
||||||
function provide(editor: MarkdownEditor): Record<string, () => void> {
|
export function provideShortcutMap(editor: MarkdownEditor): MarkdownEditorShortcutMap {
|
||||||
const shortcuts: Record<string, () => void> = {};
|
const shortcuts: MarkdownEditorShortcutMap = {};
|
||||||
|
|
||||||
// Insert Image shortcut
|
// Insert Image shortcut
|
||||||
shortcuts['Shift-Mod-i'] = () => editor.actions.insertImage();
|
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.
|
* Get the editor shortcuts in CodeMirror keybinding format.
|
||||||
*/
|
*/
|
||||||
export function provideKeyBindings(editor: MarkdownEditor): KeyBinding[] {
|
export function provideKeyBindings(editor: MarkdownEditor): KeyBinding[] {
|
||||||
const shortcuts = provide(editor);
|
const shortcuts = provideShortcutMap(editor);
|
||||||
const keyBindings = [];
|
const keyBindings = [];
|
||||||
|
|
||||||
const wrapAction = (action: ()=>void) => () => {
|
const wrapAction = (action: ()=>void) => () => {
|
||||||
|
@ -57,6 +57,9 @@
|
|||||||
padding: vars.$xs vars.$m;
|
padding: vars.$xs vars.$m;
|
||||||
color: #444;
|
color: #444;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.2;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
Reference in New Issue
Block a user