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.
This commit is contained in:
parent
3c88bde0d1
commit
28c7f7ce78
@ -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<BlogPostController> _logger;
|
|
||||||
private readonly ISessionService _sessionService;
|
|
||||||
private readonly IBlogPostService _blogPostService;
|
|
||||||
|
|
||||||
public BlogPostController(ILogger<BlogPostController> logger,
|
|
||||||
ISessionService sessionService,
|
|
||||||
IBlogPostService blogPostService)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_sessionService = sessionService;
|
|
||||||
_blogPostService = blogPostService;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPatch("{id:guid}")]
|
|
||||||
public async Task<IActionResult> 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<Dictionary<string, string>>(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" });
|
|
||||||
}
|
|
||||||
}
|
|
@ -10,6 +10,8 @@
|
|||||||
IBlogPost post = Model.BlogPost;
|
IBlogPost post = Model.BlogPost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<component type="typeof(MarkdownEditor)" render-mode="Server"/>
|
||||||
|
|
||||||
<input type="hidden" data-blog-pid="@post.Id">
|
<input type="hidden" data-blog-pid="@post.Id">
|
||||||
|
|
||||||
<div class="d-flex flex-column" style="height: calc(100vh - 35px)">
|
<div class="d-flex flex-column" style="height: calc(100vh - 35px)">
|
||||||
@ -21,11 +23,15 @@
|
|||||||
<table class="table">
|
<table class="table">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Post ID</th>
|
<th>Post ID</th>
|
||||||
<td><input class="form-control" type="text" value="@post.Id" disabled="disabled"></td>
|
<td>
|
||||||
|
<input class="form-control" type="text" value="@post.Id" disabled="disabled">
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<td><input class="form-control" id="post-title" type="text" value="@post.Title"></td>
|
<td>
|
||||||
|
<input class="form-control" id="post-title" type="text" value="@post.Title">
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
37
OliverBooth/Pages/Components/MarkdownEditor.razor
Normal file
37
OliverBooth/Pages/Components/MarkdownEditor.razor
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
@using OliverBooth.Services
|
||||||
|
@using OliverBooth.Data.Blog
|
||||||
|
@implements IDisposable
|
||||||
|
@inject IBlogPostService BlogPostService
|
||||||
|
@inject IJSRuntime JsRuntime
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private DotNetObjectReference<MarkdownEditor>? _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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
_dotNetHelper = DotNetObjectReference.Create(this);
|
||||||
|
await JsRuntime.InvokeVoidAsync("Interop.setDotNetHelper", _dotNetHelper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,2 +1,4 @@
|
|||||||
@namespace OliverBooth.Pages
|
@namespace OliverBooth.Pages
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using OliverBooth.Pages.Components
|
||||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
@ -74,7 +74,6 @@ textarea {
|
|||||||
border: 0;
|
border: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +94,6 @@ textarea {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
caret-color: #FFFFFF;
|
caret-color: #FFFFFF;
|
||||||
resize: none;
|
resize: none;
|
||||||
overflow-wrap: normal;
|
|
||||||
overflow-x: scroll;
|
overflow-x: scroll;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
|
||||||
|
@ -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 = `<code class="${span.className} language-${language}" style="padding:0;">${span.innerHTML}</code>`;
|
|
||||||
Prism.highlightAllUnder(languageSpan.parentElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
AdminUI.syncEditorScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
static syncEditorScroll() {
|
|
||||||
AdminUI.highlighting.scrollTop = AdminUI._content.scrollTop;
|
|
||||||
AdminUI.highlighting.scrollLeft = AdminUI._content.scrollLeft;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AdminUI;
|
|
@ -1,8 +1,10 @@
|
|||||||
import BlogPost from "../app/BlogPost";
|
import BlogPost from "../app/BlogPost";
|
||||||
|
import UI from "./MarkdownEditor/UI"
|
||||||
import API from "../app/API";
|
import API from "../app/API";
|
||||||
import UI from "../app/UI";
|
|
||||||
import AdminUI from "./AdminUI";
|
|
||||||
import "./TabSupport"
|
import "./TabSupport"
|
||||||
|
import Interop from "./Interop";
|
||||||
|
import MarkdownEditor from "./MarkdownEditor/MarkdownEditor";
|
||||||
|
import SaveButtonMode from "./MarkdownEditor/SaveButtonMode";
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
getCurrentBlogPost().then(post => {
|
getCurrentBlogPost().then(post => {
|
||||||
@ -10,48 +12,24 @@ import "./TabSupport"
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AdminUI.init();
|
UI.init();
|
||||||
|
UI.addSaveButtonListener(savePost);
|
||||||
|
|
||||||
const preview = document.getElementById("article-preview") as HTMLAnchorElement;
|
const editor = new MarkdownEditor(UI.markdownInput);
|
||||||
const title = document.getElementById("post-title") as HTMLInputElement;
|
editor.addSaveListener(savePost);
|
||||||
|
editor.registerDefaultShortcuts();
|
||||||
AdminUI.saveButton.addEventListener("click", async (e: MouseEvent) => {
|
editor.registerEvents();
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function savePost(): Promise<void> {
|
async function savePost(): Promise<void> {
|
||||||
const saveButton = AdminUI.saveButton;
|
UI.setSaveButtonMode(SaveButtonMode.SAVING);
|
||||||
saveButton.classList.add("btn-primary");
|
await Interop.invoke("Save", post.id, UI.markdownInput.value);
|
||||||
saveButton.classList.remove("btn-success");
|
post = await API.getBlogPost(post.id);
|
||||||
|
UI.setSaveButtonMode(SaveButtonMode.SAVED);
|
||||||
|
|
||||||
saveButton.setAttribute("disabled", "disabled");
|
setTimeout(() => UI.setSaveButtonMode(SaveButtonMode.NORMAL), 2000);
|
||||||
saveButton.innerHTML = '<i class="fa-solid fa-spinner fa-spin fa-fw"></i> 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 = '<i class="fa-solid fa-circle-check fa-fw"></i> Saved';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
saveButton.classList.add("btn-primary");
|
|
||||||
saveButton.classList.remove("btn-success");
|
|
||||||
saveButton.innerHTML = '<i class="fa-solid fa-floppy-disk fa-fw"></i> Save <span class="text-muted">(Ctrl+S)</span>';
|
|
||||||
}, 2000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AdminUI.updateEditView();
|
UI.redraw();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function getCurrentBlogPost(): Promise<BlogPost> {
|
async function getCurrentBlogPost(): Promise<BlogPost> {
|
||||||
|
3
src/ts/admin/HTMLTextAreaElement.d.ts
vendored
Normal file
3
src/ts/admin/HTMLTextAreaElement.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
declare interface HTMLTextAreaElement {
|
||||||
|
insertAt(text: string, position: number) : void;
|
||||||
|
}
|
23
src/ts/admin/Interop.ts
Normal file
23
src/ts/admin/Interop.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
declare interface DotNetHelper {
|
||||||
|
invokeMethodAsync: (methodName: string, ...args: any) => Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Interop {
|
||||||
|
private static _dotNetHelper: DotNetHelper;
|
||||||
|
|
||||||
|
public static invoke<T>(methodName: string, ...args: any): Promise<T> {
|
||||||
|
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;
|
42
src/ts/admin/MarkdownEditor/KeyboardShortcut.ts
Normal file
42
src/ts/admin/MarkdownEditor/KeyboardShortcut.ts
Normal file
@ -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};
|
254
src/ts/admin/MarkdownEditor/MarkdownEditor.ts
Normal file
254
src/ts/admin/MarkdownEditor/MarkdownEditor.ts
Normal file
@ -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("[<text>](<url>)", selectionStart);
|
||||||
|
el.selectionStart = selectionStart + 1;
|
||||||
|
el.selectionEnd = selectionStart + 7;
|
||||||
|
|
||||||
|
this._nextSelection = [2, 5];
|
||||||
|
} else {
|
||||||
|
el.insertAt("[", selectionStart);
|
||||||
|
el.insertAt("](<url>)", 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<void> {
|
||||||
|
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;
|
9
src/ts/admin/MarkdownEditor/MarkdownFormatting.ts
Normal file
9
src/ts/admin/MarkdownEditor/MarkdownFormatting.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* An enumeration of Markdown formatting strings.
|
||||||
|
*/
|
||||||
|
enum MarkdownFormatting {
|
||||||
|
BOLD = "**",
|
||||||
|
ITALIC = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MarkdownFormatting;
|
13
src/ts/admin/MarkdownEditor/Prism.d.ts
vendored
Normal file
13
src/ts/admin/MarkdownEditor/Prism.d.ts
vendored
Normal file
@ -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;
|
28
src/ts/admin/MarkdownEditor/SaveButtonMode.ts
Normal file
28
src/ts/admin/MarkdownEditor/SaveButtonMode.ts
Normal file
@ -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;
|
107
src/ts/admin/MarkdownEditor/UI.ts
Normal file
107
src/ts/admin/MarkdownEditor/UI.ts
Normal file
@ -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 <span class=\"text-muted\">(Ctrl+S)</span>", "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 = `<code class="${span.className} language-${language}" style="padding:0;">${span.innerHTML}</code>`;
|
||||||
|
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;
|
3
src/ts/admin/Prototypes.ts
Normal file
3
src/ts/admin/Prototypes.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
HTMLTextAreaElement.prototype.insertAt = function (text: string, index: number) {
|
||||||
|
this.value = this.value.slice(0, index) + text + this.value.slice(index);
|
||||||
|
};
|
@ -1,5 +1,5 @@
|
|||||||
import AdminUI from "./AdminUI";
|
import "./Prototypes"
|
||||||
import adminUI from "./AdminUI";
|
import UI from "./MarkdownEditor/UI";
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
const textareas = document.querySelectorAll("textarea.tab-support");
|
const textareas = document.querySelectorAll("textarea.tab-support");
|
||||||
@ -19,21 +19,14 @@ import adminUI from "./AdminUI";
|
|||||||
while (sel > 0 && text[sel - 1] !== '\n')
|
while (sel > 0 && text[sel - 1] !== '\n')
|
||||||
sel--;
|
sel--;
|
||||||
|
|
||||||
console.log(`Line starts at index ${sel}`);
|
|
||||||
|
|
||||||
const lineStart = sel;
|
const lineStart = sel;
|
||||||
while (text[sel] === ' ' || text[sel] === '\t')
|
while (text[sel] === ' ' || text[sel] === '\t')
|
||||||
sel++;
|
sel++;
|
||||||
|
|
||||||
console.log(`Identation ends at ${sel} (sel + ${sel - lineStart})`);
|
|
||||||
|
|
||||||
if (sel > lineStart) {
|
if (sel > lineStart) {
|
||||||
const lineEnd = lineStart + text.indexOf('\n', lineStart);
|
|
||||||
console.log(`Line starts at index ${lineEnd}`);
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const indentStr = text.slice(lineStart, sel);
|
const indentStr = text.slice(lineStart, sel);
|
||||||
console.log(`Indent string is "${indentStr}"`);
|
|
||||||
|
|
||||||
// insert carriage return and indented text
|
// insert carriage return and indented text
|
||||||
textarea.value = `${text.slice(0, selStart)}\n${indentStr}${text.slice(selStart)}`;
|
textarea.value = `${text.slice(0, selStart)}\n${indentStr}${text.slice(selStart)}`;
|
||||||
@ -42,70 +35,12 @@ import adminUI from "./AdminUI";
|
|||||||
textarea.blur();
|
textarea.blur();
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
textarea.selectionEnd = selEnd + indentStr.length + 1; // +1 for \n
|
textarea.selectionEnd = selEnd + indentStr.length + 1; // +1 for \n
|
||||||
AdminUI.updateEditView();
|
UI.redraw();
|
||||||
return false;
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user