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'), | ||||
|     code: path.join(__dirname, '../../resources/js/code/index.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'), | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -268,6 +268,7 @@ return [ | ||||
|     'pages_md_insert_drawing' => 'Insert Drawing', | ||||
|     'pages_md_show_preview' => 'Show preview', | ||||
|     'pages_md_sync_scroll' => 'Sync preview scroll', | ||||
|     'pages_md_plain_editor' => 'Plaintext editor', | ||||
|     '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_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": { | ||||
|     "@eslint/js": "^9.21.0", | ||||
|     "@lezer/generator": "^1.7.2", | ||||
|     "@types/markdown-it": "^14.1.2", | ||||
|     "@types/sortablejs": "^1.15.8", | ||||
|     "chokidar-cli": "^3.0", | ||||
|     "esbuild": "^0.25.0", | ||||
|   | ||||
| @@ -1,15 +1,23 @@ | ||||
| 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 { | ||||
| 
 | ||||
|     protected container!: HTMLElement; | ||||
|     protected selectButton!: HTMLElement; | ||||
|     protected selectorEl!: HTMLElement; | ||||
| 
 | ||||
|     protected callback: EntitySelectorPopupCallback|null = null; | ||||
|     protected selection: EntitySelectorEntity|null = null; | ||||
| 
 | ||||
|     setup() { | ||||
|         this.container = this.$el; | ||||
|         this.selectButton = this.$refs.select; | ||||
|         this.selectorEl = this.$refs.selector; | ||||
| 
 | ||||
|         this.callback = null; | ||||
|         this.selection = null; | ||||
| 
 | ||||
|         this.selectButton.addEventListener('click', this.onSelectButtonClick.bind(this)); | ||||
|         window.$events.listen('entity-select-change', this.onSelectionChange.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. | ||||
|      * @param {Function} callback | ||||
|      * @param {EntitySelectorSearchOptions} searchOptions | ||||
|      */ | ||||
|     show(callback, searchOptions = {}) { | ||||
|     show(callback: EntitySelectorPopupCallback, searchOptions: Partial<EntitySelectorSearchOptions> = {}) { | ||||
|         this.callback = callback; | ||||
|         this.getSelector().configureSearchOptions(searchOptions); | ||||
|         this.getPopup().show(); | ||||
| @@ -32,34 +38,28 @@ export class EntitySelectorPopup extends Component { | ||||
|         this.getPopup().hide(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @returns {Popup} | ||||
|      */ | ||||
|     getPopup() { | ||||
|         return window.$components.firstOnElement(this.container, 'popup'); | ||||
|     getPopup(): Popup { | ||||
|         return window.$components.firstOnElement(this.container, 'popup') as Popup; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @returns {EntitySelector} | ||||
|      */ | ||||
|     getSelector() { | ||||
|         return window.$components.firstOnElement(this.selectorEl, 'entity-selector'); | ||||
|     getSelector(): EntitySelector { | ||||
|         return window.$components.firstOnElement(this.selectorEl, 'entity-selector') as EntitySelector; | ||||
|     } | ||||
| 
 | ||||
|     onSelectButtonClick() { | ||||
|         this.handleConfirmedSelection(this.selection); | ||||
|     } | ||||
| 
 | ||||
|     onSelectionChange(entity) { | ||||
|         this.selection = entity; | ||||
|         if (entity === null) { | ||||
|     onSelectionChange(entity: EntitySelectorEntity|{}) { | ||||
|         this.selection = (entity.hasOwnProperty('id') ? entity : null) as EntitySelectorEntity|null; | ||||
|         if (!this.selection) { | ||||
|             this.selectButton.setAttribute('disabled', 'true'); | ||||
|         } else { | ||||
|             this.selectButton.removeAttribute('disabled'); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     handleConfirmedSelection(entity) { | ||||
|     handleConfirmedSelection(entity: EntitySelectorEntity|null): void { | ||||
|         this.hide(); | ||||
|         this.getSelector().reset(); | ||||
|         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'; | ||||
| 
 | ||||
| /** | ||||
|  * @typedef EntitySelectorSearchOptions | ||||
|  * @property entityTypes string | ||||
|  * @property entityPermission string | ||||
|  * @property searchEndpoint string | ||||
|  * @property initialValue string | ||||
|  */ | ||||
| export interface EntitySelectorSearchOptions { | ||||
|     entityTypes: string; | ||||
|     entityPermission: string; | ||||
|     searchEndpoint: string; | ||||
|     initialValue: string; | ||||
| } | ||||
| 
 | ||||
| export type EntitySelectorEntity = { | ||||
|     id: number, | ||||
|     name: string, | ||||
|     link: string, | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Entity Selector | ||||
|  */ | ||||
| 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() { | ||||
|         this.elem = this.$el; | ||||
| 
 | ||||
|         this.input = this.$refs.input; | ||||
|         this.searchInput = this.$refs.search; | ||||
|         this.input = this.$refs.input as HTMLInputElement; | ||||
|         this.searchInput = this.$refs.search as HTMLInputElement; | ||||
|         this.loading = this.$refs.loading; | ||||
|         this.resultsContainer = this.$refs.results; | ||||
| 
 | ||||
| @@ -29,9 +41,6 @@ export class EntitySelector extends Component { | ||||
|             initialValue: this.searchInput.value || '', | ||||
|         }; | ||||
| 
 | ||||
|         this.search = ''; | ||||
|         this.lastClick = 0; | ||||
| 
 | ||||
|         this.setupListeners(); | ||||
|         this.showLoading(); | ||||
| 
 | ||||
| @@ -40,16 +49,13 @@ export class EntitySelector extends Component { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @param {EntitySelectorSearchOptions} options | ||||
|      */ | ||||
|     configureSearchOptions(options) { | ||||
|     configureSearchOptions(options: Partial<EntitySelectorSearchOptions>): void { | ||||
|         Object.assign(this.searchOptions, options); | ||||
|         this.reset(); | ||||
|         this.searchInput.value = this.searchOptions.initialValue; | ||||
|     } | ||||
| 
 | ||||
|     setupListeners() { | ||||
|     setupListeners(): void { | ||||
|         this.elem.addEventListener('click', this.onClick.bind(this)); | ||||
| 
 | ||||
|         let lastSearch = 0; | ||||
| @@ -67,7 +73,7 @@ export class EntitySelector extends Component { | ||||
|         }); | ||||
| 
 | ||||
|         // 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') { | ||||
|                 const form = this.$el.closest('form'); | ||||
|                 if (form) { | ||||
| @@ -83,7 +89,7 @@ export class EntitySelector extends Component { | ||||
|             if (event.code === 'ArrowUp') { | ||||
|                 this.focusAdjacent(false); | ||||
|             } | ||||
|         }); | ||||
|         }) as (event: Event) => void); | ||||
| 
 | ||||
|         this.searchInput.addEventListener('keydown', event => { | ||||
|             if (event.code === 'ArrowDown') { | ||||
| @@ -93,10 +99,10 @@ export class EntitySelector extends Component { | ||||
|     } | ||||
| 
 | ||||
|     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 newItem = items[selectedIndex + (forward ? 1 : -1)] || items[0]; | ||||
|         if (newItem) { | ||||
|         if (newItem instanceof HTMLElement) { | ||||
|             newItem.focus(); | ||||
|         } | ||||
|     } | ||||
| @@ -132,7 +138,7 @@ export class EntitySelector extends Component { | ||||
|         } | ||||
| 
 | ||||
|         window.$http.get(this.searchUrl()).then(resp => { | ||||
|             this.resultsContainer.innerHTML = resp.data; | ||||
|             this.resultsContainer.innerHTML = resp.data as string; | ||||
|             this.hideLoading(); | ||||
|         }); | ||||
|     } | ||||
| @@ -142,7 +148,7 @@ export class EntitySelector extends Component { | ||||
|         return `${this.searchOptions.searchEndpoint}?${query}`; | ||||
|     } | ||||
| 
 | ||||
|     searchEntities(searchTerm) { | ||||
|     searchEntities(searchTerm: string) { | ||||
|         if (!this.searchOptions.searchEndpoint) { | ||||
|             throw new Error('Search endpoint not set for entity-selector load'); | ||||
|         } | ||||
| @@ -150,7 +156,7 @@ export class EntitySelector extends Component { | ||||
|         this.input.value = ''; | ||||
|         const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`; | ||||
|         window.$http.get(url).then(resp => { | ||||
|             this.resultsContainer.innerHTML = resp.data; | ||||
|             this.resultsContainer.innerHTML = resp.data as string; | ||||
|             this.hideLoading(); | ||||
|         }); | ||||
|     } | ||||
| @@ -162,16 +168,16 @@ export class EntitySelector extends Component { | ||||
|         return answer; | ||||
|     } | ||||
| 
 | ||||
|     onClick(event) { | ||||
|         const listItem = event.target.closest('[data-entity-type]'); | ||||
|         if (listItem) { | ||||
|     onClick(event: MouseEvent) { | ||||
|         const listItem = (event.target as HTMLElement).closest('[data-entity-type]'); | ||||
|         if (listItem instanceof HTMLElement) { | ||||
|             event.preventDefault(); | ||||
|             event.stopPropagation(); | ||||
|             this.selectItem(listItem); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     selectItem(item) { | ||||
|     selectItem(item: HTMLElement): void { | ||||
|         const isDblClick = this.isDoubleClick(); | ||||
|         const type = item.getAttribute('data-entity-type'); | ||||
|         const id = item.getAttribute('data-entity-id'); | ||||
| @@ -180,14 +186,14 @@ export class EntitySelector extends Component { | ||||
|         this.unselectAll(); | ||||
|         this.input.value = isSelected ? `${type}:${id}` : ''; | ||||
| 
 | ||||
|         const link = item.getAttribute('href'); | ||||
|         const name = item.querySelector('.entity-list-item-name').textContent; | ||||
|         const data = {id: Number(id), name, link}; | ||||
|         const link = item.getAttribute('href') || ''; | ||||
|         const name = item.querySelector('.entity-list-item-name')?.textContent || ''; | ||||
|         const data: EntitySelectorEntity = {id: Number(id), name, link}; | ||||
| 
 | ||||
|         if (isSelected) { | ||||
|             item.classList.add('selected'); | ||||
|         } else { | ||||
|             window.$events.emit('entity-select-change', null); | ||||
|             window.$events.emit('entity-select-change'); | ||||
|         } | ||||
| 
 | ||||
|         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); | ||||
|     } | ||||
| 
 | ||||
| @@ -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') { | ||||
|         this.resetAll(); | ||||
|  | ||||
|   | ||||
							
								
								
									
										2
									
								
								resources/js/global.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								resources/js/global.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -16,3 +16,5 @@ declare global { | ||||
|         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 { | ||||
| 
 | ||||
|     /** | ||||
|      * @param {MarkdownEditor} editor | ||||
|      */ | ||||
|     constructor(editor) { | ||||
|         this.editor = editor; | ||||
|         this.lastContent = { | ||||
|     protected readonly editor: MarkdownEditor; | ||||
|     protected lastContent: { html: string; markdown: string } = { | ||||
|         html: '', | ||||
|         markdown: '', | ||||
|     }; | ||||
| 
 | ||||
|     constructor(editor: MarkdownEditor) { | ||||
|         this.editor = editor; | ||||
|     } | ||||
| 
 | ||||
|     updateAndRender() { | ||||
|         const content = this.#getText(); | ||||
|         const content = this.editor.input.getText(); | ||||
|         this.editor.config.inputEl.value = content; | ||||
| 
 | ||||
|         const html = this.editor.markdown.render(content); | ||||
| @@ -30,45 +39,42 @@ export class Actions { | ||||
|     } | ||||
| 
 | ||||
|     showImageInsert() { | ||||
|         /** @type {ImageManager} * */ | ||||
|         const imageManager = window.$components.first('image-manager'); | ||||
|         const imageManager = window.$components.first('image-manager') as ImageManager; | ||||
| 
 | ||||
|         imageManager.show(image => { | ||||
|         imageManager.show((image: ImageManagerImage) => { | ||||
|             const imageUrl = image.thumbs?.display || image.url; | ||||
|             const selectedText = this.#getSelectionText(); | ||||
|             const selectedText = this.editor.input.getSelectionText(); | ||||
|             const newText = `[](${image.url})`; | ||||
|             this.#replaceSelection(newText, newText.length); | ||||
|         }, 'gallery'); | ||||
|     } | ||||
| 
 | ||||
|     insertImage() { | ||||
|         const newText = ``; | ||||
|         const newText = ``; | ||||
|         this.#replaceSelection(newText, newText.length - 1); | ||||
|     } | ||||
| 
 | ||||
|     insertLink() { | ||||
|         const selectedText = this.#getSelectionText(); | ||||
|         const selectedText = this.editor.input.getSelectionText(); | ||||
|         const newText = `[${selectedText}]()`; | ||||
|         const cursorPosDiff = (selectedText === '') ? -3 : -1; | ||||
|         this.#replaceSelection(newText, newText.length + cursorPosDiff); | ||||
|     } | ||||
| 
 | ||||
|     showImageManager() { | ||||
|         const selectionRange = this.#getSelectionRange(); | ||||
|         /** @type {ImageManager} * */ | ||||
|         const imageManager = window.$components.first('image-manager'); | ||||
|         imageManager.show(image => { | ||||
|         const selectionRange = this.editor.input.getSelection(); | ||||
|         const imageManager = window.$components.first('image-manager') as ImageManager; | ||||
|         imageManager.show((image: ImageManagerImage) => { | ||||
|             this.#insertDrawing(image, selectionRange); | ||||
|         }, 'drawio'); | ||||
|     } | ||||
| 
 | ||||
|     // Show the popup link selector and insert a link when finished
 | ||||
|     showLinkSelector() { | ||||
|         const selectionRange = this.#getSelectionRange(); | ||||
|         const selectionRange = this.editor.input.getSelection(); | ||||
| 
 | ||||
|         /** @type {EntitySelectorPopup} * */ | ||||
|         const selector = window.$components.first('entity-selector-popup'); | ||||
|         const selectionText = this.#getSelectionText(selectionRange); | ||||
|         const selector = window.$components.first('entity-selector-popup') as EntitySelectorPopup; | ||||
|         const selectionText = this.editor.input.getSelectionText(selectionRange); | ||||
|         selector.show(entity => { | ||||
|             const selectedText = selectionText || entity.name; | ||||
|             const newText = `[${selectedText}](${entity.link})`; | ||||
| @@ -86,7 +92,7 @@ export class Actions { | ||||
|         const url = this.editor.config.drawioUrl; | ||||
|         if (!url) return; | ||||
| 
 | ||||
|         const selectionRange = this.#getSelectionRange(); | ||||
|         const selectionRange = this.editor.input.getSelection(); | ||||
| 
 | ||||
|         DrawIO.show(url, () => Promise.resolve(''), async pngData => { | ||||
|             const data = { | ||||
| @@ -96,7 +102,7 @@ export class Actions { | ||||
| 
 | ||||
|             try { | ||||
|                 const resp = await window.$http.post('/images/drawio', data); | ||||
|                 this.#insertDrawing(resp.data, selectionRange); | ||||
|                 this.#insertDrawing(resp.data as ImageManagerImage, selectionRange); | ||||
|                 DrawIO.close(); | ||||
|             } catch (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>`; | ||||
|         this.#replaceSelection(newText, newText.length, originalSelectionRange); | ||||
|     } | ||||
| 
 | ||||
|     // Show draw.io if enabled and handle save.
 | ||||
|     editDrawing(imgContainer) { | ||||
|     editDrawing(imgContainer: HTMLElement) { | ||||
|         const {drawioUrl} = this.editor.config; | ||||
|         if (!drawioUrl) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const selectionRange = this.#getSelectionRange(); | ||||
|         const drawingId = imgContainer.getAttribute('drawio-diagram'); | ||||
|         const selectionRange = this.editor.input.getSelection(); | ||||
|         const drawingId = imgContainer.getAttribute('drawio-diagram') || ''; | ||||
|         if (!drawingId) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         DrawIO.show(drawioUrl, () => DrawIO.load(drawingId), async pngData => { | ||||
|             const data = { | ||||
| @@ -128,14 +137,15 @@ export class Actions { | ||||
| 
 | ||||
|             try { | ||||
|                 const resp = await window.$http.post('/images/drawio', data); | ||||
|                 const newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`; | ||||
|                 const newContent = this.#getText().split('\n').map(line => { | ||||
|                 const image = resp.data as ImageManagerImage; | ||||
|                 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) { | ||||
|                         return newText; | ||||
|                     } | ||||
|                     return line; | ||||
|                 }).join('\n'); | ||||
|                 this.#setText(newContent, selectionRange); | ||||
|                 this.editor.input.setText(newContent, selectionRange); | ||||
|                 DrawIO.close(); | ||||
|             } catch (err) { | ||||
|                 this.handleDrawingUploadError(err); | ||||
| @@ -144,7 +154,7 @@ export class Actions { | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     handleDrawingUploadError(error) { | ||||
|     handleDrawingUploadError(error: any): void { | ||||
|         if (error.status === 413) { | ||||
|             window.$events.emit('error', this.editor.config.text.serverUploadLimit); | ||||
|         } else { | ||||
| @@ -162,91 +172,71 @@ export class Actions { | ||||
|     } | ||||
| 
 | ||||
|     // Scroll to a specified text
 | ||||
|     scrollToText(searchText) { | ||||
|     scrollToText(searchText: string): void { | ||||
|         if (!searchText) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const text = this.editor.cm.state.doc; | ||||
|         let lineCount = 1; | ||||
|         let scrollToLine = -1; | ||||
|         for (const line of text.iterLines()) { | ||||
|             if (line.includes(searchText)) { | ||||
|                 scrollToLine = lineCount; | ||||
|                 break; | ||||
|         const lineRange = this.editor.input.searchForLineContaining(searchText); | ||||
|         if (lineRange) { | ||||
|             this.editor.input.setSelection(lineRange, true); | ||||
|             this.editor.input.focus(); | ||||
|         } | ||||
|             lineCount += 1; | ||||
|         } | ||||
| 
 | ||||
|         if (scrollToLine === -1) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const line = text.line(scrollToLine); | ||||
|         this.#setSelection(line.from, line.to, true); | ||||
|         this.focus(); | ||||
|     } | ||||
| 
 | ||||
|     focus() { | ||||
|         if (!this.editor.cm.hasFocus) { | ||||
|             this.editor.cm.focus(); | ||||
|         } | ||||
|         this.editor.input.focus(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Insert content into the editor. | ||||
|      * @param {String} content | ||||
|      */ | ||||
|     insertContent(content) { | ||||
|     insertContent(content: string) { | ||||
|         this.#replaceSelection(content, content.length); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Prepend content to the editor. | ||||
|      * @param {String} content | ||||
|      */ | ||||
|     prependContent(content) { | ||||
|     prependContent(content: string): void { | ||||
|         content = this.#cleanTextForEditor(content); | ||||
|         const selectionRange = this.#getSelectionRange(); | ||||
|         const selectionRange = this.editor.input.getSelection(); | ||||
|         const selectFrom = selectionRange.from + content.length + 1; | ||||
|         this.#dispatchChange(0, 0, `${content}\n`, selectFrom); | ||||
|         this.focus(); | ||||
|         this.editor.input.spliceText(0, 0, `${content}\n`, {from: selectFrom}); | ||||
|         this.editor.input.focus(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Append content to the editor. | ||||
|      * @param {String} content | ||||
|      */ | ||||
|     appendContent(content) { | ||||
|     appendContent(content: string): void { | ||||
|         content = this.#cleanTextForEditor(content); | ||||
|         this.#dispatchChange(this.editor.cm.state.doc.length, `\n${content}`); | ||||
|         this.focus(); | ||||
|         this.editor.input.appendText(content); | ||||
|         this.editor.input.focus(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Replace the editor's contents | ||||
|      * @param {String} content | ||||
|      */ | ||||
|     replaceContent(content) { | ||||
|         this.#setText(content); | ||||
|     replaceContent(content: string): void { | ||||
|         this.editor.input.setText(content); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Replace the start of the line | ||||
|      * @param {String} newStart | ||||
|      */ | ||||
|     replaceLineStart(newStart) { | ||||
|         const selectionRange = this.#getSelectionRange(); | ||||
|         const line = this.editor.cm.state.doc.lineAt(selectionRange.from); | ||||
| 
 | ||||
|         const lineContent = line.text; | ||||
|     replaceLineStart(newStart: string): void { | ||||
|         const selectionRange = this.editor.input.getSelection(); | ||||
|         const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from); | ||||
|         const lineContent = this.editor.input.getSelectionText(lineRange); | ||||
|         const lineStart = lineContent.split(' ')[0]; | ||||
| 
 | ||||
|         // Remove symbol if already set
 | ||||
|         if (lineStart === newStart) { | ||||
|             const newLineContent = lineContent.replace(`${newStart} `, ''); | ||||
|             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; | ||||
|         } | ||||
| 
 | ||||
| @@ -259,48 +249,46 @@ export class Actions { | ||||
|         } | ||||
| 
 | ||||
|         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. | ||||
|      * @param {String} start | ||||
|      * @param {String} end | ||||
|      */ | ||||
|     wrapSelection(start, end) { | ||||
|         const selectRange = this.#getSelectionRange(); | ||||
|         const selectionText = this.#getSelectionText(selectRange); | ||||
|     wrapSelection(start: string, end: string): void { | ||||
|         const selectRange = this.editor.input.getSelection(); | ||||
|         const selectionText = this.editor.input.getSelectionText(selectRange); | ||||
|         if (!selectionText) { | ||||
|             this.#wrapLine(start, end); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         let newSelectionText = selectionText; | ||||
|         let newRange; | ||||
|         let newSelectionText: string; | ||||
|         let newRange = {from: selectRange.from, to: selectRange.to}; | ||||
| 
 | ||||
|         if (selectionText.startsWith(start) && selectionText.endsWith(end)) { | ||||
|             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 { | ||||
|             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.to, | ||||
|             newSelectionText, | ||||
|             newRange.anchor, | ||||
|             newRange.head, | ||||
|             newRange, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     replaceLineStartForOrderedList() { | ||||
|         const selectionRange = this.#getSelectionRange(); | ||||
|         const line = this.editor.cm.state.doc.lineAt(selectionRange.from); | ||||
|         const prevLine = this.editor.cm.state.doc.line(line.number - 1); | ||||
|         const selectionRange = this.editor.input.getSelection(); | ||||
|         const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from); | ||||
|         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 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. | ||||
|      */ | ||||
|     cycleCalloutTypeAtSelection() { | ||||
|         const selectionRange = this.#getSelectionRange(); | ||||
|         const line = this.editor.cm.state.doc.lineAt(selectionRange.from); | ||||
|         const selectionRange = this.editor.input.getSelection(); | ||||
|         const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from); | ||||
|         const lineText = this.editor.input.getSelectionText(lineRange); | ||||
| 
 | ||||
|         const formats = ['info', 'success', 'warning', 'danger']; | ||||
|         const joint = formats.join('|'); | ||||
|         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(); | ||||
| 
 | ||||
|         if (format === formats[formats.length - 1]) { | ||||
|             this.#wrapLine(`<p class="callout ${formats[formats.length - 1]}">`, '</p>'); | ||||
|         } else if (format === '') { | ||||
|             this.#wrapLine('<p class="callout info">', '</p>'); | ||||
|         } else { | ||||
|         } else if (matches) { | ||||
|             const newFormatIndex = formats.indexOf(format) + 1; | ||||
|             const newFormat = formats[newFormatIndex]; | ||||
|             const newContent = line.text.replace(matches[0], matches[0].replace(format, newFormat)); | ||||
|             const lineDiff = newContent.length - line.text.length; | ||||
|             this.#dispatchChange( | ||||
|                 line.from, | ||||
|                 line.to, | ||||
|             const newContent = lineText.replace(matches[0], matches[0].replace(format, newFormat)); | ||||
|             const lineDiff = newContent.length - lineText.length; | ||||
|             const anchor = Math.min(selectionRange.from, selectionRange.to); | ||||
|             const head = Math.max(selectionRange.from, selectionRange.to); | ||||
|             this.editor.input.spliceText( | ||||
|                 lineRange.from, | ||||
|                 lineRange.to, | ||||
|                 newContent, | ||||
|                 selectionRange.anchor + lineDiff, | ||||
|                 selectionRange.head + lineDiff, | ||||
|                 {from: anchor + lineDiff, to: 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
 | ||||
|         const scrollEl = event.target; | ||||
|         const scrollEl = event.target as HTMLElement; | ||||
|         const atEnd = Math.abs(scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop) < 1; | ||||
|         if (atEnd) { | ||||
|             this.editor.display.scrollToIndex(-1); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const blockInfo = this.editor.cm.lineBlockAtHeight(scrollEl.scrollTop); | ||||
|         const range = this.editor.cm.state.sliceDoc(0, blockInfo.from); | ||||
|         const range = this.editor.input.getTextAboveView(); | ||||
|         const parser = new DOMParser(); | ||||
|         const doc = parser.parseFromString(this.editor.markdown.render(range), 'text/html'); | ||||
|         const totalLines = doc.documentElement.querySelectorAll('body > *'); | ||||
| @@ -363,54 +352,48 @@ export class Actions { | ||||
|     /** | ||||
|      * Fetch and insert the template of the given ID. | ||||
|      * 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) { | ||||
|         const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false); | ||||
|         const {data} = await window.$http.get(`/templates/${templateId}`); | ||||
|         const content = data.markdown || data.html; | ||||
|         this.#dispatchChange(cursorPos, cursorPos, content, cursorPos); | ||||
|     async insertTemplate(templateId: string, event: MouseEvent): Promise<void> { | ||||
|         const cursorPos = this.editor.input.eventToPosition(event).from; | ||||
|         const responseData = (await window.$http.get(`/templates/${templateId}`)).data as {markdown: string, html: string}; | ||||
|         const content = responseData.markdown || responseData.html; | ||||
|         this.editor.input.spliceText(cursorPos, cursorPos, content, {from: cursorPos}); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Insert multiple images from the clipboard from an event at the provided | ||||
|      * screen coordinates (Typically form a paste event). | ||||
|      * @param {File[]} images | ||||
|      * @param {Number} posX | ||||
|      * @param {Number} posY | ||||
|      */ | ||||
|     insertClipboardImages(images, posX, posY) { | ||||
|         const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false); | ||||
|     insertClipboardImages(images: File[], event: MouseEvent): void { | ||||
|         const cursorPos = this.editor.input.eventToPosition(event).from; | ||||
|         for (const image of images) { | ||||
|             this.uploadImage(image, cursorPos); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Handle image upload and add image into markdown content | ||||
|      * @param {File} file | ||||
|      * @param {?Number} position | ||||
|      * Handle image upload and add image into Markdown content | ||||
|      */ | ||||
|     async uploadImage(file, position = null) { | ||||
|     async uploadImage(file: File, position: number|null = null): Promise<void> { | ||||
|         if (file === null || file.type.indexOf('image') !== 0) return; | ||||
|         let ext = 'png'; | ||||
| 
 | ||||
|         if (position === null) { | ||||
|             position = this.#getSelectionRange().from; | ||||
|             position = this.editor.input.getSelection().from; | ||||
|         } | ||||
| 
 | ||||
|         if (file.name) { | ||||
|             const fileNameMatches = file.name.match(/\.(.+)$/); | ||||
|             if (fileNameMatches.length > 1) ext = fileNameMatches[1]; | ||||
|             if (fileNameMatches && fileNameMatches.length > 1) { | ||||
|                 ext = fileNameMatches[1]; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Insert image into markdown
 | ||||
|         const id = `image-${Math.random().toString(16).slice(2)}`; | ||||
|         const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`); | ||||
|         const placeHolderText = ``; | ||||
|         this.#dispatchChange(position, position, placeHolderText, position); | ||||
|         this.editor.input.spliceText(position, position, placeHolderText, {from: position}); | ||||
| 
 | ||||
|         const remoteFilename = `image-${Date.now()}.${ext}`; | ||||
|         const formData = new FormData(); | ||||
| @@ -418,105 +401,53 @@ export class Actions { | ||||
|         formData.append('uploaded_to', this.editor.config.pageId); | ||||
| 
 | ||||
|         try { | ||||
|             const {data} = await window.$http.post('/images/gallery', formData); | ||||
|             const newContent = `[](${data.url})`; | ||||
|             const image = (await window.$http.post('/images/gallery', formData)).data as ImageManagerImage; | ||||
|             const newContent = `[](${image.url})`; | ||||
|             this.#findAndReplaceContent(placeHolderText, newContent); | ||||
|         } catch (err) { | ||||
|         } catch (err: any) { | ||||
|             window.$events.error(err?.data?.message || this.editor.config.text.imageUploadError); | ||||
|             this.#findAndReplaceContent(placeHolderText, ''); | ||||
|             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. | ||||
|      * 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. | ||||
|      * @param {String} newContent | ||||
|      * @param {Number} cursorOffset | ||||
|      * @param {?SelectionRange} selectionRange | ||||
|      */ | ||||
|     #replaceSelection(newContent, cursorOffset = 0, selectionRange = null) { | ||||
|         selectionRange = selectionRange || this.editor.cm.state.selection.main; | ||||
|         const selectFrom = selectionRange.from + cursorOffset; | ||||
|         this.#dispatchChange(selectionRange.from, selectionRange.to, newContent, selectFrom); | ||||
|         this.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; | ||||
|     #replaceSelection(newContent: string, offset: number = 0, selection: MarkdownEditorInputSelection|null = null) { | ||||
|         selection = selection || this.editor.input.getSelection(); | ||||
|         const selectFrom = selection.from + offset; | ||||
|         this.editor.input.spliceText(selection.from, selection.to, newContent, {from: selectFrom, to: selectFrom}); | ||||
|         this.editor.input.focus(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Cleans the given text to work with the editor. | ||||
|      * 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'); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Find and replace the first occurrence of [search] with [replace] | ||||
|      * @param {String} search | ||||
|      * @param {String} replace | ||||
|      */ | ||||
|     #findAndReplaceContent(search, replace) { | ||||
|         const newText = this.#getText().replace(search, replace); | ||||
|         this.#setText(newText); | ||||
|     #findAndReplaceContent(search: string, replace: string): void { | ||||
|         const newText = this.editor.input.getText().replace(search, replace); | ||||
|         this.editor.input.setText(newText); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Wrap the line in the given start and end contents. | ||||
|      * @param {String} start | ||||
|      * @param {String} end | ||||
|      */ | ||||
|     #wrapLine(start, end) { | ||||
|         const selectionRange = this.#getSelectionRange(); | ||||
|         const line = this.editor.cm.state.doc.lineAt(selectionRange.from); | ||||
|         const lineContent = line.text; | ||||
|         let newLineContent; | ||||
|         let lineOffset = 0; | ||||
|     #wrapLine(start: string, end: string): void { | ||||
|         const selectionRange = this.editor.input.getSelection(); | ||||
|         const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from); | ||||
|         const lineContent = this.editor.input.getSelectionText(lineRange); | ||||
|         let newLineContent: string; | ||||
|         let lineOffset: number; | ||||
| 
 | ||||
|         if (lineContent.startsWith(start) && lineContent.endsWith(end)) { | ||||
|             newLineContent = lineContent.slice(start.length, lineContent.length - end.length); | ||||
| @@ -526,42 +457,7 @@ export class Actions { | ||||
|             lineOffset = start.length; | ||||
|         } | ||||
| 
 | ||||
|         this.#dispatchChange(line.from, line.to, newLineContent, 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, | ||||
|         }); | ||||
|         this.editor.input.spliceText(lineRange.from, lineRange.to, newLineContent, {from: selectionRange.from + lineOffset}); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										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 { | ||||
|     protected editor: MarkdownEditor; | ||||
|     protected container: HTMLIFrameElement; | ||||
|     protected doc: Document | null = null; | ||||
|     protected lastDisplayClick: number = 0; | ||||
| 
 | ||||
|     /** | ||||
|      * @param {MarkdownEditor} editor | ||||
|      */ | ||||
|     constructor(editor) { | ||||
|     constructor(editor: MarkdownEditor) { | ||||
|         this.editor = editor; | ||||
|         this.container = editor.config.displayEl; | ||||
| 
 | ||||
|         this.doc = null; | ||||
|         this.lastDisplayClick = 0; | ||||
| 
 | ||||
|         if (this.container.contentDocument.readyState === 'complete') { | ||||
|         if (this.container.contentDocument?.readyState === 'complete') { | ||||
|             this.onLoad(); | ||||
|         } else { | ||||
|             this.container.addEventListener('load', this.onLoad.bind(this)); | ||||
|         } | ||||
| 
 | ||||
|         this.updateVisibility(editor.settings.get('showPreview')); | ||||
|         editor.settings.onChange('showPreview', show => this.updateVisibility(show)); | ||||
|         this.updateVisibility(Boolean(editor.settings.get('showPreview'))); | ||||
|         editor.settings.onChange('showPreview', (show) => this.updateVisibility(Boolean(show))); | ||||
|     } | ||||
| 
 | ||||
|     updateVisibility(show) { | ||||
|         const wrap = this.container.closest('.markdown-editor-wrap'); | ||||
|         wrap.style.display = show ? null : 'none'; | ||||
|     protected updateVisibility(show: boolean): void { | ||||
|         const wrap = this.container.closest('.markdown-editor-wrap') as HTMLElement; | ||||
|         wrap.style.display = show ? '' : 'none'; | ||||
|     } | ||||
| 
 | ||||
|     onLoad() { | ||||
|     protected onLoad(): void { | ||||
|         this.doc = this.container.contentDocument; | ||||
| 
 | ||||
|         if (!this.doc) return; | ||||
| 
 | ||||
|         this.loadStylesIntoDisplay(); | ||||
|         this.doc.body.className = 'page-content'; | ||||
| 
 | ||||
| @@ -37,20 +38,20 @@ export class Display { | ||||
|         this.doc.addEventListener('click', this.onDisplayClick.bind(this)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @param {MouseEvent} event | ||||
|      */ | ||||
|     onDisplayClick(event) { | ||||
|     protected onDisplayClick(event: MouseEvent): void { | ||||
|         const isDblClick = Date.now() - this.lastDisplayClick < 300; | ||||
| 
 | ||||
|         const link = event.target.closest('a'); | ||||
|         const link = (event.target as Element).closest('a'); | ||||
|         if (link !== null) { | ||||
|             event.preventDefault(); | ||||
|             window.open(link.getAttribute('href')); | ||||
|             const href = link.getAttribute('href'); | ||||
|             if (href) { | ||||
|                 window.open(href); | ||||
|             } | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const drawing = event.target.closest('[drawio-diagram]'); | ||||
|         const drawing = (event.target as Element).closest('[drawio-diagram]') as HTMLElement; | ||||
|         if (drawing !== null && isDblClick) { | ||||
|             this.editor.actions.editDrawing(drawing); | ||||
|             return; | ||||
| @@ -59,10 +60,12 @@ export class Display { | ||||
|         this.lastDisplayClick = Date.now(); | ||||
|     } | ||||
| 
 | ||||
|     loadStylesIntoDisplay() { | ||||
|     protected loadStylesIntoDisplay(): void { | ||||
|         if (!this.doc) return; | ||||
| 
 | ||||
|         this.doc.documentElement.classList.add('markdown-editor-display'); | ||||
| 
 | ||||
|         // Set display to be dark mode if parent is
 | ||||
|         // Set display to be dark mode if the parent is
 | ||||
|         if (document.documentElement.classList.contains('dark-mode')) { | ||||
|             this.doc.documentElement.style.backgroundColor = '#222'; | ||||
|             this.doc.documentElement.classList.add('dark-mode'); | ||||
| @@ -71,24 +74,25 @@ export class Display { | ||||
|         this.doc.head.innerHTML = ''; | ||||
|         const styles = document.head.querySelectorAll('style,link[rel=stylesheet]'); | ||||
|         for (const style of styles) { | ||||
|             const copy = style.cloneNode(true); | ||||
|             const copy = style.cloneNode(true) as HTMLElement; | ||||
|             this.doc.head.appendChild(copy); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Patch the display DOM with the given HTML content. | ||||
|      * @param {String} html | ||||
|      */ | ||||
|     patchWithHtml(html) { | ||||
|         const {body} = this.doc; | ||||
|     public patchWithHtml(html: string): void { | ||||
|         if (!this.doc) return; | ||||
| 
 | ||||
|         const { body } = this.doc; | ||||
| 
 | ||||
|         if (body.children.length === 0) { | ||||
|             const wrap = document.createElement('div'); | ||||
|             this.doc.body.append(wrap); | ||||
|         } | ||||
| 
 | ||||
|         const target = body.children[0]; | ||||
|         const target = body.children[0] as HTMLElement; | ||||
| 
 | ||||
|         patchDomFromHtmlString(target, html); | ||||
|     } | ||||
| @@ -96,14 +100,16 @@ export class Display { | ||||
|     /** | ||||
|      * Scroll to the given block index within the display content. | ||||
|      * Will scroll to the end if the index is -1. | ||||
|      * @param {Number} index | ||||
|      */ | ||||
|     scrollToIndex(index) { | ||||
|         const elems = this.doc.body?.children[0]?.children; | ||||
|         if (elems && elems.length <= index) return; | ||||
|     public scrollToIndex(index: number): void { | ||||
|         const elems = this.doc?.body?.children[0]?.children; | ||||
|         if (!elems || elems.length <= index) return; | ||||
| 
 | ||||
|         const topElem = (index === -1) ? elems[elems.length - 1] : elems[index]; | ||||
|         topElem.scrollIntoView({block: 'start', inline: 'nearest', behavior: 'smooth'}); | ||||
|         (topElem as Element).scrollIntoView({ | ||||
|             block: 'start', | ||||
|             inline: 'nearest', | ||||
|             behavior: 'smooth' | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @@ -1,23 +1,11 @@ | ||||
| import {provideKeyBindings} from './shortcuts'; | ||||
| import {debounce} from '../services/util.ts'; | ||||
| import {Clipboard} from '../services/clipboard.ts'; | ||||
| import {Clipboard} from "../services/clipboard"; | ||||
| import {MarkdownEditor} from "./index.mjs"; | ||||
| 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'); | ||||
| 
 | ||||
|     /** | ||||
|      * @param {ViewUpdate} v | ||||
|      */ | ||||
|     function onViewUpdate(v) { | ||||
|         if (v.docChanged) { | ||||
|             editor.actions.updateAndRender(); | ||||
|         } | ||||
|     } | ||||
| export type MarkdownEditorEventMap = Record<string, (event: any) => void>; | ||||
| 
 | ||||
| export function getMarkdownDomEventHandlers(editor: MarkdownEditor): MarkdownEditorEventMap { | ||||
| 
 | ||||
|     const onScrollDebounced = debounce(editor.actions.syncDisplayPosition.bind(editor.actions), 100, false); | ||||
|     let syncActive = editor.settings.get('scrollSync'); | ||||
| @@ -25,15 +13,19 @@ export async function init(editor) { | ||||
|         syncActive = val; | ||||
|     }); | ||||
| 
 | ||||
|     const domEventHandlers = { | ||||
|     return { | ||||
|         // Handle scroll to sync display view
 | ||||
|         scroll: event => syncActive && onScrollDebounced(event), | ||||
|         scroll: (event: Event) => syncActive && onScrollDebounced(event), | ||||
|         // Handle image & content drag n drop
 | ||||
|         drop: event => { | ||||
|         drop: (event: DragEvent) => { | ||||
|             if (!event.dataTransfer) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             const templateId = event.dataTransfer.getData('bookstack/template'); | ||||
|             if (templateId) { | ||||
|                 event.preventDefault(); | ||||
|                 editor.actions.insertTemplate(templateId, event.pageX, event.pageY); | ||||
|                 editor.actions.insertTemplate(templateId, event); | ||||
|             } | ||||
| 
 | ||||
|             const clipboard = new Clipboard(event.dataTransfer); | ||||
| @@ -41,16 +33,20 @@ export async function init(editor) { | ||||
|             if (clipboardImages.length > 0) { | ||||
|                 event.stopPropagation(); | ||||
|                 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
 | ||||
|         dragover: event => { | ||||
|         dragover: (event: DragEvent) => { | ||||
|             event.preventDefault(); | ||||
|         }, | ||||
|         // Handle image paste
 | ||||
|         paste: event => { | ||||
|             const clipboard = new Clipboard(event.clipboardData || event.dataTransfer); | ||||
|         paste: (event: ClipboardEvent) => { | ||||
|             if (!event.clipboardData) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             const clipboard = new Clipboard(event.clipboardData); | ||||
| 
 | ||||
|             // Don't handle the event ourselves if no items exist of contains table-looking data
 | ||||
|             if (!clipboard.hasItems() || clipboard.containsTabularData()) { | ||||
| @@ -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'; | ||||
| // @ts-ignore
 | ||||
| import mdTasksLists from 'markdown-it-task-lists'; | ||||
| 
 | ||||
| export class Markdown { | ||||
|     protected renderer: MarkdownIt; | ||||
| 
 | ||||
|     constructor() { | ||||
|         this.renderer = new MarkdownIt({html: true}); | ||||
| @@ -9,19 +11,16 @@ export class Markdown { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the front-end render used to convert markdown to HTML. | ||||
|      * @returns {MarkdownIt} | ||||
|      * Get the front-end render used to convert Markdown to HTML. | ||||
|      */ | ||||
|     getRenderer() { | ||||
|     getRenderer(): MarkdownIt { | ||||
|         return this.renderer; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Convert the given Markdown to HTML. | ||||
|      * @param {String} markdown | ||||
|      * @returns {String} | ||||
|      */ | ||||
|     render(markdown) { | ||||
|     render(markdown: string): string { | ||||
|         return this.renderer.render(markdown); | ||||
|     } | ||||
| 
 | ||||
| @@ -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. | ||||
|  * @param {MarkdownEditor} editor | ||||
|  * @returns {Object<String, Function>} | ||||
|  */ | ||||
| function provide(editor) { | ||||
|     const shortcuts = {}; | ||||
| export function provideShortcutMap(editor: MarkdownEditor): MarkdownEditorShortcutMap { | ||||
|     const shortcuts: MarkdownEditorShortcutMap = {}; | ||||
| 
 | ||||
|     // Insert Image shortcut
 | ||||
|     shortcuts['Shift-Mod-i'] = () => editor.actions.insertImage(); | ||||
| @@ -39,24 +41,3 @@ function provide(editor) { | ||||
| 
 | ||||
|     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. | ||||
|  * @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; | ||||
|     return function debouncedWrapper(this: any, ...args: any[]) { | ||||
|         const context: any = this; | ||||
| @@ -19,7 +19,7 @@ export function debounce(func: Function, waitMs: number, immediate: boolean): Fu | ||||
|         } | ||||
|         timeout = window.setTimeout(later, waitMs); | ||||
|         if (callNow) func.apply(context, args); | ||||
|     }; | ||||
|     } as T; | ||||
| } | ||||
|  | ||||
| function isDetailsElement(element: HTMLElement): element is HTMLDetailsElement { | ||||
|   | ||||
| @@ -175,7 +175,7 @@ export type NodeKey = string; | ||||
|  | ||||
| export class LexicalNode { | ||||
|   // Allow us to look up the type including static props | ||||
|   ['constructor']!: KlassConstructor<typeof LexicalNode>; | ||||
|   declare ['constructor']: KlassConstructor<typeof LexicalNode>; | ||||
|   /** @internal */ | ||||
|   __type: string; | ||||
|   /** @internal */ | ||||
|   | ||||
| @@ -24,7 +24,7 @@ export interface DecoratorNode<T> { | ||||
| /** @noInheritDoc */ | ||||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging | ||||
| export class DecoratorNode<T> extends LexicalNode { | ||||
|   ['constructor']!: KlassConstructor<typeof DecoratorNode<T>>; | ||||
|   declare ['constructor']: KlassConstructor<typeof DecoratorNode<T>>; | ||||
|   constructor(key?: NodeKey) { | ||||
|     super(key); | ||||
|   } | ||||
|   | ||||
| @@ -55,7 +55,7 @@ export interface ElementNode { | ||||
| /** @noInheritDoc */ | ||||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging | ||||
| export class ElementNode extends LexicalNode { | ||||
|   ['constructor']!: KlassConstructor<typeof ElementNode>; | ||||
|   declare ['constructor']: KlassConstructor<typeof ElementNode>; | ||||
|   /** @internal */ | ||||
|   __first: null | NodeKey; | ||||
|   /** @internal */ | ||||
|   | ||||
| @@ -22,7 +22,7 @@ export type SerializedLineBreakNode = SerializedLexicalNode; | ||||
|  | ||||
| /** @noInheritDoc */ | ||||
| export class LineBreakNode extends LexicalNode { | ||||
|   ['constructor']!: KlassConstructor<typeof LineBreakNode>; | ||||
|   declare ['constructor']: KlassConstructor<typeof LineBreakNode>; | ||||
|   static getType(): string { | ||||
|     return 'linebreak'; | ||||
|   } | ||||
|   | ||||
| @@ -44,7 +44,7 @@ export type SerializedParagraphNode = Spread< | ||||
|  | ||||
| /** @noInheritDoc */ | ||||
| export class ParagraphNode extends CommonBlockNode { | ||||
|   ['constructor']!: KlassConstructor<typeof ParagraphNode>; | ||||
|   declare ['constructor']: KlassConstructor<typeof ParagraphNode>; | ||||
|   /** @internal */ | ||||
|   __textFormat: number; | ||||
|   __textStyle: string; | ||||
|   | ||||
| @@ -284,7 +284,7 @@ export interface TextNode { | ||||
| /** @noInheritDoc */ | ||||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging | ||||
| export class TextNode extends LexicalNode { | ||||
|   ['constructor']!: KlassConstructor<typeof TextNode>; | ||||
|   declare ['constructor']: KlassConstructor<typeof TextNode>; | ||||
|   __text: string; | ||||
|   /** @internal */ | ||||
|   __format: number; | ||||
|   | ||||
| @@ -55,12 +55,17 @@ | ||||
|     font-style: normal; | ||||
|     font-weight: 400; | ||||
|     padding: vars.$xs vars.$m; | ||||
|     color: #444; | ||||
|     @include mixins.lightDark(color, #444, #aaa); | ||||
|     @include mixins.lightDark(background-color, #fff, #222); | ||||
|     border-radius: 0; | ||||
|     height: 100%; | ||||
|     font-size: 14px; | ||||
|     line-height: 1.2; | ||||
|     max-height: 100%; | ||||
|     flex: 1; | ||||
|     border: 0; | ||||
|     width: 100%; | ||||
|     margin: 0; | ||||
|     &:focus { | ||||
|       outline: 0; | ||||
|     } | ||||
|   | ||||
| @@ -26,6 +26,10 @@ | ||||
|                     <div class="px-m"> | ||||
|                         @include('form.custom-checkbox', ['name' => 'md-scrollSync', 'label' => trans('entities.pages_md_sync_scroll'), 'value' => true, 'checked' => true]) | ||||
|                     </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> | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|   "include": ["resources/js/**/*"], | ||||
|   "exclude": ["resources/js/wysiwyg/lexical/yjs/*"], | ||||
|   "compilerOptions": { | ||||
|     "target": "es2019", | ||||
|     "target": "es2022", | ||||
|     "module": "commonjs", | ||||
|     "rootDir": "./resources/js/", | ||||
|     "baseUrl": "./", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user