/** * **Markdownx** * * Frontend (JavaScript) management of Django-MarkdownX package. * * Written in JavaScript ECMA 2016, trans-compiled to ECMA 5 (2011). * * Requirements: * - Modern browser with support for HTML5 and ECMA 2011+ (IE 10+). Older browsers would work but some * features may be missing * - TypeScript 2 + * * JavaScript ECMA 5 files formatted as `.js` are trans-compiled files. Please do not edit such files as all * changes will be lost. Please modify `.ts` stored in `django-markdownx/markdownx/.static/markdownx/js` directory. * See **Contributions** in the documentations for additional instructions. * * @Copyright 2017 - Adi, Pouria Hadjibagheri. */ // Import, definitions and constant ------------------------------------------------------------------------------------ "use strict"; declare function docReady(args: any): any; interface ImageUploadResponse { image_code?: string, image_path?: string, [propName: string]: any; } interface HandlerFunction { (properties: { start: number, end: number, value: string }): string } interface KeyboardEvents { keys: { TAB: string, DUPLICATE: string, UNINDENT: string, INDENT: string }, handlers: { _multiLineIndentation: HandlerFunction, applyTab: HandlerFunction, applyIndentation: HandlerFunction, removeIndentation: HandlerFunction, removeTab: HandlerFunction, applyDuplication: HandlerFunction }, hub: Function } interface EventHandlers { inhibitDefault: Function, onDragEnter: Function } interface MarkdownxProperties { parent: HTMLElement, editor: HTMLTextAreaElement, preview: HTMLElement, _latency: number | null, _editorIsResizable: Boolean | null } import { Request, mountEvents, triggerEvent, preparePostData, triggerCustomEvent } from "./utils"; const UPLOAD_URL_ATTRIBUTE: string = "data-markdownx-upload-urls-path", PROCESSING_URL_ATTRIBUTE: string = "data-markdownx-urls-path", RESIZABILITY_ATTRIBUTE: string = "data-markdownx-editor-resizable", LATENCY_ATTRIBUTE: string = "data-markdownx-latency", LATENCY_MINIMUM: number = 500, // microseconds. XHR_RESPONSE_ERROR: string = "Invalid response", UPLOAD_START_OPACITY: string = "0.3", NORMAL_OPACITY: string = "1"; // --------------------------------------------------------------------------------------------------------------------- /** * */ const EventHandlers: EventHandlers = { /** * Routine tasks for event handlers (e.g. default preventions). * * @param {Event} event * @returns {Event} */ inhibitDefault: function (event: Event | KeyboardEvent): any { event.preventDefault(); event.stopPropagation(); return event }, /** * * @param {DragEvent} event * returns {Event} */ onDragEnter: function (event: DragEvent): Event { event.dataTransfer.dropEffect = 'copy'; return EventHandlers.inhibitDefault(event) } }; /** * */ const keyboardEvents: KeyboardEvents = { /** * Custom hotkeys. */ keys: { TAB: "Tab", DUPLICATE: "d", UNINDENT: "[", INDENT: "]" }, /** * Hotkey response functions. */ handlers: { /** * Smart application of tab indentations under various conditions. * * @param {JSON} properties * @returns {string} */ applyTab: function (properties) { // Do not replace with variables; this // feature is optimised for swift response. return properties.value .substring(0, properties.start) + // Preceding text. ( properties.value .substring(properties.start, properties.end) // Selected text .match(/\n/gm) === null ? // Not multi line? `\t${properties.value.substring(properties.start)}` : // Add `\t`. properties.value // Otherwise: .substring(properties.start, properties.end) .replace(/^/gm, '\t') + // Add `\t` to be beginning of each line. properties.value.substring(properties.end) // Succeeding text. ) }, /** * Smart removal of tab indentations. * * @param {JSON} properties * @returns {string} */ removeTab: function (properties) { let substitution: string = null, lineTotal: number = ( properties.value .substring( properties.start, properties.end ).match(/\n/g) || [] // Number of lines (\n) or empty array (zero). ).length; // Length of the array is equal to the number of lines. if (properties.start === properties.end) { // Replacing `\t` at a specific location // (+/- 1 chars) where there is no selection. properties.start = properties.start > 0 && properties.value[properties.start - 1] // -1 is to account any tabs just before the cursor. .match(/\t/) !== null ? // if there's no `\t`, check the preceding character. properties.start - 1 : properties.start; substitution = properties.value .substring(properties.start) .replace("\t", ''); // Remove only a single `\t`. } else if (!lineTotal) { // Replacing `\t` within a single line selection. substitution = properties.value .substring(properties.start) .replace("\t", '') } else { // Replacing `\t` in the beginning of each line // in a multi-line selection. substitution = properties.value.substring( properties.start, properties.end ).replace(/^\t/gm, '') + // Selection. properties.value.substring(properties.end); // After the selection } return properties.value .substring(0, properties.start) + // Text preceding to selection / cursor. substitution }, /** * Handles multi line indentations. * * @param {JSON} properties * @returns {string} * @private */ _multiLineIndentation: function (properties) { // Last line in the selection; regardless of // where of not the entire line is selected. const endLine: string = new RegExp(`(?:\n|.){0,${properties.end}}(^.*$)`, "m") .exec(properties.value)[1]; // Do not replace with variables; this // feature is optimised for swift response. return properties.value.substring( // First line of the selection, regardless of // whether or not the entire line is selected. properties.value.indexOf( new RegExp(`(?:\n|.){0,${properties.start}}(^.*$)`, "m") .exec(properties.value)[1] // Start line. ), ( // If there is a last line in a multi line selected // value where the last line is not empty or `\n`: properties.value.indexOf(endLine) ? // Location where the last line finishes with // respect to the entire value. properties.value.indexOf(endLine) + endLine.length : // Otherwise, where the selection ends. properties.end ) ); }, /** * Smart application of indentation at the beginning of the line. * * @param {JSON} properties * @returns {string} */ applyIndentation: function (properties) { // Single line? if (properties.start === properties.end) { // Current line, from the beginning to the end, regardless of any selections. const line: string = new RegExp(`(?:\n|.){0,${properties.start}}(^.+$)`, "m") .exec(properties.value)[1]; return properties.value.replace(line, `\t${line}`) } // Multi line const content: string = this._multiLineIndentation({ start: properties.start, end: properties.end, value: properties.value }); return properties.value .replace( content, // Existing contents. content.replace(/(^.+$)\n*/gmi, "\t$&") // Indented contents. ) }, /** * Smart removal of indentation from the beginning of the line. * * @param {JSON} properties * @returns {string} */ removeIndentation: function (properties) { // Single Line if (properties.start === properties.end) { // Entire line where the line immediately begins // with a one or more `\t`, regardless of any // selections. const line: string = new RegExp(`(?:\n|.){0,${properties.start}}(^\t.+$)`, "m") .exec(properties.value)[1]; return properties.value .replace( line, // Existing content. line.substring(1) // First character (necessarily a `\t`) removed. ) } // Multi line const content: string = this._multiLineIndentation({ start: properties.start, end: properties.end, value: properties.value }); return properties.value .replace( content, // Existing content. content.replace(/^\t(.+)\n*$/gmi, "$1") // A single `\t` removed from the beginning. ) }, /** * Duplication of the current or selected lines. * * @param {JSON} properties * @returns {string} */ applyDuplication: function (properties) { // With selection. // Do not replace with variables. This // feature is optimised for swift response. if (properties.start !== properties.end) return ( properties.value.substring( // Text preceding the selected area. 0, properties.start ) + properties.value.substring( // Selected area properties.start, properties.end ) + ( ~properties.value // First character before the cursor is linebreak? .charAt(properties.start - 1) .indexOf('\n') || // --> or ~properties.value // Character on the cursor is linebreak? .charAt(properties.start) .indexOf('\n') ? '\n' : '' // If either, add linebreak, otherwise add nothing. ) + properties.value.substring( // Selected area (again for duplication). properties.start, properties.end ) + properties.value.substring(properties.end) // Text succeeding the selected area. ); // Without selection. let pattern: RegExp = // Separate lines up to the end of the current line. new RegExp(`(?:.|\n){0,160}(^.*$)`, 'm'), line: string = ''; // Add anything found to the `line`. Note that // `replace` is used a simple hack; it functions // in a similar way to `regex.search` in Python. properties.value .replace(pattern, (match, p1) => line += p1); return properties.value .replace( line, // Existing line. `${line}\n${line}` // Doubled ... magic! ) }, }, /** * Mapping of hotkeys from keyboard events to their corresponding functions. * * @param {KeyboardEvent} event * @returns {Function | Boolean} */ hub: function (event: KeyboardEvent): Function | false { switch (event.key) { case this.keys.TAB: // Tab. // Shift pressed: un-indent, otherwise indent. return event.shiftKey ? this.handlers.removeTab : this.handlers.applyTab; case this.keys.DUPLICATE: // Line duplication. // Is CTRL or CMD (on Mac) pressed? return (event.ctrlKey || event.metaKey) ? this.handlers.applyDuplication : false; case this.keys.INDENT: // Indentation. // Is CTRL or CMD (on Mac) pressed? return (event.ctrlKey || event.metaKey) ? this.handlers.applyIndentation : false; case this.keys.UNINDENT: // Unindentation. // Is CTRL or CMD (on Mac) pressed? return (event.ctrlKey || event.metaKey) ? this.handlers.removeIndentation : false; default: // default would prevent the // inhibition of default settings. return false } } }; /** * Get either the height of an element as defined in style/CSS or its browser-computed height. * * @param {HTMLElement} element * @returns {number} */ function getHeight (element: HTMLElement): number { return Math.max( // Maximum of computed or set heights. parseInt(window.getComputedStyle(element).height), // Height is not set in styles. (parseInt(element.style.height) || 0) // Property's own height if set, otherwise 0. ) } /** * Update the height of an element based on its scroll height. * * @param {HTMLTextAreaElement} editor * @returns {HTMLTextAreaElement} */ function updateHeight(editor: HTMLTextAreaElement): HTMLTextAreaElement { // Ensure that the editor is resizable before anything else. // Change size if scroll is larger that height, otherwise do nothing. if (editor.scrollTop) editor.style.height = `${editor.scrollTop + getHeight(editor)}px`; return editor } /** * @example * * let element = document.getElementsByClassName('markdownx'); * * new MarkdownX( * element, * element.querySelector('.markdownx-editor'), * element.querySelector('.markdownx-preview') * ) * * @param {HTMLElement} parent - Markdown editor element. * @param {HTMLTextAreaElement} editor - Markdown editor element. * @param {HTMLElement} preview - Markdown preview element. */ const MarkdownX = function (parent: HTMLElement, editor: HTMLTextAreaElement, preview: HTMLElement): void { /** * MarkdownX properties. */ const properties: MarkdownxProperties = { editor: editor, preview: preview, parent: parent, _latency: null, _editorIsResizable: null }; /** * Initialisation settings (mounting events, retrieval of initial data, * setting animation properties, latency, timeout, and resizability). * * @private */ const _initialize = () => { this.timeout = null; // Events // ---------------------------------------------------------------------------------------------- let documentListeners = { object: document, listeners: [ { type: "drop" , capture: false, listener: EventHandlers.inhibitDefault }, { type: "dragover" , capture: false, listener: EventHandlers.inhibitDefault }, { type: "dragenter", capture: false, listener: EventHandlers.inhibitDefault }, { type: "dragleave", capture: false, listener: EventHandlers.inhibitDefault } ] }, editorListeners = { object: properties.editor, listeners: [ { type: "drop", capture: false, listener: onDrop }, { type: "input", capture: true , listener: inputChanged }, { type: "keydown", capture: true , listener: onKeyDown }, { type: "dragover", capture: false, listener: EventHandlers.onDragEnter }, { type: "dragenter", capture: false, listener: EventHandlers.onDragEnter }, { type: "dragleave", capture: false, listener: EventHandlers.inhibitDefault }, { type: "compositionstart", capture: true , listener: onKeyDown } ] }; // Initialise // -------------------------------------------------------- // Mounting the defined events. mountEvents(editorListeners, documentListeners); properties.editor.setAttribute('data-markdownx-init', ''); // Set animation for image uploads lock down. properties.editor.style.transition = "opacity 1s ease"; properties.editor.style.webkitTransition = "opacity 1s ease"; // Upload latency - must be a value >= 500 microseconds. properties._latency = Math.max(parseInt(properties.editor.getAttribute(LATENCY_ATTRIBUTE)) || 0, LATENCY_MINIMUM); // If `true`, the editor will expand to scrollHeight when needed. properties._editorIsResizable = ( (properties.editor.getAttribute(RESIZABILITY_ATTRIBUTE).match(/true/i) || []).length > 0 && properties.editor.offsetHeight > 0 && properties.editor.offsetWidth > 0 ); getMarkdown(); triggerCustomEvent("markdownx.init"); }; /** * settings for `timeout`. * * @private */ const _markdownify = (): void => { clearTimeout(this.timeout); this.timeout = setTimeout(getMarkdown, properties._latency) }; /** * Handling changes in the editor. */ const inputChanged = (): void => { properties.editor = properties._editorIsResizable ? updateHeight(properties.editor) : properties.editor; return _markdownify() }; /** * Handling of drop events (when a file is dropped into `properties.editor`). * * @param {DragEvent} event */ const onDrop = (event: DragEvent): void => { if (event.dataTransfer && event.dataTransfer.files.length) Object.keys(event.dataTransfer.files).map(fileKey => sendFile(event.dataTransfer.files[fileKey]) ); EventHandlers.inhibitDefault(event); }; /** * Handling of keyboard events (i.e. primarily hotkeys). * * @param {KeyboardEvent} event * @returns {Boolean | null} */ const onKeyDown = (event: KeyboardEvent): Boolean | null => { const handlerFunc: Function | Boolean = keyboardEvents.hub(event); if (typeof handlerFunc != 'function') return false; EventHandlers.inhibitDefault(event); // Holding the start location before anything changes. const SELECTION_START: number = properties.editor.selectionStart; properties.editor.value = handlerFunc({ start: properties.editor.selectionStart, end: properties.editor.selectionEnd, value: properties.editor.value }); _markdownify(); properties.editor.focus(); // Set the cursor location to the start location of the selection. properties.editor.selectionEnd = properties.editor.selectionStart = SELECTION_START; return false }; /** * Uploading the `file` onto the server through an AJAX request. * * @param {File} file */ const sendFile = (file: File) => { properties.editor.style.opacity = UPLOAD_START_OPACITY; const xhr = new Request( properties.editor.getAttribute(UPLOAD_URL_ATTRIBUTE), // URL preparePostData({image: file}) // Data ); xhr.success = (resp: string): void | null => { const response: ImageUploadResponse = JSON.parse(resp); if (response.image_code) { insertImage(response.image_code); triggerCustomEvent('markdownx.fileUploadEnd', properties.parent, [response]) } else if (response.image_path) { // ToDo: Deprecate. insertImage(`![]("${response.image_path}")`); triggerCustomEvent('markdownx.fileUploadEnd', properties.parent, [response]) } else { console.error(XHR_RESPONSE_ERROR, response); triggerCustomEvent('markdownx.fileUploadError', properties.parent, [response]); insertImage(XHR_RESPONSE_ERROR); } properties.editor.style.opacity = NORMAL_OPACITY; }; xhr.error = (response: any): void => { console.error(response); triggerCustomEvent('fileUploadError', properties.parent, [response]); insertImage(XHR_RESPONSE_ERROR); properties.editor.style.opacity = NORMAL_OPACITY; }; return xhr.send() }; /** * Uploading the markdown text from `properties.editor` onto the server * through an AJAX request, and upon receiving the HTML encoded text * in response, the response will be display in `properties.preview`. */ const getMarkdown = () => { const xhr = new Request( properties.editor.getAttribute(PROCESSING_URL_ATTRIBUTE), // URL preparePostData({content: properties.editor.value}) // Data ); xhr.success = (response: string): void => { properties.preview.innerHTML = response; properties.editor = updateHeight(properties.editor); triggerCustomEvent('markdownx.update', properties.parent, [response]) }; xhr.error = (response: any): void => { console.error(response); triggerCustomEvent('markdownx.updateError', properties.parent, [response]) }; return xhr.send() }; /** * Inserts markdown encoded image URL into `properties.editor` where * the cursor is located. * * @param textToInsert */ const insertImage = (textToInsert: string): void => { properties.editor.value = `${properties.editor.value.substring(0, properties.editor.selectionStart)}` + // Preceding text. textToInsert + `${properties.editor.value.substring(properties.editor.selectionEnd)}`; // Succeeding text. properties.editor.selectionStart = properties.editor.selectionEnd = properties.editor.selectionStart + textToInsert.length; triggerEvent(properties.editor, 'keyup'); inputChanged(); }; _initialize(); }; (function(funcName: any, baseObj: any) { // The public function name defaults to window.docReady // but you can pass in your own object and own function // name and those will be used. // if you want to put them in a different namespace funcName = funcName || "docReady"; baseObj = baseObj || window; let readyList = [], readyFired = false, readyEventHandlersInstalled = false; /** * Called when the document is ready. This function protects itself * against being called more than once. */ const ready = () => { if (!readyFired) { // Must be `true` before the callbacks are called. readyFired = true; // if a callback here happens to add new ready handlers, // the docReady() function will see that it already fired // and will schedule the callback to run right after // this event loop finishes so all handlers will still execute // in order and no new ones will be added to the readyList // while we are processing the list readyList.map(ready => ready.fn.call(window, ready.ctx)); // allow any closures held by these functions to free readyList = []; } }; const readyStateChange = () => document.readyState === "complete" ? ready() : null; // This is the one public interface // docReady(fn, context); // the context argument is optional - if present, it will be passed // as an argument to the callback baseObj[funcName] = (callback, context) => { // if ready has already fired, then just schedule the callback // to fire asynchronously, but right away if (readyFired) { setTimeout(() => callback(context), 1); return; } else { // add the function and context to the list readyList.push({fn: callback, ctx: context}); } // If the document is already ready, schedule the ready // function to run. if (document.readyState === "complete") { setTimeout(ready, 1); } else if (!readyEventHandlersInstalled) { // otherwise if we don't have event handlers installed, // install them first choice is DOMContentLoaded event. document.addEventListener("DOMContentLoaded", ready, false); // backup is window load event window.addEventListener("load", ready, false); readyEventHandlersInstalled = true; } } })("docReady", window); docReady(() => { const ELEMENTS = document.getElementsByClassName('markdownx'); return Object.keys(ELEMENTS).map(key => { let element = ELEMENTS[key], editor = element.querySelector('.markdownx-editor'), preview = element.querySelector('.markdownx-preview'); // Only add the new MarkdownX instance to fields that have no MarkdownX instance yet. if (!editor.hasAttribute('data-markdownx-init')) return new MarkdownX(element, editor, preview) }); }); export { MarkdownX };