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:
Oliver Booth 2024-02-28 16:04:56 +00:00
parent 3c88bde0d1
commit 28c7f7ce78
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
18 changed files with 551 additions and 234 deletions

View File

@ -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" });
}
}

View File

@ -10,6 +10,8 @@
IBlogPost post = Model.BlogPost;
}
<component type="typeof(MarkdownEditor)" render-mode="Server"/>
<input type="hidden" data-blog-pid="@post.Id">
<div class="d-flex flex-column" style="height: calc(100vh - 35px)">
@ -21,11 +23,15 @@
<table class="table">
<tr>
<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>
<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>
</table>

View 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);
}
}
}

View File

@ -1,2 +1,4 @@
@namespace OliverBooth.Pages
@using Microsoft.AspNetCore.Components.Web
@using OliverBooth.Pages.Components
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -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;

View File

@ -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;

View File

@ -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<void> {
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 = '<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);
setTimeout(() => UI.setSaveButtonMode(SaveButtonMode.NORMAL), 2000);
}
AdminUI.updateEditView();
UI.redraw();
});
async function getCurrentBlogPost(): Promise<BlogPost> {

3
src/ts/admin/HTMLTextAreaElement.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare interface HTMLTextAreaElement {
insertAt(text: string, position: number) : void;
}

23
src/ts/admin/Interop.ts Normal file
View 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;

View 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};

View 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;

View 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
View 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;

View 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;

View 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;

View 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);
};

View File

@ -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;
});
});