From 28c7f7ce78dbf574027106ac191ade7b66c656c2 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Wed, 28 Feb 2024 16:04:56 +0000 Subject: [PATCH] refactor!: restructure the markdown editor This change significantly impacts the organisation and structure of the markdown editor, starting to utilise Blazor (SignalR) to perform operations such as saving, removing the need for an API controller. Much of the TypeScript source has been more coherently decoupled, for example UI vs business logic is now independent. --- .../Controllers/Api/v1/BlogPostController.cs | 59 ---- OliverBooth/Pages/Admin/EditBlogPost.cshtml | 12 +- .../Pages/Components/MarkdownEditor.razor | 37 +++ OliverBooth/Pages/_ViewImports.cshtml | 2 + src/scss/admin.scss | 4 +- src/ts/admin/AdminUI.ts | 62 ----- src/ts/admin/EditBlogPost.ts | 54 ++-- src/ts/admin/HTMLTextAreaElement.d.ts | 3 + src/ts/admin/Interop.ts | 23 ++ .../admin/MarkdownEditor/KeyboardShortcut.ts | 42 +++ src/ts/admin/MarkdownEditor/MarkdownEditor.ts | 254 ++++++++++++++++++ .../MarkdownEditor/MarkdownFormatting.ts | 9 + src/ts/admin/MarkdownEditor/Prism.d.ts | 13 + src/ts/admin/MarkdownEditor/SaveButtonMode.ts | 28 ++ src/ts/admin/MarkdownEditor/UI.ts | 107 ++++++++ src/ts/admin/Prototypes.ts | 3 + src/ts/admin/TabSupport.ts | 71 +---- src/ts/admin/admin.ts | 2 +- 18 files changed, 551 insertions(+), 234 deletions(-) delete mode 100644 OliverBooth/Controllers/Api/v1/BlogPostController.cs create mode 100644 OliverBooth/Pages/Components/MarkdownEditor.razor delete mode 100644 src/ts/admin/AdminUI.ts create mode 100644 src/ts/admin/HTMLTextAreaElement.d.ts create mode 100644 src/ts/admin/Interop.ts create mode 100644 src/ts/admin/MarkdownEditor/KeyboardShortcut.ts create mode 100644 src/ts/admin/MarkdownEditor/MarkdownEditor.ts create mode 100644 src/ts/admin/MarkdownEditor/MarkdownFormatting.ts create mode 100644 src/ts/admin/MarkdownEditor/Prism.d.ts create mode 100644 src/ts/admin/MarkdownEditor/SaveButtonMode.ts create mode 100644 src/ts/admin/MarkdownEditor/UI.ts create mode 100644 src/ts/admin/Prototypes.ts diff --git a/OliverBooth/Controllers/Api/v1/BlogPostController.cs b/OliverBooth/Controllers/Api/v1/BlogPostController.cs deleted file mode 100644 index 75066c9..0000000 --- a/OliverBooth/Controllers/Api/v1/BlogPostController.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Asp.Versioning; -using Microsoft.AspNetCore.Mvc; -using OliverBooth.Data.Blog; -using OliverBooth.Data.Web; -using OliverBooth.Services; - -namespace OliverBooth.Controllers.Api.v1; - -[ApiController] -[Route("api/v{version:apiVersion}/post")] -[ApiVersion(1)] -[Produces("application/json")] -public sealed class BlogPostController : ControllerBase -{ - private readonly ILogger _logger; - private readonly ISessionService _sessionService; - private readonly IBlogPostService _blogPostService; - - public BlogPostController(ILogger logger, - ISessionService sessionService, - IBlogPostService blogPostService) - { - _logger = logger; - _sessionService = sessionService; - _blogPostService = blogPostService; - } - - [HttpPatch("{id:guid}")] - public async Task OnPatch([FromRoute] Guid id) - { - if (!_sessionService.TryGetCurrentUser(Request, Response, out IUser? user)) - { - Response.StatusCode = 401; - return new JsonResult(new { status = 401, message = "Unauthorized" }); - } - - if (!_blogPostService.TryGetPost(id, out IBlogPost? post)) - { - Response.StatusCode = 404; - return new JsonResult(new { status = 404, message = "Not Found" }); - } - - var body = await JsonSerializer.DeserializeAsync>(Request.Body); - if (body is null) - { - Response.StatusCode = 400; - return new JsonResult(new { status = 400, message = "Bad Request" }); - } - - post.Body = body["content"]; - post.Title = body["title"]; - _blogPostService.UpdatePost(post); - - return new JsonResult(new { status = 200, message = "OK" }); - } -} diff --git a/OliverBooth/Pages/Admin/EditBlogPost.cshtml b/OliverBooth/Pages/Admin/EditBlogPost.cshtml index a559ede..42d9dd9 100644 --- a/OliverBooth/Pages/Admin/EditBlogPost.cshtml +++ b/OliverBooth/Pages/Admin/EditBlogPost.cshtml @@ -10,6 +10,8 @@ IBlogPost post = Model.BlogPost; } + +
@@ -21,11 +23,15 @@ - + - +
Post ID + +
Title + +
@@ -40,4 +46,4 @@
- + \ No newline at end of file diff --git a/OliverBooth/Pages/Components/MarkdownEditor.razor b/OliverBooth/Pages/Components/MarkdownEditor.razor new file mode 100644 index 0000000..59f7595 --- /dev/null +++ b/OliverBooth/Pages/Components/MarkdownEditor.razor @@ -0,0 +1,37 @@ +@using OliverBooth.Services +@using OliverBooth.Data.Blog +@implements IDisposable +@inject IBlogPostService BlogPostService +@inject IJSRuntime JsRuntime + +@code { + private DotNetObjectReference? _dotNetHelper; + + [JSInvokable] + public void Save(Guid id, string content) + { + if (!BlogPostService.TryGetPost(id, out IBlogPost? post)) + { + return; + } + + post.Body = content; + BlogPostService.UpdatePost(post); + } + + public void Dispose() + { + _dotNetHelper?.Dispose(); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _dotNetHelper = DotNetObjectReference.Create(this); + await JsRuntime.InvokeVoidAsync("Interop.setDotNetHelper", _dotNetHelper); + } + } + +} \ No newline at end of file diff --git a/OliverBooth/Pages/_ViewImports.cshtml b/OliverBooth/Pages/_ViewImports.cshtml index 47d742e..0f729c9 100644 --- a/OliverBooth/Pages/_ViewImports.cshtml +++ b/OliverBooth/Pages/_ViewImports.cshtml @@ -1,2 +1,4 @@ @namespace OliverBooth.Pages +@using Microsoft.AspNetCore.Components.Web +@using OliverBooth.Pages.Components @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers \ No newline at end of file diff --git a/src/scss/admin.scss b/src/scss/admin.scss index db97261..89a0a92 100644 --- a/src/scss/admin.scss +++ b/src/scss/admin.scss @@ -74,7 +74,6 @@ textarea { border: 0; width: 100%; height: 100%; - position: relative; margin: 0; } @@ -95,7 +94,6 @@ textarea { background: transparent; caret-color: #FFFFFF; resize: none; - overflow-wrap: normal; overflow-x: scroll; white-space: pre; @@ -122,7 +120,7 @@ textarea { .highlighting-container .code-toolbar { height: 100%; - + .toolbar { display: none; } diff --git a/src/ts/admin/AdminUI.ts b/src/ts/admin/AdminUI.ts deleted file mode 100644 index 33f799d..0000000 --- a/src/ts/admin/AdminUI.ts +++ /dev/null @@ -1,62 +0,0 @@ -import adminUI from "./AdminUI"; - -declare const Prism: any; -class AdminUI { - static highlightingContent: HTMLElement; - static highlighting: HTMLElement; - static _content: HTMLTextAreaElement; - static _saveButton: HTMLButtonElement; - - static init() { - const content = AdminUI.content; - AdminUI.highlightingContent = document.getElementById("highlighting-content"); - AdminUI.highlighting = document.getElementById("highlighting"); - content.addEventListener("input", () => AdminUI.updateEditView()); - content.addEventListener("scroll", () => AdminUI.syncEditorScroll()); - } - - public static get content() { - if (!AdminUI._content) { - AdminUI._content = document.getElementById("content") as HTMLTextAreaElement; - } - - return AdminUI._content; - } - - public static get saveButton() { - if (!AdminUI._saveButton) { - AdminUI._saveButton = document.getElementById("save-button") as HTMLButtonElement; - } - - return AdminUI._saveButton; - } - - public static updateEditView() { - AdminUI.highlightingContent.innerHTML = Prism.highlight(AdminUI.content.value, Prism.languages.markdown); - - document.querySelectorAll("#highlighting-content span.token.code").forEach(el => { - const languageSpan = el.querySelector(".code-language") as HTMLSpanElement; - if (!languageSpan) { - return; - } - - const language = languageSpan.innerText; - const span = el.querySelector(".code-block"); - if (!span) { - return; - } - - span.outerHTML = `${span.innerHTML}`; - Prism.highlightAllUnder(languageSpan.parentElement); - }); - - AdminUI.syncEditorScroll(); - } - - static syncEditorScroll() { - AdminUI.highlighting.scrollTop = AdminUI._content.scrollTop; - AdminUI.highlighting.scrollLeft = AdminUI._content.scrollLeft; - } -} - -export default AdminUI; \ No newline at end of file diff --git a/src/ts/admin/EditBlogPost.ts b/src/ts/admin/EditBlogPost.ts index fbcc96e..4156d4e 100644 --- a/src/ts/admin/EditBlogPost.ts +++ b/src/ts/admin/EditBlogPost.ts @@ -1,8 +1,10 @@ import BlogPost from "../app/BlogPost"; +import UI from "./MarkdownEditor/UI" import API from "../app/API"; -import UI from "../app/UI"; -import AdminUI from "./AdminUI"; import "./TabSupport" +import Interop from "./Interop"; +import MarkdownEditor from "./MarkdownEditor/MarkdownEditor"; +import SaveButtonMode from "./MarkdownEditor/SaveButtonMode"; (() => { getCurrentBlogPost().then(post => { @@ -10,48 +12,24 @@ import "./TabSupport" return; } - AdminUI.init(); + UI.init(); + UI.addSaveButtonListener(savePost); - const preview = document.getElementById("article-preview") as HTMLAnchorElement; - const title = document.getElementById("post-title") as HTMLInputElement; - - AdminUI.saveButton.addEventListener("click", async (e: MouseEvent) => { - await savePost(); - }); - - document.addEventListener("keydown", async (e: KeyboardEvent) => { - if (e.ctrlKey && e.key === "s") { - e.preventDefault(); - await savePost(); - preview.innerHTML = post.content; - UI.updateUI(preview); - // Prism.highlightAllUnder(preview); - } - }); + const editor = new MarkdownEditor(UI.markdownInput); + editor.addSaveListener(savePost); + editor.registerDefaultShortcuts(); + editor.registerEvents(); async function savePost(): Promise { - const saveButton = AdminUI.saveButton; - saveButton.classList.add("btn-primary"); - saveButton.classList.remove("btn-success"); + UI.setSaveButtonMode(SaveButtonMode.SAVING); + await Interop.invoke("Save", post.id, UI.markdownInput.value); + post = await API.getBlogPost(post.id); + UI.setSaveButtonMode(SaveButtonMode.SAVED); - saveButton.setAttribute("disabled", "disabled"); - saveButton.innerHTML = ' Saving ...'; - - post = await API.updatePost(post, {content: AdminUI.content.value, title: title.value}); - - saveButton.classList.add("btn-success"); - saveButton.classList.remove("btn-primary"); - saveButton.removeAttribute("disabled"); - saveButton.innerHTML = ' Saved'; - - setTimeout(() => { - saveButton.classList.add("btn-primary"); - saveButton.classList.remove("btn-success"); - saveButton.innerHTML = ' Save (Ctrl+S)'; - }, 2000); + setTimeout(() => UI.setSaveButtonMode(SaveButtonMode.NORMAL), 2000); } - AdminUI.updateEditView(); + UI.redraw(); }); async function getCurrentBlogPost(): Promise { diff --git a/src/ts/admin/HTMLTextAreaElement.d.ts b/src/ts/admin/HTMLTextAreaElement.d.ts new file mode 100644 index 0000000..2030c41 --- /dev/null +++ b/src/ts/admin/HTMLTextAreaElement.d.ts @@ -0,0 +1,3 @@ +declare interface HTMLTextAreaElement { + insertAt(text: string, position: number) : void; +} diff --git a/src/ts/admin/Interop.ts b/src/ts/admin/Interop.ts new file mode 100644 index 0000000..77b78ac --- /dev/null +++ b/src/ts/admin/Interop.ts @@ -0,0 +1,23 @@ +declare interface DotNetHelper { + invokeMethodAsync: (methodName: string, ...args: any) => Promise; +} + +class Interop { + private static _dotNetHelper: DotNetHelper; + + public static invoke(methodName: string, ...args: any): Promise { + return Interop.dotNetHelper.invokeMethodAsync(methodName, ...args); + } + + public static get dotNetHelper() { + return this._dotNetHelper; + } + + static setDotNetHelper(value: DotNetHelper) { + this._dotNetHelper = value; + } +} + +// @ts-ignore +window.Interop = Interop; +export default Interop; \ No newline at end of file diff --git a/src/ts/admin/MarkdownEditor/KeyboardShortcut.ts b/src/ts/admin/MarkdownEditor/KeyboardShortcut.ts new file mode 100644 index 0000000..cc9f4d2 --- /dev/null +++ b/src/ts/admin/MarkdownEditor/KeyboardShortcut.ts @@ -0,0 +1,42 @@ +/** + * Represents a keyboard modifier. + */ +export enum KeyboardModifier { + CTRL = 1, + SHIFT = 2, + ALT = 4 +} + +/** + * Represents a keyboard shortcut. + */ +export class KeyboardShortcut { + private readonly _key: string; + private readonly _modifier: KeyboardModifier; + + /** + * Initializes a new instance of the {@link KeyboardShortcut} class. + * @param key The key. + * @param modifier The modifier, if any. + */ + constructor(key: string, modifier?: KeyboardModifier) { + this._key = key; + this._modifier = modifier; + } + + /** + * Gets the key for this keyboard shortcut. + */ + public get key(): string { + return this._key; + } + + /** + * Gets the modifier for this keyboard shortcut. + */ + public get modifier(): KeyboardModifier { + return this._modifier; + } +} + +export default {KeyboardShortcut, KeyboardModifier}; \ No newline at end of file diff --git a/src/ts/admin/MarkdownEditor/MarkdownEditor.ts b/src/ts/admin/MarkdownEditor/MarkdownEditor.ts new file mode 100644 index 0000000..7a82ac7 --- /dev/null +++ b/src/ts/admin/MarkdownEditor/MarkdownEditor.ts @@ -0,0 +1,254 @@ +import "../Prototypes"; +import {KeyboardModifier, KeyboardShortcut} from "./KeyboardShortcut"; +import MarkdownFormatting from "./MarkdownFormatting"; +import UI from "./UI"; + +/** + * Represents a class which implements the Markdown editor and its controls. + */ +class MarkdownEditor { + private readonly _element: HTMLTextAreaElement; + private _nextSelection?: number[]; + private _saveListeners: Function[] = []; + + /** + * Initializes a new instance of the {@link MarkdownEditor} class. + * @param element The editor's {@link HTMLTextAreaElement}. + */ + constructor(element: HTMLTextAreaElement) { + this._element = element; + } + + /** + * Adds a callback that will be invoked when the save shortcut is executed. + * @param callback The callback to invoke. + */ + public addSaveListener(callback: Function) { + if (callback) { + this._saveListeners.push(callback); + } + } + + /** + * Applies Markdown formatting to the current selection. + * @param formatting The formatting to apply. + */ + public formatSelection(formatting: MarkdownFormatting): void { + if (!formatting) { + return; + } + + const el: HTMLTextAreaElement = this._element; + const selectionStart: number = el.selectionStart; + const selectionEnd: number = el.selectionEnd; + + const formatString = formatting.toString(); + const len = formatString.length; + + el.insertAt(formatString, selectionStart); + el.insertAt(formatString, selectionEnd + len); + + el.selectionStart = selectionStart + len; + el.selectionEnd = selectionEnd + len; + + UI.redraw(); + } + + /** + * Inserts a Markdown hyperlink at the current caret position. + */ + public insertLink(): void { + const el: HTMLTextAreaElement = this._element; + const selectionStart: number = el.selectionStart; + const selectionEnd: number = el.selectionEnd; + + if (selectionStart === selectionEnd) { + el.insertAt("[]()", selectionStart); + el.selectionStart = selectionStart + 1; + el.selectionEnd = selectionStart + 7; + + this._nextSelection = [2, 5]; + } else { + el.insertAt("[", selectionStart); + el.insertAt("]()", selectionEnd + 1); + el.selectionStart = selectionEnd + 3; + el.selectionEnd = el.selectionStart + 5; + } + + UI.redraw(); + } + + /** + * Pastes the current clipboard content into the editor. + */ + public async pasteClipboard(event: ClipboardEvent): Promise { + const files: FileList = event.clipboardData.files; + if (files.length) { + const file: File = files[0]; + const data: ArrayBuffer = await file.arrayBuffer(); + console.log(data); + } + + console.log(files); + } + + /** + * Registers all default keyboard shortcuts for the editor. + */ + public registerDefaultShortcuts() { + this.registerShortcut(new KeyboardShortcut('b', KeyboardModifier.CTRL), () => this.formatSelection(MarkdownFormatting.BOLD)); + this.registerShortcut(new KeyboardShortcut('i', KeyboardModifier.CTRL), () => this.formatSelection(MarkdownFormatting.ITALIC)); + this.registerShortcut(new KeyboardShortcut('k', KeyboardModifier.CTRL), () => this.insertLink()); + this.registerShortcut(new KeyboardShortcut('s', KeyboardModifier.CTRL), () => this.save()); + + this._element.addEventListener("paste", (ev: ClipboardEvent) => this.pasteClipboard(ev)); + } + + /** + * Registers a new keyboard shortcut. + * @param shortcut The keyboard shortcut for which to listen. + * @param action The action to invoke when the shortcut is executed. + */ + public registerShortcut(shortcut: KeyboardShortcut, action: Function): void { + if (!shortcut) throw new Error("No shortcut provided"); + if (!action) throw new Error("No callback function provided"); + + this._element.addEventListener("keydown", (ev: KeyboardEvent) => { + if ((shortcut.modifier & KeyboardModifier.CTRL) && !ev.ctrlKey) { + return; + } + + if ((shortcut.modifier & KeyboardModifier.SHIFT) && !ev.shiftKey) { + return; + } + + if ((shortcut.modifier & KeyboardModifier.ALT) && !ev.altKey) { + return; + } + + if (ev.key == shortcut.key) { + ev.preventDefault(); + action(); + } + }); + + console.log("Bound shortcut", shortcut, "to", action); + } + + public registerEvents() { + this.handleQuoteNewLine(); + this.handleTab(); + } + + /** + * Saves the content in the editor. + */ + public save() { + this._saveListeners.forEach(fn => fn()); + UI.redraw(); + } + + private handleTab() { + const el = this._element; + el.addEventListener("keydown", (ev: KeyboardEvent) => { + if (ev.key !== "Tab") { + return; + } + + ev.preventDefault(); + + if (this._nextSelection) { + el.selectionStart += this._nextSelection[0]; + el.selectionEnd = el.selectionEnd + this._nextSelection[1]; + return; + } + + let text = el.value; + let selStart = el.selectionStart; + let selEnd = el.selectionEnd; + + // selection? + if (selStart === selEnd) { + // These single character operations are undoable + if (ev.shiftKey) { + text = el.value; + if (selStart > 0 && (text[selStart - 1] === '\t' || text.slice(selStart - 4, selStart) === " ")) { + document.execCommand("delete"); + } + } else { + document.execCommand("insertText", false, " "); + } + } else { + // Block indent/unindent trashes undo stack. + // Select whole lines + text = el.value; + while (selStart > 0 && text[selStart - 1] !== '\n') + selStart--; + while (selEnd > 0 && text[selEnd - 1] !== '\n' && selEnd < text.length) + selEnd++; + + // Get selected text + let lines = text.slice(selStart, selEnd - selStart).split('\n'); + + // Insert tabs + for (let i = 0; i < lines.length; i++) { + // Don't indent last line if cursor at start of line + if (i == lines.length - 1 && lines[i].length == 0) + continue; + + // Tab or Shift+Tab? + if (ev.shiftKey) { + if (lines[i].startsWith('\t')) + lines[i] = lines[i].slice(1); + else if (lines[i].startsWith(" ")) + lines[i] = lines[i].slice(4); + } else { + lines[i] = ` ${lines[i]}`; + } + } + + const result = lines.join('\n'); + + // Update the text area + el.value = text.slice(0, selStart) + result + text.slice(selEnd); + el.selectionStart = selStart; + el.selectionEnd = selStart + lines.length; + + UI.redraw(); + } + }); + } + + private handleQuoteNewLine() { + const el = this._element; + el.addEventListener("keydown", (ev: KeyboardEvent) => { + if (ev.key !== "Enter") { + return; + } + + const selectionStart = el.selectionStart; + const selectionEnd = el.selectionEnd; + + let lineStart = selectionStart; + while (lineStart > 0 && el.value[lineStart - 1] !== '\n') { + lineStart--; + } + + const lineEnd = el.value.indexOf('\n', lineStart); + const line = el.value.slice(lineStart, lineEnd); + + if (line.startsWith("> ")) { + ev.preventDefault(); + + el.insertAt("\n> ", lineEnd); + el.blur(); + el.focus(); + el.selectionEnd = selectionEnd + 3; + + UI.redraw(); + } + }); + } +} + +export default MarkdownEditor; \ No newline at end of file diff --git a/src/ts/admin/MarkdownEditor/MarkdownFormatting.ts b/src/ts/admin/MarkdownEditor/MarkdownFormatting.ts new file mode 100644 index 0000000..0435fe5 --- /dev/null +++ b/src/ts/admin/MarkdownEditor/MarkdownFormatting.ts @@ -0,0 +1,9 @@ +/** + * An enumeration of Markdown formatting strings. + */ +enum MarkdownFormatting { + BOLD = "**", + ITALIC = "*" +} + +export default MarkdownFormatting; \ No newline at end of file diff --git a/src/ts/admin/MarkdownEditor/Prism.d.ts b/src/ts/admin/MarkdownEditor/Prism.d.ts new file mode 100644 index 0000000..b3e9cc8 --- /dev/null +++ b/src/ts/admin/MarkdownEditor/Prism.d.ts @@ -0,0 +1,13 @@ +declare interface PrismLanguage { +} + +declare interface Prism { + languages: { + markdown: PrismLanguage + }; + + highlight(markdown: string, language: PrismLanguage): string; + highlightAllUnder(element: HTMLElement): void; +} + +declare const Prism: Prism; \ No newline at end of file diff --git a/src/ts/admin/MarkdownEditor/SaveButtonMode.ts b/src/ts/admin/MarkdownEditor/SaveButtonMode.ts new file mode 100644 index 0000000..62b7f15 --- /dev/null +++ b/src/ts/admin/MarkdownEditor/SaveButtonMode.ts @@ -0,0 +1,28 @@ +/** + * An enumeration of states that the save button can be in. + */ +enum SaveButtonMode { + /** + * The save button should give feedback that the save process failed. + */ + FAILURE, + + /** + * The save button should draw as normal. + */ + NORMAL, + + /** + * The save button should give feedback that the save process is in progress. + */ + SAVING, + + /** + * The save button should give feedback that the save process completed. + */ + SAVED, + + _UNUSED +} + +export default SaveButtonMode; \ No newline at end of file diff --git a/src/ts/admin/MarkdownEditor/UI.ts b/src/ts/admin/MarkdownEditor/UI.ts new file mode 100644 index 0000000..02beb01 --- /dev/null +++ b/src/ts/admin/MarkdownEditor/UI.ts @@ -0,0 +1,107 @@ +import SaveButtonMode from "./SaveButtonMode"; + +/** + * Represents a class which interacts with the editor DOM. + */ +class UI { + private static readonly SB_ColorClasses = ["danger", "primary", "primary", "success"]; + private static readonly SB_IconClasses = ["exclamation", "floppy-disk", "spinner fa-spin", "circle-check"]; + private static readonly SB_Text = ["Error Saving", "Save (Ctrl+S)", "Saving ...", "Saved"]; + + private static _highlightingContent: HTMLElement; + private static _highlighting: HTMLElement; + private static _content: HTMLTextAreaElement; + private static _saveButton: HTMLButtonElement; + + private static _saveCallbacks: Function[] = []; + + /** + * Returns the {@link HTMLTextAreaElement} where the Markdown is inputted by the user. + */ + public static get markdownInput(): HTMLTextAreaElement { + return UI._content; + } + + /** + * Adds a callback that will be invoked when the save button is pressed. + * @param callback The callback to invoke. + */ + public static addSaveButtonListener(callback: Function) { + if (!callback) { + return; + } + + this._saveCallbacks.push(callback); + } + + /** + * Initializes the editor's user interface. + */ + public static init() { + const content = UI._content; + UI._highlightingContent = document.getElementById("highlighting-content"); + UI._highlighting = document.getElementById("highlighting"); + content.addEventListener("input", () => UI.redraw()); + content.addEventListener("scroll", () => UI.syncEditorScroll()); + + UI._saveButton.addEventListener("click", (_: MouseEvent) => { + UI._saveCallbacks.forEach(fn => fn()); + }); + } + + /** + * Redraws the Markdown editor UI. + */ + public static redraw() { + UI._highlightingContent.innerHTML = Prism.highlight(UI._content.value, Prism.languages.markdown); + + document.querySelectorAll("#highlighting-content span.token.code").forEach(el => { + const languageSpan = el.querySelector(".code-language") as HTMLSpanElement; + if (!languageSpan) { + return; + } + + const language = languageSpan.innerText; + const span = el.querySelector(".code-block"); + if (!span) { + return; + } + + span.outerHTML = `${span.innerHTML}`; + Prism.highlightAllUnder(languageSpan.parentElement); + }); + + UI.syncEditorScroll(); + } + + /** + * Sets the display mode of the save button. + * @param mode The display mode. + */ + public static setSaveButtonMode(mode: SaveButtonMode) { + if (mode >= SaveButtonMode._UNUSED) { + throw new Error("Invalid display mode"); + } + + UI._saveButton.innerHTML = UI.SB_Text[mode]; + UI.SB_ColorClasses.concat(UI.SB_IconClasses).forEach(c => UI._saveButton.classList.remove(c)); + UI._saveButton.classList.add(UI.SB_ColorClasses[mode]); + UI._saveButton.classList.add(UI.SB_IconClasses[mode]); + + if (mode === SaveButtonMode.NORMAL) { + UI._saveButton.removeAttribute("disabled"); + } else { + UI._saveButton.setAttribute("disabled", "disabled"); + } + } + + /** + * Synchronises the syntax highlighting element's scroll value with that of the input element. + */ + public static syncEditorScroll() { + UI._highlighting.scrollTop = UI._content.scrollTop; + UI._highlighting.scrollLeft = UI._content.scrollLeft; + } +} + +export default UI; diff --git a/src/ts/admin/Prototypes.ts b/src/ts/admin/Prototypes.ts new file mode 100644 index 0000000..a1c235c --- /dev/null +++ b/src/ts/admin/Prototypes.ts @@ -0,0 +1,3 @@ +HTMLTextAreaElement.prototype.insertAt = function (text: string, index: number) { + this.value = this.value.slice(0, index) + text + this.value.slice(index); +}; \ No newline at end of file diff --git a/src/ts/admin/TabSupport.ts b/src/ts/admin/TabSupport.ts index 250392e..bab813d 100644 --- a/src/ts/admin/TabSupport.ts +++ b/src/ts/admin/TabSupport.ts @@ -1,5 +1,5 @@ -import AdminUI from "./AdminUI"; -import adminUI from "./AdminUI"; +import "./Prototypes" +import UI from "./MarkdownEditor/UI"; (() => { const textareas = document.querySelectorAll("textarea.tab-support"); @@ -19,21 +19,14 @@ import adminUI from "./AdminUI"; while (sel > 0 && text[sel - 1] !== '\n') sel--; - console.log(`Line starts at index ${sel}`); - const lineStart = sel; while (text[sel] === ' ' || text[sel] === '\t') sel++; - console.log(`Identation ends at ${sel} (sel + ${sel - lineStart})`); - if (sel > lineStart) { - const lineEnd = lineStart + text.indexOf('\n', lineStart); - console.log(`Line starts at index ${lineEnd}`); e.preventDefault(); const indentStr = text.slice(lineStart, sel); - console.log(`Indent string is "${indentStr}"`); // insert carriage return and indented text textarea.value = `${text.slice(0, selStart)}\n${indentStr}${text.slice(selStart)}`; @@ -42,70 +35,12 @@ import adminUI from "./AdminUI"; textarea.blur(); textarea.focus(); textarea.selectionEnd = selEnd + indentStr.length + 1; // +1 for \n - AdminUI.updateEditView(); + UI.redraw(); return false; } } } - // Tab key? - if (e.key === "Tab") { - e.preventDefault(); - let selStart = textarea.selectionStart; - - // selection? - if (selStart == textarea.selectionEnd) { - // These single character operations are undoable - if (e.shiftKey) { - text = textarea.value; - if (selStart > 0 && (text[selStart - 1] === '\t' || text.slice(selStart - 4, selStart) === " ")) { - document.execCommand("delete"); - } - } else { - document.execCommand("insertText", false, " "); - } - } else { - // Block indent/unindent trashes undo stack. - // Select whole lines - let selEnd = textarea.selectionEnd; - text = textarea.value; - while (selStart > 0 && text[selStart - 1] !== '\n') - selStart--; - while (selEnd > 0 && text[selEnd - 1] !== '\n' && selEnd < text.length) - selEnd++; - - // Get selected text - let lines = text.slice(selStart, selEnd - selStart).split('\n'); - - // Insert tabs - for (let i = 0; i < lines.length; i++) { - // Don't indent last line if cursor at start of line - if (i == lines.length - 1 && lines[i].length == 0) - continue; - - // Tab or Shift+Tab? - if (e.shiftKey) { - if (lines[i].startsWith('\t')) - lines[i] = lines[i].slice(1); - else if (lines[i].startsWith(" ")) - lines[i] = lines[i].slice(4); - } else { - lines[i] = ` ${lines[i]}`; - } - } - - const result = lines.join('\n'); - - // Update the text area - textarea.value = text.slice(0, selStart) + result + text.slice(selEnd); - textarea.selectionStart = selStart; - textarea.selectionEnd = selStart + lines.length; - } - - AdminUI.updateEditView(); - return false; - } - return true; }); }); diff --git a/src/ts/admin/admin.ts b/src/ts/admin/admin.ts index ce471a6..53cc0f7 100644 --- a/src/ts/admin/admin.ts +++ b/src/ts/admin/admin.ts @@ -1 +1 @@ -import "./EditBlogPost" \ No newline at end of file +import "./EditBlogPost"