mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-10-29 16:09:29 +03:00
Merge pull request #5725 from BookStackApp/md_plaintext
MarkDown Editor: TypeScript Conversion & Plaintext Editor
This commit is contained in:
@@ -13,7 +13,7 @@ const entryPoints = {
|
|||||||
app: path.join(__dirname, '../../resources/js/app.ts'),
|
app: path.join(__dirname, '../../resources/js/app.ts'),
|
||||||
code: path.join(__dirname, '../../resources/js/code/index.mjs'),
|
code: path.join(__dirname, '../../resources/js/code/index.mjs'),
|
||||||
'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'),
|
'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'),
|
||||||
markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'),
|
markdown: path.join(__dirname, '../../resources/js/markdown/index.mts'),
|
||||||
wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'),
|
wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ return [
|
|||||||
'pages_md_insert_drawing' => 'Insert Drawing',
|
'pages_md_insert_drawing' => 'Insert Drawing',
|
||||||
'pages_md_show_preview' => 'Show preview',
|
'pages_md_show_preview' => 'Show preview',
|
||||||
'pages_md_sync_scroll' => 'Sync preview scroll',
|
'pages_md_sync_scroll' => 'Sync preview scroll',
|
||||||
|
'pages_md_plain_editor' => 'Plaintext editor',
|
||||||
'pages_drawing_unsaved' => 'Unsaved Drawing Found',
|
'pages_drawing_unsaved' => 'Unsaved Drawing Found',
|
||||||
'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
|
'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
|
||||||
'pages_not_in_chapter' => 'Page is not in a chapter',
|
'pages_not_in_chapter' => 'Page is not in a chapter',
|
||||||
|
|||||||
719
package-lock.json
generated
719
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
"@lezer/generator": "^1.7.2",
|
"@lezer/generator": "^1.7.2",
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/sortablejs": "^1.15.8",
|
"@types/sortablejs": "^1.15.8",
|
||||||
"chokidar-cli": "^3.0",
|
"chokidar-cli": "^3.0",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import {Component} from './component';
|
import {Component} from './component';
|
||||||
|
import {EntitySelector, EntitySelectorEntity, EntitySelectorSearchOptions} from "./entity-selector";
|
||||||
|
import {Popup} from "./popup";
|
||||||
|
|
||||||
|
export type EntitySelectorPopupCallback = (entity: EntitySelectorEntity) => void;
|
||||||
|
|
||||||
export class EntitySelectorPopup extends Component {
|
export class EntitySelectorPopup extends Component {
|
||||||
|
|
||||||
|
protected container!: HTMLElement;
|
||||||
|
protected selectButton!: HTMLElement;
|
||||||
|
protected selectorEl!: HTMLElement;
|
||||||
|
|
||||||
|
protected callback: EntitySelectorPopupCallback|null = null;
|
||||||
|
protected selection: EntitySelectorEntity|null = null;
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.container = this.$el;
|
this.container = this.$el;
|
||||||
this.selectButton = this.$refs.select;
|
this.selectButton = this.$refs.select;
|
||||||
this.selectorEl = this.$refs.selector;
|
this.selectorEl = this.$refs.selector;
|
||||||
|
|
||||||
this.callback = null;
|
|
||||||
this.selection = null;
|
|
||||||
|
|
||||||
this.selectButton.addEventListener('click', this.onSelectButtonClick.bind(this));
|
this.selectButton.addEventListener('click', this.onSelectButtonClick.bind(this));
|
||||||
window.$events.listen('entity-select-change', this.onSelectionChange.bind(this));
|
window.$events.listen('entity-select-change', this.onSelectionChange.bind(this));
|
||||||
window.$events.listen('entity-select-confirm', this.handleConfirmedSelection.bind(this));
|
window.$events.listen('entity-select-confirm', this.handleConfirmedSelection.bind(this));
|
||||||
@@ -17,10 +25,8 @@ export class EntitySelectorPopup extends Component {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the selector popup.
|
* Show the selector popup.
|
||||||
* @param {Function} callback
|
|
||||||
* @param {EntitySelectorSearchOptions} searchOptions
|
|
||||||
*/
|
*/
|
||||||
show(callback, searchOptions = {}) {
|
show(callback: EntitySelectorPopupCallback, searchOptions: Partial<EntitySelectorSearchOptions> = {}) {
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
this.getSelector().configureSearchOptions(searchOptions);
|
this.getSelector().configureSearchOptions(searchOptions);
|
||||||
this.getPopup().show();
|
this.getPopup().show();
|
||||||
@@ -32,34 +38,28 @@ export class EntitySelectorPopup extends Component {
|
|||||||
this.getPopup().hide();
|
this.getPopup().hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getPopup(): Popup {
|
||||||
* @returns {Popup}
|
return window.$components.firstOnElement(this.container, 'popup') as Popup;
|
||||||
*/
|
|
||||||
getPopup() {
|
|
||||||
return window.$components.firstOnElement(this.container, 'popup');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getSelector(): EntitySelector {
|
||||||
* @returns {EntitySelector}
|
return window.$components.firstOnElement(this.selectorEl, 'entity-selector') as EntitySelector;
|
||||||
*/
|
|
||||||
getSelector() {
|
|
||||||
return window.$components.firstOnElement(this.selectorEl, 'entity-selector');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelectButtonClick() {
|
onSelectButtonClick() {
|
||||||
this.handleConfirmedSelection(this.selection);
|
this.handleConfirmedSelection(this.selection);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelectionChange(entity) {
|
onSelectionChange(entity: EntitySelectorEntity|{}) {
|
||||||
this.selection = entity;
|
this.selection = (entity.hasOwnProperty('id') ? entity : null) as EntitySelectorEntity|null;
|
||||||
if (entity === null) {
|
if (!this.selection) {
|
||||||
this.selectButton.setAttribute('disabled', 'true');
|
this.selectButton.setAttribute('disabled', 'true');
|
||||||
} else {
|
} else {
|
||||||
this.selectButton.removeAttribute('disabled');
|
this.selectButton.removeAttribute('disabled');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConfirmedSelection(entity) {
|
handleConfirmedSelection(entity: EntitySelectorEntity|null): void {
|
||||||
this.hide();
|
this.hide();
|
||||||
this.getSelector().reset();
|
this.getSelector().reset();
|
||||||
if (this.callback && entity) this.callback(entity);
|
if (this.callback && entity) this.callback(entity);
|
||||||
@@ -1,24 +1,36 @@
|
|||||||
import {onChildEvent} from '../services/dom.ts';
|
import {onChildEvent} from '../services/dom';
|
||||||
import {Component} from './component';
|
import {Component} from './component';
|
||||||
|
|
||||||
/**
|
export interface EntitySelectorSearchOptions {
|
||||||
* @typedef EntitySelectorSearchOptions
|
entityTypes: string;
|
||||||
* @property entityTypes string
|
entityPermission: string;
|
||||||
* @property entityPermission string
|
searchEndpoint: string;
|
||||||
* @property searchEndpoint string
|
initialValue: string;
|
||||||
* @property initialValue string
|
}
|
||||||
*/
|
|
||||||
|
export type EntitySelectorEntity = {
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
link: string,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity Selector
|
|
||||||
*/
|
|
||||||
export class EntitySelector extends Component {
|
export class EntitySelector extends Component {
|
||||||
|
protected elem!: HTMLElement;
|
||||||
|
protected input!: HTMLInputElement;
|
||||||
|
protected searchInput!: HTMLInputElement;
|
||||||
|
protected loading!: HTMLElement;
|
||||||
|
protected resultsContainer!: HTMLElement;
|
||||||
|
|
||||||
|
protected searchOptions!: EntitySelectorSearchOptions;
|
||||||
|
|
||||||
|
protected search = '';
|
||||||
|
protected lastClick = 0;
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.elem = this.$el;
|
this.elem = this.$el;
|
||||||
|
|
||||||
this.input = this.$refs.input;
|
this.input = this.$refs.input as HTMLInputElement;
|
||||||
this.searchInput = this.$refs.search;
|
this.searchInput = this.$refs.search as HTMLInputElement;
|
||||||
this.loading = this.$refs.loading;
|
this.loading = this.$refs.loading;
|
||||||
this.resultsContainer = this.$refs.results;
|
this.resultsContainer = this.$refs.results;
|
||||||
|
|
||||||
@@ -29,9 +41,6 @@ export class EntitySelector extends Component {
|
|||||||
initialValue: this.searchInput.value || '',
|
initialValue: this.searchInput.value || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
this.search = '';
|
|
||||||
this.lastClick = 0;
|
|
||||||
|
|
||||||
this.setupListeners();
|
this.setupListeners();
|
||||||
this.showLoading();
|
this.showLoading();
|
||||||
|
|
||||||
@@ -40,16 +49,13 @@ export class EntitySelector extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
configureSearchOptions(options: Partial<EntitySelectorSearchOptions>): void {
|
||||||
* @param {EntitySelectorSearchOptions} options
|
|
||||||
*/
|
|
||||||
configureSearchOptions(options) {
|
|
||||||
Object.assign(this.searchOptions, options);
|
Object.assign(this.searchOptions, options);
|
||||||
this.reset();
|
this.reset();
|
||||||
this.searchInput.value = this.searchOptions.initialValue;
|
this.searchInput.value = this.searchOptions.initialValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupListeners() {
|
setupListeners(): void {
|
||||||
this.elem.addEventListener('click', this.onClick.bind(this));
|
this.elem.addEventListener('click', this.onClick.bind(this));
|
||||||
|
|
||||||
let lastSearch = 0;
|
let lastSearch = 0;
|
||||||
@@ -67,7 +73,7 @@ export class EntitySelector extends Component {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
onChildEvent(this.$el, '[data-entity-type]', 'keydown', event => {
|
onChildEvent(this.$el, '[data-entity-type]', 'keydown', ((event: KeyboardEvent) => {
|
||||||
if (event.ctrlKey && event.code === 'Enter') {
|
if (event.ctrlKey && event.code === 'Enter') {
|
||||||
const form = this.$el.closest('form');
|
const form = this.$el.closest('form');
|
||||||
if (form) {
|
if (form) {
|
||||||
@@ -83,7 +89,7 @@ export class EntitySelector extends Component {
|
|||||||
if (event.code === 'ArrowUp') {
|
if (event.code === 'ArrowUp') {
|
||||||
this.focusAdjacent(false);
|
this.focusAdjacent(false);
|
||||||
}
|
}
|
||||||
});
|
}) as (event: Event) => void);
|
||||||
|
|
||||||
this.searchInput.addEventListener('keydown', event => {
|
this.searchInput.addEventListener('keydown', event => {
|
||||||
if (event.code === 'ArrowDown') {
|
if (event.code === 'ArrowDown') {
|
||||||
@@ -93,10 +99,10 @@ export class EntitySelector extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
focusAdjacent(forward = true) {
|
focusAdjacent(forward = true) {
|
||||||
const items = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]'));
|
const items: (Element|null)[] = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]'));
|
||||||
const selectedIndex = items.indexOf(document.activeElement);
|
const selectedIndex = items.indexOf(document.activeElement);
|
||||||
const newItem = items[selectedIndex + (forward ? 1 : -1)] || items[0];
|
const newItem = items[selectedIndex + (forward ? 1 : -1)] || items[0];
|
||||||
if (newItem) {
|
if (newItem instanceof HTMLElement) {
|
||||||
newItem.focus();
|
newItem.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +138,7 @@ export class EntitySelector extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.$http.get(this.searchUrl()).then(resp => {
|
window.$http.get(this.searchUrl()).then(resp => {
|
||||||
this.resultsContainer.innerHTML = resp.data;
|
this.resultsContainer.innerHTML = resp.data as string;
|
||||||
this.hideLoading();
|
this.hideLoading();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -142,7 +148,7 @@ export class EntitySelector extends Component {
|
|||||||
return `${this.searchOptions.searchEndpoint}?${query}`;
|
return `${this.searchOptions.searchEndpoint}?${query}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchEntities(searchTerm) {
|
searchEntities(searchTerm: string) {
|
||||||
if (!this.searchOptions.searchEndpoint) {
|
if (!this.searchOptions.searchEndpoint) {
|
||||||
throw new Error('Search endpoint not set for entity-selector load');
|
throw new Error('Search endpoint not set for entity-selector load');
|
||||||
}
|
}
|
||||||
@@ -150,7 +156,7 @@ export class EntitySelector extends Component {
|
|||||||
this.input.value = '';
|
this.input.value = '';
|
||||||
const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`;
|
const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`;
|
||||||
window.$http.get(url).then(resp => {
|
window.$http.get(url).then(resp => {
|
||||||
this.resultsContainer.innerHTML = resp.data;
|
this.resultsContainer.innerHTML = resp.data as string;
|
||||||
this.hideLoading();
|
this.hideLoading();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -162,16 +168,16 @@ export class EntitySelector extends Component {
|
|||||||
return answer;
|
return answer;
|
||||||
}
|
}
|
||||||
|
|
||||||
onClick(event) {
|
onClick(event: MouseEvent) {
|
||||||
const listItem = event.target.closest('[data-entity-type]');
|
const listItem = (event.target as HTMLElement).closest('[data-entity-type]');
|
||||||
if (listItem) {
|
if (listItem instanceof HTMLElement) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.selectItem(listItem);
|
this.selectItem(listItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectItem(item) {
|
selectItem(item: HTMLElement): void {
|
||||||
const isDblClick = this.isDoubleClick();
|
const isDblClick = this.isDoubleClick();
|
||||||
const type = item.getAttribute('data-entity-type');
|
const type = item.getAttribute('data-entity-type');
|
||||||
const id = item.getAttribute('data-entity-id');
|
const id = item.getAttribute('data-entity-id');
|
||||||
@@ -180,14 +186,14 @@ export class EntitySelector extends Component {
|
|||||||
this.unselectAll();
|
this.unselectAll();
|
||||||
this.input.value = isSelected ? `${type}:${id}` : '';
|
this.input.value = isSelected ? `${type}:${id}` : '';
|
||||||
|
|
||||||
const link = item.getAttribute('href');
|
const link = item.getAttribute('href') || '';
|
||||||
const name = item.querySelector('.entity-list-item-name').textContent;
|
const name = item.querySelector('.entity-list-item-name')?.textContent || '';
|
||||||
const data = {id: Number(id), name, link};
|
const data: EntitySelectorEntity = {id: Number(id), name, link};
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
item.classList.add('selected');
|
item.classList.add('selected');
|
||||||
} else {
|
} else {
|
||||||
window.$events.emit('entity-select-change', null);
|
window.$events.emit('entity-select-change');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDblClick && !isSelected) return;
|
if (!isDblClick && !isSelected) return;
|
||||||
@@ -200,7 +206,7 @@ export class EntitySelector extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmSelection(data) {
|
confirmSelection(data: EntitySelectorEntity) {
|
||||||
window.$events.emit('entity-select-confirm', data);
|
window.$events.emit('entity-select-confirm', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +127,10 @@ export class ImageManager extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {({ thumbs: { display: string; }; url: string; name: string; }) => void} callback
|
||||||
|
* @param {String} type
|
||||||
|
*/
|
||||||
show(callback, type = 'gallery') {
|
show(callback, type = 'gallery') {
|
||||||
this.resetAll();
|
this.resetAll();
|
||||||
|
|
||||||
|
|||||||
4
resources/js/global.d.ts
vendored
4
resources/js/global.d.ts
vendored
@@ -15,4 +15,6 @@ declare global {
|
|||||||
baseUrl: (path: string) => string;
|
baseUrl: (path: string) => string;
|
||||||
importVersioned: (module: string) => Promise<object>;
|
importVersioned: (module: string) => Promise<object>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CodeModule = (typeof import('./code/index.mjs'));
|
||||||
@@ -1,20 +1,29 @@
|
|||||||
import * as DrawIO from '../services/drawio.ts';
|
import * as DrawIO from '../services/drawio';
|
||||||
|
import {MarkdownEditor} from "./index.mjs";
|
||||||
|
import {EntitySelectorPopup, ImageManager} from "../components";
|
||||||
|
import {MarkdownEditorInputSelection} from "./inputs/interface";
|
||||||
|
|
||||||
|
interface ImageManagerImage {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
thumbs: { display: string; };
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class Actions {
|
export class Actions {
|
||||||
|
|
||||||
/**
|
protected readonly editor: MarkdownEditor;
|
||||||
* @param {MarkdownEditor} editor
|
protected lastContent: { html: string; markdown: string } = {
|
||||||
*/
|
html: '',
|
||||||
constructor(editor) {
|
markdown: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(editor: MarkdownEditor) {
|
||||||
this.editor = editor;
|
this.editor = editor;
|
||||||
this.lastContent = {
|
|
||||||
html: '',
|
|
||||||
markdown: '',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAndRender() {
|
updateAndRender() {
|
||||||
const content = this.#getText();
|
const content = this.editor.input.getText();
|
||||||
this.editor.config.inputEl.value = content;
|
this.editor.config.inputEl.value = content;
|
||||||
|
|
||||||
const html = this.editor.markdown.render(content);
|
const html = this.editor.markdown.render(content);
|
||||||
@@ -30,45 +39,42 @@ export class Actions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showImageInsert() {
|
showImageInsert() {
|
||||||
/** @type {ImageManager} * */
|
const imageManager = window.$components.first('image-manager') as ImageManager;
|
||||||
const imageManager = window.$components.first('image-manager');
|
|
||||||
|
|
||||||
imageManager.show(image => {
|
imageManager.show((image: ImageManagerImage) => {
|
||||||
const imageUrl = image.thumbs?.display || image.url;
|
const imageUrl = image.thumbs?.display || image.url;
|
||||||
const selectedText = this.#getSelectionText();
|
const selectedText = this.editor.input.getSelectionText();
|
||||||
const newText = `[](${image.url})`;
|
const newText = `[](${image.url})`;
|
||||||
this.#replaceSelection(newText, newText.length);
|
this.#replaceSelection(newText, newText.length);
|
||||||
}, 'gallery');
|
}, 'gallery');
|
||||||
}
|
}
|
||||||
|
|
||||||
insertImage() {
|
insertImage() {
|
||||||
const newText = ``;
|
const newText = ``;
|
||||||
this.#replaceSelection(newText, newText.length - 1);
|
this.#replaceSelection(newText, newText.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
insertLink() {
|
insertLink() {
|
||||||
const selectedText = this.#getSelectionText();
|
const selectedText = this.editor.input.getSelectionText();
|
||||||
const newText = `[${selectedText}]()`;
|
const newText = `[${selectedText}]()`;
|
||||||
const cursorPosDiff = (selectedText === '') ? -3 : -1;
|
const cursorPosDiff = (selectedText === '') ? -3 : -1;
|
||||||
this.#replaceSelection(newText, newText.length + cursorPosDiff);
|
this.#replaceSelection(newText, newText.length + cursorPosDiff);
|
||||||
}
|
}
|
||||||
|
|
||||||
showImageManager() {
|
showImageManager() {
|
||||||
const selectionRange = this.#getSelectionRange();
|
const selectionRange = this.editor.input.getSelection();
|
||||||
/** @type {ImageManager} * */
|
const imageManager = window.$components.first('image-manager') as ImageManager;
|
||||||
const imageManager = window.$components.first('image-manager');
|
imageManager.show((image: ImageManagerImage) => {
|
||||||
imageManager.show(image => {
|
|
||||||
this.#insertDrawing(image, selectionRange);
|
this.#insertDrawing(image, selectionRange);
|
||||||
}, 'drawio');
|
}, 'drawio');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the popup link selector and insert a link when finished
|
// Show the popup link selector and insert a link when finished
|
||||||
showLinkSelector() {
|
showLinkSelector() {
|
||||||
const selectionRange = this.#getSelectionRange();
|
const selectionRange = this.editor.input.getSelection();
|
||||||
|
|
||||||
/** @type {EntitySelectorPopup} * */
|
const selector = window.$components.first('entity-selector-popup') as EntitySelectorPopup;
|
||||||
const selector = window.$components.first('entity-selector-popup');
|
const selectionText = this.editor.input.getSelectionText(selectionRange);
|
||||||
const selectionText = this.#getSelectionText(selectionRange);
|
|
||||||
selector.show(entity => {
|
selector.show(entity => {
|
||||||
const selectedText = selectionText || entity.name;
|
const selectedText = selectionText || entity.name;
|
||||||
const newText = `[${selectedText}](${entity.link})`;
|
const newText = `[${selectedText}](${entity.link})`;
|
||||||
@@ -86,7 +92,7 @@ export class Actions {
|
|||||||
const url = this.editor.config.drawioUrl;
|
const url = this.editor.config.drawioUrl;
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
const selectionRange = this.#getSelectionRange();
|
const selectionRange = this.editor.input.getSelection();
|
||||||
|
|
||||||
DrawIO.show(url, () => Promise.resolve(''), async pngData => {
|
DrawIO.show(url, () => Promise.resolve(''), async pngData => {
|
||||||
const data = {
|
const data = {
|
||||||
@@ -96,7 +102,7 @@ export class Actions {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await window.$http.post('/images/drawio', data);
|
const resp = await window.$http.post('/images/drawio', data);
|
||||||
this.#insertDrawing(resp.data, selectionRange);
|
this.#insertDrawing(resp.data as ImageManagerImage, selectionRange);
|
||||||
DrawIO.close();
|
DrawIO.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleDrawingUploadError(err);
|
this.handleDrawingUploadError(err);
|
||||||
@@ -105,20 +111,23 @@ export class Actions {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#insertDrawing(image, originalSelectionRange) {
|
#insertDrawing(image: ImageManagerImage, originalSelectionRange: MarkdownEditorInputSelection) {
|
||||||
const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
|
const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
|
||||||
this.#replaceSelection(newText, newText.length, originalSelectionRange);
|
this.#replaceSelection(newText, newText.length, originalSelectionRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show draw.io if enabled and handle save.
|
// Show draw.io if enabled and handle save.
|
||||||
editDrawing(imgContainer) {
|
editDrawing(imgContainer: HTMLElement) {
|
||||||
const {drawioUrl} = this.editor.config;
|
const {drawioUrl} = this.editor.config;
|
||||||
if (!drawioUrl) {
|
if (!drawioUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectionRange = this.#getSelectionRange();
|
const selectionRange = this.editor.input.getSelection();
|
||||||
const drawingId = imgContainer.getAttribute('drawio-diagram');
|
const drawingId = imgContainer.getAttribute('drawio-diagram') || '';
|
||||||
|
if (!drawingId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
DrawIO.show(drawioUrl, () => DrawIO.load(drawingId), async pngData => {
|
DrawIO.show(drawioUrl, () => DrawIO.load(drawingId), async pngData => {
|
||||||
const data = {
|
const data = {
|
||||||
@@ -128,14 +137,15 @@ export class Actions {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await window.$http.post('/images/drawio', data);
|
const resp = await window.$http.post('/images/drawio', data);
|
||||||
const newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
|
const image = resp.data as ImageManagerImage;
|
||||||
const newContent = this.#getText().split('\n').map(line => {
|
const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
|
||||||
|
const newContent = this.editor.input.getText().split('\n').map(line => {
|
||||||
if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
|
if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
|
||||||
return newText;
|
return newText;
|
||||||
}
|
}
|
||||||
return line;
|
return line;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
this.#setText(newContent, selectionRange);
|
this.editor.input.setText(newContent, selectionRange);
|
||||||
DrawIO.close();
|
DrawIO.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleDrawingUploadError(err);
|
this.handleDrawingUploadError(err);
|
||||||
@@ -144,7 +154,7 @@ export class Actions {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDrawingUploadError(error) {
|
handleDrawingUploadError(error: any): void {
|
||||||
if (error.status === 413) {
|
if (error.status === 413) {
|
||||||
window.$events.emit('error', this.editor.config.text.serverUploadLimit);
|
window.$events.emit('error', this.editor.config.text.serverUploadLimit);
|
||||||
} else {
|
} else {
|
||||||
@@ -162,91 +172,71 @@ export class Actions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to a specified text
|
// Scroll to a specified text
|
||||||
scrollToText(searchText) {
|
scrollToText(searchText: string): void {
|
||||||
if (!searchText) {
|
if (!searchText) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = this.editor.cm.state.doc;
|
const lineRange = this.editor.input.searchForLineContaining(searchText);
|
||||||
let lineCount = 1;
|
if (lineRange) {
|
||||||
let scrollToLine = -1;
|
this.editor.input.setSelection(lineRange, true);
|
||||||
for (const line of text.iterLines()) {
|
this.editor.input.focus();
|
||||||
if (line.includes(searchText)) {
|
|
||||||
scrollToLine = lineCount;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
lineCount += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scrollToLine === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const line = text.line(scrollToLine);
|
|
||||||
this.#setSelection(line.from, line.to, true);
|
|
||||||
this.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
focus() {
|
focus() {
|
||||||
if (!this.editor.cm.hasFocus) {
|
this.editor.input.focus();
|
||||||
this.editor.cm.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert content into the editor.
|
* Insert content into the editor.
|
||||||
* @param {String} content
|
|
||||||
*/
|
*/
|
||||||
insertContent(content) {
|
insertContent(content: string) {
|
||||||
this.#replaceSelection(content, content.length);
|
this.#replaceSelection(content, content.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepend content to the editor.
|
* Prepend content to the editor.
|
||||||
* @param {String} content
|
|
||||||
*/
|
*/
|
||||||
prependContent(content) {
|
prependContent(content: string): void {
|
||||||
content = this.#cleanTextForEditor(content);
|
content = this.#cleanTextForEditor(content);
|
||||||
const selectionRange = this.#getSelectionRange();
|
const selectionRange = this.editor.input.getSelection();
|
||||||
const selectFrom = selectionRange.from + content.length + 1;
|
const selectFrom = selectionRange.from + content.length + 1;
|
||||||
this.#dispatchChange(0, 0, `${content}\n`, selectFrom);
|
this.editor.input.spliceText(0, 0, `${content}\n`, {from: selectFrom});
|
||||||
this.focus();
|
this.editor.input.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append content to the editor.
|
* Append content to the editor.
|
||||||
* @param {String} content
|
|
||||||
*/
|
*/
|
||||||
appendContent(content) {
|
appendContent(content: string): void {
|
||||||
content = this.#cleanTextForEditor(content);
|
content = this.#cleanTextForEditor(content);
|
||||||
this.#dispatchChange(this.editor.cm.state.doc.length, `\n${content}`);
|
this.editor.input.appendText(content);
|
||||||
this.focus();
|
this.editor.input.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the editor's contents
|
* Replace the editor's contents
|
||||||
* @param {String} content
|
|
||||||
*/
|
*/
|
||||||
replaceContent(content) {
|
replaceContent(content: string): void {
|
||||||
this.#setText(content);
|
this.editor.input.setText(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the start of the line
|
* Replace the start of the line
|
||||||
* @param {String} newStart
|
* @param {String} newStart
|
||||||
*/
|
*/
|
||||||
replaceLineStart(newStart) {
|
replaceLineStart(newStart: string): void {
|
||||||
const selectionRange = this.#getSelectionRange();
|
const selectionRange = this.editor.input.getSelection();
|
||||||
const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
|
const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from);
|
||||||
|
const lineContent = this.editor.input.getSelectionText(lineRange);
|
||||||
const lineContent = line.text;
|
|
||||||
const lineStart = lineContent.split(' ')[0];
|
const lineStart = lineContent.split(' ')[0];
|
||||||
|
|
||||||
// Remove symbol if already set
|
// Remove symbol if already set
|
||||||
if (lineStart === newStart) {
|
if (lineStart === newStart) {
|
||||||
const newLineContent = lineContent.replace(`${newStart} `, '');
|
const newLineContent = lineContent.replace(`${newStart} `, '');
|
||||||
const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length);
|
const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length);
|
||||||
this.#dispatchChange(line.from, line.to, newLineContent, selectFrom);
|
this.editor.input.spliceText(lineRange.from, lineRange.to, newLineContent, {from: selectFrom});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,48 +249,46 @@ export class Actions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length);
|
const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length);
|
||||||
this.#dispatchChange(line.from, line.to, newLineContent, selectFrom);
|
this.editor.input.spliceText(lineRange.from, lineRange.to, newLineContent, {from: selectFrom});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap the selection in the given contents start and end contents.
|
* Wrap the selection in the given contents start and end contents.
|
||||||
* @param {String} start
|
|
||||||
* @param {String} end
|
|
||||||
*/
|
*/
|
||||||
wrapSelection(start, end) {
|
wrapSelection(start: string, end: string): void {
|
||||||
const selectRange = this.#getSelectionRange();
|
const selectRange = this.editor.input.getSelection();
|
||||||
const selectionText = this.#getSelectionText(selectRange);
|
const selectionText = this.editor.input.getSelectionText(selectRange);
|
||||||
if (!selectionText) {
|
if (!selectionText) {
|
||||||
this.#wrapLine(start, end);
|
this.#wrapLine(start, end);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newSelectionText = selectionText;
|
let newSelectionText: string;
|
||||||
let newRange;
|
let newRange = {from: selectRange.from, to: selectRange.to};
|
||||||
|
|
||||||
if (selectionText.startsWith(start) && selectionText.endsWith(end)) {
|
if (selectionText.startsWith(start) && selectionText.endsWith(end)) {
|
||||||
newSelectionText = selectionText.slice(start.length, selectionText.length - end.length);
|
newSelectionText = selectionText.slice(start.length, selectionText.length - end.length);
|
||||||
newRange = selectRange.extend(selectRange.from, selectRange.to - (start.length + end.length));
|
newRange.to = selectRange.to - (start.length + end.length);
|
||||||
} else {
|
} else {
|
||||||
newSelectionText = `${start}${selectionText}${end}`;
|
newSelectionText = `${start}${selectionText}${end}`;
|
||||||
newRange = selectRange.extend(selectRange.from, selectRange.to + (start.length + end.length));
|
newRange.to = selectRange.to + (start.length + end.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#dispatchChange(
|
this.editor.input.spliceText(
|
||||||
selectRange.from,
|
selectRange.from,
|
||||||
selectRange.to,
|
selectRange.to,
|
||||||
newSelectionText,
|
newSelectionText,
|
||||||
newRange.anchor,
|
newRange,
|
||||||
newRange.head,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceLineStartForOrderedList() {
|
replaceLineStartForOrderedList() {
|
||||||
const selectionRange = this.#getSelectionRange();
|
const selectionRange = this.editor.input.getSelection();
|
||||||
const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
|
const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from);
|
||||||
const prevLine = this.editor.cm.state.doc.line(line.number - 1);
|
const prevLineRange = this.editor.input.getLineRangeFromPosition(lineRange.from - 1);
|
||||||
|
const prevLineText = this.editor.input.getSelectionText(prevLineRange);
|
||||||
|
|
||||||
const listMatch = prevLine.text.match(/^(\s*)(\d)([).])\s/) || [];
|
const listMatch = prevLineText.match(/^(\s*)(\d)([).])\s/) || [];
|
||||||
|
|
||||||
const number = (Number(listMatch[2]) || 0) + 1;
|
const number = (Number(listMatch[2]) || 0) + 1;
|
||||||
const whiteSpace = listMatch[1] || '';
|
const whiteSpace = listMatch[1] || '';
|
||||||
@@ -315,45 +303,46 @@ export class Actions {
|
|||||||
* Creates a callout block if none existing, and removes it if cycling past the danger type.
|
* Creates a callout block if none existing, and removes it if cycling past the danger type.
|
||||||
*/
|
*/
|
||||||
cycleCalloutTypeAtSelection() {
|
cycleCalloutTypeAtSelection() {
|
||||||
const selectionRange = this.#getSelectionRange();
|
const selectionRange = this.editor.input.getSelection();
|
||||||
const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
|
const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from);
|
||||||
|
const lineText = this.editor.input.getSelectionText(lineRange);
|
||||||
|
|
||||||
const formats = ['info', 'success', 'warning', 'danger'];
|
const formats = ['info', 'success', 'warning', 'danger'];
|
||||||
const joint = formats.join('|');
|
const joint = formats.join('|');
|
||||||
const regex = new RegExp(`class="((${joint})\\s+callout|callout\\s+(${joint}))"`, 'i');
|
const regex = new RegExp(`class="((${joint})\\s+callout|callout\\s+(${joint}))"`, 'i');
|
||||||
const matches = regex.exec(line.text);
|
const matches = regex.exec(lineText);
|
||||||
const format = (matches ? (matches[2] || matches[3]) : '').toLowerCase();
|
const format = (matches ? (matches[2] || matches[3]) : '').toLowerCase();
|
||||||
|
|
||||||
if (format === formats[formats.length - 1]) {
|
if (format === formats[formats.length - 1]) {
|
||||||
this.#wrapLine(`<p class="callout ${formats[formats.length - 1]}">`, '</p>');
|
this.#wrapLine(`<p class="callout ${formats[formats.length - 1]}">`, '</p>');
|
||||||
} else if (format === '') {
|
} else if (format === '') {
|
||||||
this.#wrapLine('<p class="callout info">', '</p>');
|
this.#wrapLine('<p class="callout info">', '</p>');
|
||||||
} else {
|
} else if (matches) {
|
||||||
const newFormatIndex = formats.indexOf(format) + 1;
|
const newFormatIndex = formats.indexOf(format) + 1;
|
||||||
const newFormat = formats[newFormatIndex];
|
const newFormat = formats[newFormatIndex];
|
||||||
const newContent = line.text.replace(matches[0], matches[0].replace(format, newFormat));
|
const newContent = lineText.replace(matches[0], matches[0].replace(format, newFormat));
|
||||||
const lineDiff = newContent.length - line.text.length;
|
const lineDiff = newContent.length - lineText.length;
|
||||||
this.#dispatchChange(
|
const anchor = Math.min(selectionRange.from, selectionRange.to);
|
||||||
line.from,
|
const head = Math.max(selectionRange.from, selectionRange.to);
|
||||||
line.to,
|
this.editor.input.spliceText(
|
||||||
|
lineRange.from,
|
||||||
|
lineRange.to,
|
||||||
newContent,
|
newContent,
|
||||||
selectionRange.anchor + lineDiff,
|
{from: anchor + lineDiff, to: head + lineDiff}
|
||||||
selectionRange.head + lineDiff,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
syncDisplayPosition(event) {
|
syncDisplayPosition(event: Event): void {
|
||||||
// Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
|
// Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
|
||||||
const scrollEl = event.target;
|
const scrollEl = event.target as HTMLElement;
|
||||||
const atEnd = Math.abs(scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop) < 1;
|
const atEnd = Math.abs(scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop) < 1;
|
||||||
if (atEnd) {
|
if (atEnd) {
|
||||||
this.editor.display.scrollToIndex(-1);
|
this.editor.display.scrollToIndex(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockInfo = this.editor.cm.lineBlockAtHeight(scrollEl.scrollTop);
|
const range = this.editor.input.getTextAboveView();
|
||||||
const range = this.editor.cm.state.sliceDoc(0, blockInfo.from);
|
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(this.editor.markdown.render(range), 'text/html');
|
const doc = parser.parseFromString(this.editor.markdown.render(range), 'text/html');
|
||||||
const totalLines = doc.documentElement.querySelectorAll('body > *');
|
const totalLines = doc.documentElement.querySelectorAll('body > *');
|
||||||
@@ -363,54 +352,48 @@ export class Actions {
|
|||||||
/**
|
/**
|
||||||
* Fetch and insert the template of the given ID.
|
* Fetch and insert the template of the given ID.
|
||||||
* The page-relative position provided can be used to determine insert location if possible.
|
* The page-relative position provided can be used to determine insert location if possible.
|
||||||
* @param {String} templateId
|
|
||||||
* @param {Number} posX
|
|
||||||
* @param {Number} posY
|
|
||||||
*/
|
*/
|
||||||
async insertTemplate(templateId, posX, posY) {
|
async insertTemplate(templateId: string, event: MouseEvent): Promise<void> {
|
||||||
const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false);
|
const cursorPos = this.editor.input.eventToPosition(event).from;
|
||||||
const {data} = await window.$http.get(`/templates/${templateId}`);
|
const responseData = (await window.$http.get(`/templates/${templateId}`)).data as {markdown: string, html: string};
|
||||||
const content = data.markdown || data.html;
|
const content = responseData.markdown || responseData.html;
|
||||||
this.#dispatchChange(cursorPos, cursorPos, content, cursorPos);
|
this.editor.input.spliceText(cursorPos, cursorPos, content, {from: cursorPos});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert multiple images from the clipboard from an event at the provided
|
* Insert multiple images from the clipboard from an event at the provided
|
||||||
* screen coordinates (Typically form a paste event).
|
* screen coordinates (Typically form a paste event).
|
||||||
* @param {File[]} images
|
|
||||||
* @param {Number} posX
|
|
||||||
* @param {Number} posY
|
|
||||||
*/
|
*/
|
||||||
insertClipboardImages(images, posX, posY) {
|
insertClipboardImages(images: File[], event: MouseEvent): void {
|
||||||
const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false);
|
const cursorPos = this.editor.input.eventToPosition(event).from;
|
||||||
for (const image of images) {
|
for (const image of images) {
|
||||||
this.uploadImage(image, cursorPos);
|
this.uploadImage(image, cursorPos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle image upload and add image into markdown content
|
* Handle image upload and add image into Markdown content
|
||||||
* @param {File} file
|
|
||||||
* @param {?Number} position
|
|
||||||
*/
|
*/
|
||||||
async uploadImage(file, position = null) {
|
async uploadImage(file: File, position: number|null = null): Promise<void> {
|
||||||
if (file === null || file.type.indexOf('image') !== 0) return;
|
if (file === null || file.type.indexOf('image') !== 0) return;
|
||||||
let ext = 'png';
|
let ext = 'png';
|
||||||
|
|
||||||
if (position === null) {
|
if (position === null) {
|
||||||
position = this.#getSelectionRange().from;
|
position = this.editor.input.getSelection().from;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.name) {
|
if (file.name) {
|
||||||
const fileNameMatches = file.name.match(/\.(.+)$/);
|
const fileNameMatches = file.name.match(/\.(.+)$/);
|
||||||
if (fileNameMatches.length > 1) ext = fileNameMatches[1];
|
if (fileNameMatches && fileNameMatches.length > 1) {
|
||||||
|
ext = fileNameMatches[1];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert image into markdown
|
// Insert image into markdown
|
||||||
const id = `image-${Math.random().toString(16).slice(2)}`;
|
const id = `image-${Math.random().toString(16).slice(2)}`;
|
||||||
const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
|
const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
|
||||||
const placeHolderText = ``;
|
const placeHolderText = ``;
|
||||||
this.#dispatchChange(position, position, placeHolderText, position);
|
this.editor.input.spliceText(position, position, placeHolderText, {from: position});
|
||||||
|
|
||||||
const remoteFilename = `image-${Date.now()}.${ext}`;
|
const remoteFilename = `image-${Date.now()}.${ext}`;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -418,105 +401,53 @@ export class Actions {
|
|||||||
formData.append('uploaded_to', this.editor.config.pageId);
|
formData.append('uploaded_to', this.editor.config.pageId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {data} = await window.$http.post('/images/gallery', formData);
|
const image = (await window.$http.post('/images/gallery', formData)).data as ImageManagerImage;
|
||||||
const newContent = `[](${data.url})`;
|
const newContent = `[](${image.url})`;
|
||||||
this.#findAndReplaceContent(placeHolderText, newContent);
|
this.#findAndReplaceContent(placeHolderText, newContent);
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
window.$events.error(err?.data?.message || this.editor.config.text.imageUploadError);
|
window.$events.error(err?.data?.message || this.editor.config.text.imageUploadError);
|
||||||
this.#findAndReplaceContent(placeHolderText, '');
|
this.#findAndReplaceContent(placeHolderText, '');
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current text of the editor instance.
|
|
||||||
* @return {string}
|
|
||||||
*/
|
|
||||||
#getText() {
|
|
||||||
return this.editor.cm.state.doc.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the text of the current editor instance.
|
|
||||||
* @param {String} text
|
|
||||||
* @param {?SelectionRange} selectionRange
|
|
||||||
*/
|
|
||||||
#setText(text, selectionRange = null) {
|
|
||||||
selectionRange = selectionRange || this.#getSelectionRange();
|
|
||||||
const newDoc = this.editor.cm.state.toText(text);
|
|
||||||
const newSelectFrom = Math.min(selectionRange.from, newDoc.length);
|
|
||||||
const scrollTop = this.editor.cm.scrollDOM.scrollTop;
|
|
||||||
this.#dispatchChange(0, this.editor.cm.state.doc.length, text, newSelectFrom);
|
|
||||||
this.focus();
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
this.editor.cm.scrollDOM.scrollTop = scrollTop;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the current selection and focus the editor.
|
* Replace the current selection and focus the editor.
|
||||||
* Takes an offset for the cursor, after the change, relative to the start of the provided string.
|
* Takes an offset for the cursor, after the change, relative to the start of the provided string.
|
||||||
* Can be provided a selection range to use instead of the current selection range.
|
* Can be provided a selection range to use instead of the current selection range.
|
||||||
* @param {String} newContent
|
|
||||||
* @param {Number} cursorOffset
|
|
||||||
* @param {?SelectionRange} selectionRange
|
|
||||||
*/
|
*/
|
||||||
#replaceSelection(newContent, cursorOffset = 0, selectionRange = null) {
|
#replaceSelection(newContent: string, offset: number = 0, selection: MarkdownEditorInputSelection|null = null) {
|
||||||
selectionRange = selectionRange || this.editor.cm.state.selection.main;
|
selection = selection || this.editor.input.getSelection();
|
||||||
const selectFrom = selectionRange.from + cursorOffset;
|
const selectFrom = selection.from + offset;
|
||||||
this.#dispatchChange(selectionRange.from, selectionRange.to, newContent, selectFrom);
|
this.editor.input.spliceText(selection.from, selection.to, newContent, {from: selectFrom, to: selectFrom});
|
||||||
this.focus();
|
this.editor.input.focus();
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the text content of the main current selection.
|
|
||||||
* @param {SelectionRange} selectionRange
|
|
||||||
* @return {string}
|
|
||||||
*/
|
|
||||||
#getSelectionText(selectionRange = null) {
|
|
||||||
selectionRange = selectionRange || this.#getSelectionRange();
|
|
||||||
return this.editor.cm.state.sliceDoc(selectionRange.from, selectionRange.to);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the range of the current main selection.
|
|
||||||
* @return {SelectionRange}
|
|
||||||
*/
|
|
||||||
#getSelectionRange() {
|
|
||||||
return this.editor.cm.state.selection.main;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleans the given text to work with the editor.
|
* Cleans the given text to work with the editor.
|
||||||
* Standardises line endings to what's expected.
|
* Standardises line endings to what's expected.
|
||||||
* @param {String} text
|
|
||||||
* @return {String}
|
|
||||||
*/
|
*/
|
||||||
#cleanTextForEditor(text) {
|
#cleanTextForEditor(text: string): string {
|
||||||
return text.replace(/\r\n|\r/g, '\n');
|
return text.replace(/\r\n|\r/g, '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find and replace the first occurrence of [search] with [replace]
|
* Find and replace the first occurrence of [search] with [replace]
|
||||||
* @param {String} search
|
|
||||||
* @param {String} replace
|
|
||||||
*/
|
*/
|
||||||
#findAndReplaceContent(search, replace) {
|
#findAndReplaceContent(search: string, replace: string): void {
|
||||||
const newText = this.#getText().replace(search, replace);
|
const newText = this.editor.input.getText().replace(search, replace);
|
||||||
this.#setText(newText);
|
this.editor.input.setText(newText);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap the line in the given start and end contents.
|
* Wrap the line in the given start and end contents.
|
||||||
* @param {String} start
|
|
||||||
* @param {String} end
|
|
||||||
*/
|
*/
|
||||||
#wrapLine(start, end) {
|
#wrapLine(start: string, end: string): void {
|
||||||
const selectionRange = this.#getSelectionRange();
|
const selectionRange = this.editor.input.getSelection();
|
||||||
const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
|
const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from);
|
||||||
const lineContent = line.text;
|
const lineContent = this.editor.input.getSelectionText(lineRange);
|
||||||
let newLineContent;
|
let newLineContent: string;
|
||||||
let lineOffset = 0;
|
let lineOffset: number;
|
||||||
|
|
||||||
if (lineContent.startsWith(start) && lineContent.endsWith(end)) {
|
if (lineContent.startsWith(start) && lineContent.endsWith(end)) {
|
||||||
newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
|
newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
|
||||||
@@ -526,42 +457,7 @@ export class Actions {
|
|||||||
lineOffset = start.length;
|
lineOffset = start.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#dispatchChange(line.from, line.to, newLineContent, selectionRange.from + lineOffset);
|
this.editor.input.spliceText(lineRange.from, lineRange.to, newLineContent, {from: selectionRange.from + lineOffset});
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispatch changes to the editor.
|
|
||||||
* @param {Number} from
|
|
||||||
* @param {?Number} to
|
|
||||||
* @param {?String} text
|
|
||||||
* @param {?Number} selectFrom
|
|
||||||
* @param {?Number} selectTo
|
|
||||||
*/
|
|
||||||
#dispatchChange(from, to = null, text = null, selectFrom = null, selectTo = null) {
|
|
||||||
const tr = {changes: {from, to, insert: text}};
|
|
||||||
|
|
||||||
if (selectFrom) {
|
|
||||||
tr.selection = {anchor: selectFrom};
|
|
||||||
if (selectTo) {
|
|
||||||
tr.selection.head = selectTo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.editor.cm.dispatch(tr);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the current selection range.
|
|
||||||
* Optionally will scroll the new range into view.
|
|
||||||
* @param {Number} from
|
|
||||||
* @param {Number} to
|
|
||||||
* @param {Boolean} scrollIntoView
|
|
||||||
*/
|
|
||||||
#setSelection(from, to, scrollIntoView = false) {
|
|
||||||
this.editor.cm.dispatch({
|
|
||||||
selection: {anchor: from, head: to},
|
|
||||||
scrollIntoView,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
54
resources/js/markdown/codemirror.ts
Normal file
54
resources/js/markdown/codemirror.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {EditorView, KeyBinding, ViewUpdate} from "@codemirror/view";
|
||||||
|
import {CodeModule} from "../global";
|
||||||
|
import {MarkdownEditorEventMap} from "./dom-handlers";
|
||||||
|
import {MarkdownEditorShortcutMap} from "./shortcuts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert editor shortcuts to CodeMirror keybinding format.
|
||||||
|
*/
|
||||||
|
export function shortcutsToKeyBindings(shortcuts: MarkdownEditorShortcutMap): KeyBinding[] {
|
||||||
|
const keyBindings = [];
|
||||||
|
|
||||||
|
const wrapAction = (action: () => void) => () => {
|
||||||
|
action();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [shortcut, action] of Object.entries(shortcuts)) {
|
||||||
|
keyBindings.push({key: shortcut, run: wrapAction(action), preventDefault: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate the codemirror instance for the Markdown editor.
|
||||||
|
*/
|
||||||
|
export async function init(
|
||||||
|
input: HTMLTextAreaElement,
|
||||||
|
shortcuts: MarkdownEditorShortcutMap,
|
||||||
|
domEventHandlers: MarkdownEditorEventMap,
|
||||||
|
onChange: () => void
|
||||||
|
): Promise<EditorView> {
|
||||||
|
const Code = await window.importVersioned('code') as CodeModule;
|
||||||
|
|
||||||
|
function onViewUpdate(v: ViewUpdate) {
|
||||||
|
if (v.docChanged) {
|
||||||
|
onChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cm = Code.markdownEditor(
|
||||||
|
input,
|
||||||
|
onViewUpdate,
|
||||||
|
domEventHandlers,
|
||||||
|
shortcutsToKeyBindings(shortcuts),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add editor view to the window for easy access/debugging.
|
||||||
|
// Not part of official API/Docs
|
||||||
|
// @ts-ignore
|
||||||
|
window.mdEditorView = cm;
|
||||||
|
|
||||||
|
return cm;
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
function getContentToInsert({html, markdown}) {
|
|
||||||
return markdown || html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {MarkdownEditor} editor
|
|
||||||
*/
|
|
||||||
export function listen(editor) {
|
|
||||||
window.$events.listen('editor::replace', eventContent => {
|
|
||||||
const markdown = getContentToInsert(eventContent);
|
|
||||||
editor.actions.replaceContent(markdown);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.$events.listen('editor::append', eventContent => {
|
|
||||||
const markdown = getContentToInsert(eventContent);
|
|
||||||
editor.actions.appendContent(markdown);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.$events.listen('editor::prepend', eventContent => {
|
|
||||||
const markdown = getContentToInsert(eventContent);
|
|
||||||
editor.actions.prependContent(markdown);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.$events.listen('editor::insert', eventContent => {
|
|
||||||
const markdown = getContentToInsert(eventContent);
|
|
||||||
editor.actions.insertContent(markdown);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.$events.listen('editor::focus', () => {
|
|
||||||
editor.actions.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
36
resources/js/markdown/common-events.ts
Normal file
36
resources/js/markdown/common-events.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {MarkdownEditor} from "./index.mjs";
|
||||||
|
|
||||||
|
export interface HtmlOrMarkdown {
|
||||||
|
html: string;
|
||||||
|
markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContentToInsert({html, markdown}: {html: string, markdown: string}): string {
|
||||||
|
return markdown || html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listenToCommonEvents(editor: MarkdownEditor): void {
|
||||||
|
window.$events.listen('editor::replace', (eventContent: HtmlOrMarkdown) => {
|
||||||
|
const markdown = getContentToInsert(eventContent);
|
||||||
|
editor.actions.replaceContent(markdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.$events.listen('editor::append', (eventContent: HtmlOrMarkdown) => {
|
||||||
|
const markdown = getContentToInsert(eventContent);
|
||||||
|
editor.actions.appendContent(markdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.$events.listen('editor::prepend', (eventContent: HtmlOrMarkdown) => {
|
||||||
|
const markdown = getContentToInsert(eventContent);
|
||||||
|
editor.actions.prependContent(markdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.$events.listen('editor::insert', (eventContent: HtmlOrMarkdown) => {
|
||||||
|
const markdown = getContentToInsert(eventContent);
|
||||||
|
editor.actions.insertContent(markdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.$events.listen('editor::focus', () => {
|
||||||
|
editor.actions.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,35 +1,36 @@
|
|||||||
import {patchDomFromHtmlString} from '../services/vdom.ts';
|
import { patchDomFromHtmlString } from '../services/vdom';
|
||||||
|
import {MarkdownEditor} from "./index.mjs";
|
||||||
|
|
||||||
export class Display {
|
export class Display {
|
||||||
|
protected editor: MarkdownEditor;
|
||||||
|
protected container: HTMLIFrameElement;
|
||||||
|
protected doc: Document | null = null;
|
||||||
|
protected lastDisplayClick: number = 0;
|
||||||
|
|
||||||
/**
|
constructor(editor: MarkdownEditor) {
|
||||||
* @param {MarkdownEditor} editor
|
|
||||||
*/
|
|
||||||
constructor(editor) {
|
|
||||||
this.editor = editor;
|
this.editor = editor;
|
||||||
this.container = editor.config.displayEl;
|
this.container = editor.config.displayEl;
|
||||||
|
|
||||||
this.doc = null;
|
if (this.container.contentDocument?.readyState === 'complete') {
|
||||||
this.lastDisplayClick = 0;
|
|
||||||
|
|
||||||
if (this.container.contentDocument.readyState === 'complete') {
|
|
||||||
this.onLoad();
|
this.onLoad();
|
||||||
} else {
|
} else {
|
||||||
this.container.addEventListener('load', this.onLoad.bind(this));
|
this.container.addEventListener('load', this.onLoad.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateVisibility(editor.settings.get('showPreview'));
|
this.updateVisibility(Boolean(editor.settings.get('showPreview')));
|
||||||
editor.settings.onChange('showPreview', show => this.updateVisibility(show));
|
editor.settings.onChange('showPreview', (show) => this.updateVisibility(Boolean(show)));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateVisibility(show) {
|
protected updateVisibility(show: boolean): void {
|
||||||
const wrap = this.container.closest('.markdown-editor-wrap');
|
const wrap = this.container.closest('.markdown-editor-wrap') as HTMLElement;
|
||||||
wrap.style.display = show ? null : 'none';
|
wrap.style.display = show ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad() {
|
protected onLoad(): void {
|
||||||
this.doc = this.container.contentDocument;
|
this.doc = this.container.contentDocument;
|
||||||
|
|
||||||
|
if (!this.doc) return;
|
||||||
|
|
||||||
this.loadStylesIntoDisplay();
|
this.loadStylesIntoDisplay();
|
||||||
this.doc.body.className = 'page-content';
|
this.doc.body.className = 'page-content';
|
||||||
|
|
||||||
@@ -37,20 +38,20 @@ export class Display {
|
|||||||
this.doc.addEventListener('click', this.onDisplayClick.bind(this));
|
this.doc.addEventListener('click', this.onDisplayClick.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected onDisplayClick(event: MouseEvent): void {
|
||||||
* @param {MouseEvent} event
|
|
||||||
*/
|
|
||||||
onDisplayClick(event) {
|
|
||||||
const isDblClick = Date.now() - this.lastDisplayClick < 300;
|
const isDblClick = Date.now() - this.lastDisplayClick < 300;
|
||||||
|
|
||||||
const link = event.target.closest('a');
|
const link = (event.target as Element).closest('a');
|
||||||
if (link !== null) {
|
if (link !== null) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
window.open(link.getAttribute('href'));
|
const href = link.getAttribute('href');
|
||||||
|
if (href) {
|
||||||
|
window.open(href);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawing = event.target.closest('[drawio-diagram]');
|
const drawing = (event.target as Element).closest('[drawio-diagram]') as HTMLElement;
|
||||||
if (drawing !== null && isDblClick) {
|
if (drawing !== null && isDblClick) {
|
||||||
this.editor.actions.editDrawing(drawing);
|
this.editor.actions.editDrawing(drawing);
|
||||||
return;
|
return;
|
||||||
@@ -59,10 +60,12 @@ export class Display {
|
|||||||
this.lastDisplayClick = Date.now();
|
this.lastDisplayClick = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadStylesIntoDisplay() {
|
protected loadStylesIntoDisplay(): void {
|
||||||
|
if (!this.doc) return;
|
||||||
|
|
||||||
this.doc.documentElement.classList.add('markdown-editor-display');
|
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')) {
|
if (document.documentElement.classList.contains('dark-mode')) {
|
||||||
this.doc.documentElement.style.backgroundColor = '#222';
|
this.doc.documentElement.style.backgroundColor = '#222';
|
||||||
this.doc.documentElement.classList.add('dark-mode');
|
this.doc.documentElement.classList.add('dark-mode');
|
||||||
@@ -71,24 +74,25 @@ export class Display {
|
|||||||
this.doc.head.innerHTML = '';
|
this.doc.head.innerHTML = '';
|
||||||
const styles = document.head.querySelectorAll('style,link[rel=stylesheet]');
|
const styles = document.head.querySelectorAll('style,link[rel=stylesheet]');
|
||||||
for (const style of styles) {
|
for (const style of styles) {
|
||||||
const copy = style.cloneNode(true);
|
const copy = style.cloneNode(true) as HTMLElement;
|
||||||
this.doc.head.appendChild(copy);
|
this.doc.head.appendChild(copy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Patch the display DOM with the given HTML content.
|
* Patch the display DOM with the given HTML content.
|
||||||
* @param {String} html
|
|
||||||
*/
|
*/
|
||||||
patchWithHtml(html) {
|
public patchWithHtml(html: string): void {
|
||||||
const {body} = this.doc;
|
if (!this.doc) return;
|
||||||
|
|
||||||
|
const { body } = this.doc;
|
||||||
|
|
||||||
if (body.children.length === 0) {
|
if (body.children.length === 0) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
this.doc.body.append(wrap);
|
this.doc.body.append(wrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = body.children[0];
|
const target = body.children[0] as HTMLElement;
|
||||||
|
|
||||||
patchDomFromHtmlString(target, html);
|
patchDomFromHtmlString(target, html);
|
||||||
}
|
}
|
||||||
@@ -96,14 +100,16 @@ export class Display {
|
|||||||
/**
|
/**
|
||||||
* Scroll to the given block index within the display content.
|
* Scroll to the given block index within the display content.
|
||||||
* Will scroll to the end if the index is -1.
|
* Will scroll to the end if the index is -1.
|
||||||
* @param {Number} index
|
|
||||||
*/
|
*/
|
||||||
scrollToIndex(index) {
|
public scrollToIndex(index: number): void {
|
||||||
const elems = this.doc.body?.children[0]?.children;
|
const elems = this.doc?.body?.children[0]?.children;
|
||||||
if (elems && elems.length <= index) return;
|
if (!elems || elems.length <= index) return;
|
||||||
|
|
||||||
const topElem = (index === -1) ? elems[elems.length - 1] : elems[index];
|
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'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
@@ -1,23 +1,11 @@
|
|||||||
import {provideKeyBindings} from './shortcuts';
|
import {Clipboard} from "../services/clipboard";
|
||||||
import {debounce} from '../services/util.ts';
|
import {MarkdownEditor} from "./index.mjs";
|
||||||
import {Clipboard} from '../services/clipboard.ts';
|
import {debounce} from "../services/util";
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiate the codemirror instance for the markdown editor.
|
|
||||||
* @param {MarkdownEditor} editor
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
export async function init(editor) {
|
|
||||||
const Code = await window.importVersioned('code');
|
|
||||||
|
|
||||||
/**
|
export type MarkdownEditorEventMap = Record<string, (event: any) => void>;
|
||||||
* @param {ViewUpdate} v
|
|
||||||
*/
|
export function getMarkdownDomEventHandlers(editor: MarkdownEditor): MarkdownEditorEventMap {
|
||||||
function onViewUpdate(v) {
|
|
||||||
if (v.docChanged) {
|
|
||||||
editor.actions.updateAndRender();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onScrollDebounced = debounce(editor.actions.syncDisplayPosition.bind(editor.actions), 100, false);
|
const onScrollDebounced = debounce(editor.actions.syncDisplayPosition.bind(editor.actions), 100, false);
|
||||||
let syncActive = editor.settings.get('scrollSync');
|
let syncActive = editor.settings.get('scrollSync');
|
||||||
@@ -25,15 +13,19 @@ export async function init(editor) {
|
|||||||
syncActive = val;
|
syncActive = val;
|
||||||
});
|
});
|
||||||
|
|
||||||
const domEventHandlers = {
|
return {
|
||||||
// Handle scroll to sync display view
|
// Handle scroll to sync display view
|
||||||
scroll: event => syncActive && onScrollDebounced(event),
|
scroll: (event: Event) => syncActive && onScrollDebounced(event),
|
||||||
// Handle image & content drag n drop
|
// Handle image & content drag n drop
|
||||||
drop: event => {
|
drop: (event: DragEvent) => {
|
||||||
|
if (!event.dataTransfer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const templateId = event.dataTransfer.getData('bookstack/template');
|
const templateId = event.dataTransfer.getData('bookstack/template');
|
||||||
if (templateId) {
|
if (templateId) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
editor.actions.insertTemplate(templateId, event.pageX, event.pageY);
|
editor.actions.insertTemplate(templateId, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
const clipboard = new Clipboard(event.dataTransfer);
|
const clipboard = new Clipboard(event.dataTransfer);
|
||||||
@@ -41,16 +33,20 @@ export async function init(editor) {
|
|||||||
if (clipboardImages.length > 0) {
|
if (clipboardImages.length > 0) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
editor.actions.insertClipboardImages(clipboardImages, event.pageX, event.pageY);
|
editor.actions.insertClipboardImages(clipboardImages, event);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Handle dragover event to allow as drop-target in chrome
|
// Handle dragover event to allow as drop-target in chrome
|
||||||
dragover: event => {
|
dragover: (event: DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
},
|
},
|
||||||
// Handle image paste
|
// Handle image paste
|
||||||
paste: event => {
|
paste: (event: ClipboardEvent) => {
|
||||||
const clipboard = new Clipboard(event.clipboardData || event.dataTransfer);
|
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
|
// Don't handle the event ourselves if no items exist of contains table-looking data
|
||||||
if (!clipboard.hasItems() || clipboard.containsTabularData()) {
|
if (!clipboard.hasItems() || clipboard.containsTabularData()) {
|
||||||
@@ -63,17 +59,4 @@ export async function init(editor) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
const cm = Code.markdownEditor(
|
|
||||||
editor.config.inputEl,
|
|
||||||
onViewUpdate,
|
|
||||||
domEventHandlers,
|
|
||||||
provideKeyBindings(editor),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add editor view to window for easy access/debugging.
|
|
||||||
// Not part of official API/Docs
|
|
||||||
window.mdEditorView = cm;
|
|
||||||
|
|
||||||
return cm;
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import {Markdown} from './markdown';
|
|
||||||
import {Display} from './display';
|
|
||||||
import {Actions} from './actions';
|
|
||||||
import {Settings} from './settings';
|
|
||||||
import {listen} from './common-events';
|
|
||||||
import {init as initCodemirror} from './codemirror';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiate a new markdown editor instance.
|
|
||||||
* @param {MarkdownEditorConfig} config
|
|
||||||
* @returns {Promise<MarkdownEditor>}
|
|
||||||
*/
|
|
||||||
export async function init(config) {
|
|
||||||
/**
|
|
||||||
* @type {MarkdownEditor}
|
|
||||||
*/
|
|
||||||
const editor = {
|
|
||||||
config,
|
|
||||||
markdown: new Markdown(),
|
|
||||||
settings: new Settings(config.settingInputs),
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.actions = new Actions(editor);
|
|
||||||
editor.display = new Display(editor);
|
|
||||||
editor.cm = await initCodemirror(editor);
|
|
||||||
|
|
||||||
listen(editor);
|
|
||||||
|
|
||||||
return editor;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef MarkdownEditorConfig
|
|
||||||
* @property {String} pageId
|
|
||||||
* @property {Element} container
|
|
||||||
* @property {Element} displayEl
|
|
||||||
* @property {HTMLTextAreaElement} inputEl
|
|
||||||
* @property {String} drawioUrl
|
|
||||||
* @property {HTMLInputElement[]} settingInputs
|
|
||||||
* @property {Object<String, String>} text
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef MarkdownEditor
|
|
||||||
* @property {MarkdownEditorConfig} config
|
|
||||||
* @property {Display} display
|
|
||||||
* @property {Markdown} markdown
|
|
||||||
* @property {Actions} actions
|
|
||||||
* @property {EditorView} cm
|
|
||||||
* @property {Settings} settings
|
|
||||||
*/
|
|
||||||
71
resources/js/markdown/index.mts
Normal file
71
resources/js/markdown/index.mts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import {Markdown} from './markdown';
|
||||||
|
import {Display} from './display';
|
||||||
|
import {Actions} from './actions';
|
||||||
|
import {Settings} from './settings';
|
||||||
|
import {listenToCommonEvents} from './common-events';
|
||||||
|
import {init as initCodemirror} from './codemirror';
|
||||||
|
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;
|
||||||
|
container: Element;
|
||||||
|
displayEl: HTMLIFrameElement;
|
||||||
|
inputEl: HTMLTextAreaElement;
|
||||||
|
drawioUrl: string;
|
||||||
|
settingInputs: HTMLInputElement[];
|
||||||
|
text: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownEditor {
|
||||||
|
config: MarkdownEditorConfig;
|
||||||
|
display: Display;
|
||||||
|
markdown: Markdown;
|
||||||
|
actions: Actions;
|
||||||
|
input: MarkdownEditorInput;
|
||||||
|
settings: Settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate a new Markdown editor instance.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
|
||||||
|
const eventHandlers = getMarkdownDomEventHandlers(editor);
|
||||||
|
const shortcuts = provideShortcutMap(editor);
|
||||||
|
const onInputChange = () => editor.actions.updateAndRender();
|
||||||
|
|
||||||
|
const initCodemirrorInput: () => Promise<MarkdownEditorInput> = async () => {
|
||||||
|
const codeMirror = await initCodemirror(config.inputEl, shortcuts, eventHandlers, onInputChange);
|
||||||
|
return new CodemirrorInput(codeMirror);
|
||||||
|
};
|
||||||
|
const initTextAreaInput: () => Promise<MarkdownEditorInput> = async () => {
|
||||||
|
return new TextareaInput(config.inputEl, shortcuts, eventHandlers, onInputChange);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPlainEditor = Boolean(editor.settings.get('plainEditor'));
|
||||||
|
editor.input = await (isPlainEditor ? initTextAreaInput() : initCodemirrorInput());
|
||||||
|
editor.settings.onChange('plainEditor', async (value) => {
|
||||||
|
const isPlain = Boolean(value);
|
||||||
|
const newInput = await (isPlain ? initTextAreaInput() : initCodemirrorInput());
|
||||||
|
editor.input.teardown();
|
||||||
|
editor.input = newInput;
|
||||||
|
});
|
||||||
|
|
||||||
|
listenToCommonEvents(editor);
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
128
resources/js/markdown/inputs/codemirror.ts
Normal file
128
resources/js/markdown/inputs/codemirror.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface";
|
||||||
|
import {EditorView} from "@codemirror/view";
|
||||||
|
import {ChangeSpec, TransactionSpec} from "@codemirror/state";
|
||||||
|
|
||||||
|
|
||||||
|
export class CodemirrorInput implements MarkdownEditorInput {
|
||||||
|
protected cm: EditorView;
|
||||||
|
|
||||||
|
constructor(cm: EditorView) {
|
||||||
|
this.cm = cm;
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown(): void {
|
||||||
|
this.cm.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(): void {
|
||||||
|
if (!this.cm.hasFocus) {
|
||||||
|
this.cm.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelection(): MarkdownEditorInputSelection {
|
||||||
|
return this.cm.state.selection.main;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectionText(selection?: MarkdownEditorInputSelection): string {
|
||||||
|
selection = selection || this.getSelection();
|
||||||
|
return this.cm.state.sliceDoc(selection.from, selection.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean = false) {
|
||||||
|
this.cm.dispatch({
|
||||||
|
selection: {anchor: selection.from, head: selection.to},
|
||||||
|
scrollIntoView,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getText(): string {
|
||||||
|
return this.cm.state.doc.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
getTextAboveView(): string {
|
||||||
|
const blockInfo = this.cm.lineBlockAtHeight(this.cm.scrollDOM.scrollTop);
|
||||||
|
return this.cm.state.sliceDoc(0, blockInfo.from);
|
||||||
|
}
|
||||||
|
|
||||||
|
setText(text: string, selection?: MarkdownEditorInputSelection) {
|
||||||
|
selection = selection || this.getSelection();
|
||||||
|
const newDoc = this.cm.state.toText(text);
|
||||||
|
const newSelectFrom = Math.min(selection.from, newDoc.length);
|
||||||
|
const scrollTop = this.cm.scrollDOM.scrollTop;
|
||||||
|
this.dispatchChange(0, this.cm.state.doc.length, text, newSelectFrom);
|
||||||
|
this.focus();
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
this.cm.scrollDOM.scrollTop = scrollTop;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
spliceText(from: number, to: number, newText: string, selection: Partial<MarkdownEditorInputSelection> | null = null) {
|
||||||
|
const end = (selection?.from === selection?.to) ? null : selection?.to;
|
||||||
|
this.dispatchChange(from, to, newText, selection?.from, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendText(text: string) {
|
||||||
|
const end = this.cm.state.doc.length;
|
||||||
|
this.dispatchChange(end, end, `\n${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLineText(lineIndex: number = -1): string {
|
||||||
|
const index = lineIndex > -1 ? lineIndex : this.getSelection().from;
|
||||||
|
return this.cm.state.doc.lineAt(index).text;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
|
||||||
|
const cursorPos = this.cm.posAtCoords({x: event.screenX, y: event.screenY}, false);
|
||||||
|
return {from: cursorPos, to: cursorPos};
|
||||||
|
}
|
||||||
|
|
||||||
|
getLineRangeFromPosition(position: number): MarkdownEditorInputSelection {
|
||||||
|
const line = this.cm.state.doc.lineAt(position);
|
||||||
|
return {from: line.from, to: line.to};
|
||||||
|
}
|
||||||
|
|
||||||
|
searchForLineContaining(text: string): MarkdownEditorInputSelection | null {
|
||||||
|
const docText = this.cm.state.doc;
|
||||||
|
let lineCount = 1;
|
||||||
|
let scrollToLine = -1;
|
||||||
|
for (const line of docText.iterLines()) {
|
||||||
|
if (line.includes(text)) {
|
||||||
|
scrollToLine = lineCount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lineCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollToLine === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = docText.line(scrollToLine);
|
||||||
|
return {from: line.from, to: line.to};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch changes to the editor.
|
||||||
|
*/
|
||||||
|
protected dispatchChange(from: number, to: number|null = null, text: string|null = null, selectFrom: number|null = null, selectTo: number|null = null): void {
|
||||||
|
const change: ChangeSpec = {from};
|
||||||
|
if (to) {
|
||||||
|
change.to = to;
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
change.insert = text;
|
||||||
|
}
|
||||||
|
const tr: TransactionSpec = {changes: change};
|
||||||
|
|
||||||
|
if (selectFrom) {
|
||||||
|
tr.selection = {anchor: selectFrom};
|
||||||
|
if (selectTo) {
|
||||||
|
tr.selection.head = selectTo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cm.dispatch(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
81
resources/js/markdown/inputs/interface.ts
Normal file
81
resources/js/markdown/inputs/interface.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
|
||||||
|
export interface MarkdownEditorInputSelection {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownEditorInput {
|
||||||
|
/**
|
||||||
|
* Focus on the editor.
|
||||||
|
*/
|
||||||
|
focus(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current selection range.
|
||||||
|
*/
|
||||||
|
getSelection(): MarkdownEditorInputSelection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the text of the given (or current) selection range.
|
||||||
|
*/
|
||||||
|
getSelectionText(selection?: MarkdownEditorInputSelection): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the selection range of the editor.
|
||||||
|
*/
|
||||||
|
setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full text of the input.
|
||||||
|
*/
|
||||||
|
getText(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get just the text which is above (out) the current view range.
|
||||||
|
* This is used for position estimation.
|
||||||
|
*/
|
||||||
|
getTextAboveView(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the full text of the input.
|
||||||
|
* Optionally can provide a selection to restore after setting text.
|
||||||
|
*/
|
||||||
|
setText(text: string, selection?: MarkdownEditorInputSelection): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splice in/out text within the input.
|
||||||
|
* Optionally can provide a selection to restore after setting text.
|
||||||
|
*/
|
||||||
|
spliceText(from: number, to: number, newText: string, selection: Partial<MarkdownEditorInputSelection>|null): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append text to the end of the editor.
|
||||||
|
*/
|
||||||
|
appendText(text: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the text of the given line number otherwise the text
|
||||||
|
* of the current selected line.
|
||||||
|
*/
|
||||||
|
getLineText(lineIndex:number): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a selection representing the line range from the given position.
|
||||||
|
*/
|
||||||
|
getLineRangeFromPosition(position: number): MarkdownEditorInputSelection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the given event position to a selection position within the input.
|
||||||
|
*/
|
||||||
|
eventToPosition(event: MouseEvent): MarkdownEditorInputSelection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search and return a line range which includes the provided text.
|
||||||
|
*/
|
||||||
|
searchForLineContaining(text: string): MarkdownEditorInputSelection|null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tear down the input.
|
||||||
|
*/
|
||||||
|
teardown(): void;
|
||||||
|
}
|
||||||
315
resources/js/markdown/inputs/textarea.ts
Normal file
315
resources/js/markdown/inputs/textarea.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface";
|
||||||
|
import {MarkdownEditorShortcutMap} from "../shortcuts";
|
||||||
|
import {MarkdownEditorEventMap} from "../dom-handlers";
|
||||||
|
import {debounce} from "../../services/util";
|
||||||
|
|
||||||
|
type UndoStackEntry = {
|
||||||
|
content: string;
|
||||||
|
selection: MarkdownEditorInputSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UndoStack {
|
||||||
|
protected onChangeDebounced: (callback: () => UndoStackEntry) => void;
|
||||||
|
|
||||||
|
protected stack: UndoStackEntry[] = [];
|
||||||
|
protected pointer: number = -1;
|
||||||
|
protected lastActionTime: number = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.onChangeDebounced = debounce(this.onChange, 1000, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): UndoStackEntry|null {
|
||||||
|
if (this.pointer < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastActionTime = Date.now();
|
||||||
|
this.pointer -= 1;
|
||||||
|
return this.stack[this.pointer];
|
||||||
|
}
|
||||||
|
|
||||||
|
redo(): UndoStackEntry|null {
|
||||||
|
const atEnd = this.pointer === this.stack.length - 1;
|
||||||
|
if (atEnd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastActionTime = Date.now();
|
||||||
|
this.pointer++;
|
||||||
|
return this.stack[this.pointer];
|
||||||
|
}
|
||||||
|
|
||||||
|
push(getValueCallback: () => UndoStackEntry): void {
|
||||||
|
// Ignore changes made via undo/redo actions
|
||||||
|
if (Date.now() - this.lastActionTime < 100) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onChangeDebounced(getValueCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onChange(getValueCallback: () => UndoStackEntry) {
|
||||||
|
// Trim the end of the stack from the pointer since we're branching away
|
||||||
|
if (this.pointer !== this.stack.length - 1) {
|
||||||
|
this.stack = this.stack.slice(0, this.pointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stack.push(getValueCallback());
|
||||||
|
|
||||||
|
// Limit stack size
|
||||||
|
if (this.stack.length > 50) {
|
||||||
|
this.stack = this.stack.slice(this.stack.length - 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pointer = this.stack.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TextareaInput implements MarkdownEditorInput {
|
||||||
|
|
||||||
|
protected input: HTMLTextAreaElement;
|
||||||
|
protected shortcuts: MarkdownEditorShortcutMap;
|
||||||
|
protected events: MarkdownEditorEventMap;
|
||||||
|
protected onChange: () => void;
|
||||||
|
protected eventController = new AbortController();
|
||||||
|
protected undoStack = new UndoStack();
|
||||||
|
|
||||||
|
protected textSizeCache: {x: number; y: number}|null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
input: HTMLTextAreaElement,
|
||||||
|
shortcuts: MarkdownEditorShortcutMap,
|
||||||
|
events: MarkdownEditorEventMap,
|
||||||
|
onChange: () => void
|
||||||
|
) {
|
||||||
|
this.input = input;
|
||||||
|
this.shortcuts = shortcuts;
|
||||||
|
this.events = events;
|
||||||
|
this.onChange = onChange;
|
||||||
|
|
||||||
|
this.onKeyDown = this.onKeyDown.bind(this);
|
||||||
|
this.configureLocalShortcuts();
|
||||||
|
this.configureListeners();
|
||||||
|
|
||||||
|
this.input.style.removeProperty("display");
|
||||||
|
this.undoStack.push(() => ({content: this.getText(), selection: this.getSelection()}));
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
this.eventController.abort('teardown');
|
||||||
|
}
|
||||||
|
|
||||||
|
configureLocalShortcuts(): void {
|
||||||
|
this.shortcuts['Mod-z'] = () => {
|
||||||
|
const undoEntry = this.undoStack.undo();
|
||||||
|
if (undoEntry) {
|
||||||
|
this.setText(undoEntry.content);
|
||||||
|
this.setSelection(undoEntry.selection, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.shortcuts['Mod-y'] = () => {
|
||||||
|
const redoContent = this.undoStack.redo();
|
||||||
|
if (redoContent) {
|
||||||
|
this.setText(redoContent.content);
|
||||||
|
this.setSelection(redoContent.selection, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configureListeners(): void {
|
||||||
|
// Keyboard shortcuts
|
||||||
|
this.input.addEventListener('keydown', this.onKeyDown, {signal: this.eventController.signal});
|
||||||
|
|
||||||
|
// Shared event listeners
|
||||||
|
for (const [name, listener] of Object.entries(this.events)) {
|
||||||
|
this.input.addEventListener(name, listener, {signal: this.eventController.signal});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input change handling
|
||||||
|
this.input.addEventListener('input', () => {
|
||||||
|
this.onChange();
|
||||||
|
this.undoStack.push(() => ({content: this.input.value, selection: this.getSelection()}));
|
||||||
|
}, {signal: this.eventController.signal});
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown(e: KeyboardEvent) {
|
||||||
|
const isApple = navigator.platform.startsWith("Mac") || navigator.platform === "iPhone";
|
||||||
|
const key = e.key.length > 1 ? e.key : e.key.toLowerCase();
|
||||||
|
const keyParts = [
|
||||||
|
e.shiftKey ? 'Shift' : null,
|
||||||
|
isApple && e.metaKey ? 'Mod' : null,
|
||||||
|
!isApple && e.ctrlKey ? 'Mod' : null,
|
||||||
|
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}`;
|
||||||
|
this.input.dispatchEvent(new Event('input'));
|
||||||
|
}
|
||||||
|
|
||||||
|
eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
|
||||||
|
const eventCoords = this.mouseEventToTextRelativeCoords(event);
|
||||||
|
return this.inputPositionToSelection(eventCoords.x, eventCoords.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 lineEnd = lineStart + line.length;
|
||||||
|
if (position <= lineEnd) {
|
||||||
|
return {from: lineStart, to: lineEnd};
|
||||||
|
}
|
||||||
|
lineStart = lineEnd + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 selection = this.inputPositionToSelection(0, scrollTop);
|
||||||
|
return this.getSelectionText({from: 0, to: selection.to});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
this.input.dispatchEvent(new Event('input'));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected measureTextSize(): {x: number; y: number} {
|
||||||
|
if (this.textSizeCache) {
|
||||||
|
return this.textSizeCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.textContent = `a\nb`;
|
||||||
|
const inputStyles = window.getComputedStyle(this.input)
|
||||||
|
el.style.font = inputStyles.font;
|
||||||
|
el.style.lineHeight = inputStyles.lineHeight;
|
||||||
|
el.style.padding = '0px';
|
||||||
|
el.style.display = 'inline-block';
|
||||||
|
el.style.visibility = 'hidden';
|
||||||
|
el.style.position = 'absolute';
|
||||||
|
el.style.whiteSpace = 'pre';
|
||||||
|
this.input.after(el);
|
||||||
|
|
||||||
|
const bounds = el.getBoundingClientRect();
|
||||||
|
el.remove();
|
||||||
|
this.textSizeCache = {
|
||||||
|
x: bounds.width,
|
||||||
|
y: bounds.height / 2,
|
||||||
|
};
|
||||||
|
return this.textSizeCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected measureLineCharCount(textWidth: number): number {
|
||||||
|
const inputStyles = window.getComputedStyle(this.input);
|
||||||
|
const paddingLeft = Number(inputStyles.paddingLeft.replace('px', ''));
|
||||||
|
const paddingRight = Number(inputStyles.paddingRight.replace('px', ''));
|
||||||
|
const width = Number(inputStyles.width.replace('px', ''));
|
||||||
|
const textSpace = width - (paddingLeft + paddingRight);
|
||||||
|
|
||||||
|
return Math.floor(textSpace / textWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected mouseEventToTextRelativeCoords(event: MouseEvent): {x: number; y: number} {
|
||||||
|
const inputBounds = this.input.getBoundingClientRect();
|
||||||
|
const inputStyles = window.getComputedStyle(this.input);
|
||||||
|
const paddingTop = Number(inputStyles.paddingTop.replace('px', ''));
|
||||||
|
const paddingLeft = Number(inputStyles.paddingLeft.replace('px', ''));
|
||||||
|
|
||||||
|
const xPos = Math.max(event.clientX - (inputBounds.left + paddingLeft), 0);
|
||||||
|
const yPos = Math.max((event.clientY - (inputBounds.top + paddingTop)) + this.input.scrollTop, 0);
|
||||||
|
|
||||||
|
return {x: xPos, y: yPos};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected inputPositionToSelection(x: number, y: number): MarkdownEditorInputSelection {
|
||||||
|
const textSize = this.measureTextSize();
|
||||||
|
const lineWidth = this.measureLineCharCount(textSize.x);
|
||||||
|
|
||||||
|
const lines = this.getText().split('\n');
|
||||||
|
|
||||||
|
let currY = 0;
|
||||||
|
let currPos = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
let linePos = 0;
|
||||||
|
const wrapCount = Math.max(Math.ceil(line.length / lineWidth), 1);
|
||||||
|
for (let i = 0; i < wrapCount; i++) {
|
||||||
|
currY += textSize.y;
|
||||||
|
if (currY > y) {
|
||||||
|
const targetX = Math.floor(x / textSize.x);
|
||||||
|
const maxPos = Math.min(currPos + linePos + targetX, currPos + line.length);
|
||||||
|
return {from: maxPos, to: maxPos};
|
||||||
|
}
|
||||||
|
|
||||||
|
linePos += lineWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
currPos += line.length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import MarkdownIt from 'markdown-it';
|
import MarkdownIt from 'markdown-it';
|
||||||
|
// @ts-ignore
|
||||||
import mdTasksLists from 'markdown-it-task-lists';
|
import mdTasksLists from 'markdown-it-task-lists';
|
||||||
|
|
||||||
export class Markdown {
|
export class Markdown {
|
||||||
|
protected renderer: MarkdownIt;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.renderer = new MarkdownIt({html: true});
|
this.renderer = new MarkdownIt({html: true});
|
||||||
@@ -9,19 +11,16 @@ export class Markdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the front-end render used to convert markdown to HTML.
|
* Get the front-end render used to convert Markdown to HTML.
|
||||||
* @returns {MarkdownIt}
|
|
||||||
*/
|
*/
|
||||||
getRenderer() {
|
getRenderer(): MarkdownIt {
|
||||||
return this.renderer;
|
return this.renderer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert the given Markdown to HTML.
|
* Convert the given Markdown to HTML.
|
||||||
* @param {String} markdown
|
|
||||||
* @returns {String}
|
|
||||||
*/
|
*/
|
||||||
render(markdown) {
|
render(markdown: string): string {
|
||||||
return this.renderer.render(markdown);
|
return this.renderer.render(markdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
export class Settings {
|
|
||||||
|
|
||||||
constructor(settingInputs) {
|
|
||||||
this.settingMap = {
|
|
||||||
scrollSync: true,
|
|
||||||
showPreview: true,
|
|
||||||
editorWidth: 50,
|
|
||||||
};
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
82
resources/js/markdown/settings.ts
Normal file
82
resources/js/markdown/settings.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import {MarkdownEditor} from "./index.mjs";
|
||||||
|
|
||||||
|
export type MarkdownEditorShortcutMap = Record<string, () => void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provide shortcuts for the editor instance.
|
* Provide shortcuts for the editor instance.
|
||||||
* @param {MarkdownEditor} editor
|
|
||||||
* @returns {Object<String, Function>}
|
|
||||||
*/
|
*/
|
||||||
function provide(editor) {
|
export function provideShortcutMap(editor: MarkdownEditor): MarkdownEditorShortcutMap {
|
||||||
const shortcuts = {};
|
const shortcuts: MarkdownEditorShortcutMap = {};
|
||||||
|
|
||||||
// Insert Image shortcut
|
// Insert Image shortcut
|
||||||
shortcuts['Shift-Mod-i'] = () => editor.actions.insertImage();
|
shortcuts['Shift-Mod-i'] = () => editor.actions.insertImage();
|
||||||
@@ -39,24 +41,3 @@ function provide(editor) {
|
|||||||
|
|
||||||
return shortcuts;
|
return shortcuts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the editor shortcuts in CodeMirror keybinding format.
|
|
||||||
* @param {MarkdownEditor} editor
|
|
||||||
* @return {{key: String, run: function, preventDefault: boolean}[]}
|
|
||||||
*/
|
|
||||||
export function provideKeyBindings(editor) {
|
|
||||||
const shortcuts = provide(editor);
|
|
||||||
const keyBindings = [];
|
|
||||||
|
|
||||||
const wrapAction = action => () => {
|
|
||||||
action();
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [shortcut, action] of Object.entries(shortcuts)) {
|
|
||||||
keyBindings.push({key: shortcut, run: wrapAction(action), preventDefault: true});
|
|
||||||
}
|
|
||||||
|
|
||||||
return keyBindings;
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* leading edge, instead of the trailing.
|
* leading edge, instead of the trailing.
|
||||||
* @attribution https://davidwalsh.name/javascript-debounce-function
|
* @attribution https://davidwalsh.name/javascript-debounce-function
|
||||||
*/
|
*/
|
||||||
export function debounce(func: Function, waitMs: number, immediate: boolean): Function {
|
export function debounce<T extends (...args: any[]) => any>(func: T, waitMs: number, immediate: boolean): T {
|
||||||
let timeout: number|null = null;
|
let timeout: number|null = null;
|
||||||
return function debouncedWrapper(this: any, ...args: any[]) {
|
return function debouncedWrapper(this: any, ...args: any[]) {
|
||||||
const context: any = this;
|
const context: any = this;
|
||||||
@@ -19,7 +19,7 @@ export function debounce(func: Function, waitMs: number, immediate: boolean): Fu
|
|||||||
}
|
}
|
||||||
timeout = window.setTimeout(later, waitMs);
|
timeout = window.setTimeout(later, waitMs);
|
||||||
if (callNow) func.apply(context, args);
|
if (callNow) func.apply(context, args);
|
||||||
};
|
} as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDetailsElement(element: HTMLElement): element is HTMLDetailsElement {
|
function isDetailsElement(element: HTMLElement): element is HTMLDetailsElement {
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export type NodeKey = string;
|
|||||||
|
|
||||||
export class LexicalNode {
|
export class LexicalNode {
|
||||||
// Allow us to look up the type including static props
|
// Allow us to look up the type including static props
|
||||||
['constructor']!: KlassConstructor<typeof LexicalNode>;
|
declare ['constructor']: KlassConstructor<typeof LexicalNode>;
|
||||||
/** @internal */
|
/** @internal */
|
||||||
__type: string;
|
__type: string;
|
||||||
/** @internal */
|
/** @internal */
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface DecoratorNode<T> {
|
|||||||
/** @noInheritDoc */
|
/** @noInheritDoc */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||||
export class DecoratorNode<T> extends LexicalNode {
|
export class DecoratorNode<T> extends LexicalNode {
|
||||||
['constructor']!: KlassConstructor<typeof DecoratorNode<T>>;
|
declare ['constructor']: KlassConstructor<typeof DecoratorNode<T>>;
|
||||||
constructor(key?: NodeKey) {
|
constructor(key?: NodeKey) {
|
||||||
super(key);
|
super(key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export interface ElementNode {
|
|||||||
/** @noInheritDoc */
|
/** @noInheritDoc */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||||
export class ElementNode extends LexicalNode {
|
export class ElementNode extends LexicalNode {
|
||||||
['constructor']!: KlassConstructor<typeof ElementNode>;
|
declare ['constructor']: KlassConstructor<typeof ElementNode>;
|
||||||
/** @internal */
|
/** @internal */
|
||||||
__first: null | NodeKey;
|
__first: null | NodeKey;
|
||||||
/** @internal */
|
/** @internal */
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export type SerializedLineBreakNode = SerializedLexicalNode;
|
|||||||
|
|
||||||
/** @noInheritDoc */
|
/** @noInheritDoc */
|
||||||
export class LineBreakNode extends LexicalNode {
|
export class LineBreakNode extends LexicalNode {
|
||||||
['constructor']!: KlassConstructor<typeof LineBreakNode>;
|
declare ['constructor']: KlassConstructor<typeof LineBreakNode>;
|
||||||
static getType(): string {
|
static getType(): string {
|
||||||
return 'linebreak';
|
return 'linebreak';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export type SerializedParagraphNode = Spread<
|
|||||||
|
|
||||||
/** @noInheritDoc */
|
/** @noInheritDoc */
|
||||||
export class ParagraphNode extends CommonBlockNode {
|
export class ParagraphNode extends CommonBlockNode {
|
||||||
['constructor']!: KlassConstructor<typeof ParagraphNode>;
|
declare ['constructor']: KlassConstructor<typeof ParagraphNode>;
|
||||||
/** @internal */
|
/** @internal */
|
||||||
__textFormat: number;
|
__textFormat: number;
|
||||||
__textStyle: string;
|
__textStyle: string;
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export interface TextNode {
|
|||||||
/** @noInheritDoc */
|
/** @noInheritDoc */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||||
export class TextNode extends LexicalNode {
|
export class TextNode extends LexicalNode {
|
||||||
['constructor']!: KlassConstructor<typeof TextNode>;
|
declare ['constructor']: KlassConstructor<typeof TextNode>;
|
||||||
__text: string;
|
__text: string;
|
||||||
/** @internal */
|
/** @internal */
|
||||||
__format: number;
|
__format: number;
|
||||||
|
|||||||
@@ -55,12 +55,17 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
padding: vars.$xs vars.$m;
|
padding: vars.$xs vars.$m;
|
||||||
color: #444;
|
@include mixins.lightDark(color, #444, #aaa);
|
||||||
|
@include mixins.lightDark(background-color, #fff, #222);
|
||||||
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;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
<div class="px-m">
|
<div class="px-m">
|
||||||
@include('form.custom-checkbox', ['name' => 'md-scrollSync', 'label' => trans('entities.pages_md_sync_scroll'), 'value' => true, 'checked' => true])
|
@include('form.custom-checkbox', ['name' => 'md-scrollSync', 'label' => trans('entities.pages_md_sync_scroll'), 'value' => true, 'checked' => true])
|
||||||
</div>
|
</div>
|
||||||
|
<hr class="m-none">
|
||||||
|
<div class="px-m">
|
||||||
|
@include('form.custom-checkbox', ['name' => 'md-plainEditor', 'label' => trans('entities.pages_md_plain_editor'), 'value' => true, 'checked' => false])
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"include": ["resources/js/**/*"],
|
"include": ["resources/js/**/*"],
|
||||||
"exclude": ["resources/js/wysiwyg/lexical/yjs/*"],
|
"exclude": ["resources/js/wysiwyg/lexical/yjs/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2019",
|
"target": "es2022",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"rootDir": "./resources/js/",
|
"rootDir": "./resources/js/",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
|
|||||||
Reference in New Issue
Block a user