all tests pass but only if md-delim display: inline

This commit is contained in:
evilchili
2026-07-10 12:39:35 -07:00
parent 818ee418d5
commit c1dc49f0b3
23 changed files with 5109 additions and 6197 deletions
+1
View File
@@ -0,0 +1 @@
export * from './ts';
+128 -46
View File
@@ -1,9 +1,18 @@
/*
* ribbit-core.css — functional editor styles. Always load this.
* These styles control editor state visibility and behavior.
* They should not be overridden by themes.
*
* These styles control editor state visibility and the styled-source
* rendering. They should not be overridden by themes.
*
* Two CSS states (not modes):
* .wysiwyg — contentEditable, delimiters revealed on cursor focus
* .view — read-only, all delimiters hidden, full block styling
*
* The DOM is identical in both states; only CSS changes.
*/
/* ── Visibility ─────────────────────────────────────────────────────────────── */
#ribbit {
display: none;
}
@@ -12,54 +21,127 @@
display: block;
}
#ribbit.edit {
/* ── Delimiter visibility ───────────────────────────────────────────────────── */
/*
* Delimiters are always present in the DOM as text nodes inside
* .md-delim spans. In view state they are hidden; in wysiwyg state
* they are hidden by default and revealed only for the span the
* cursor is currently inside (.ribbit-editing).
*
* This means getMarkdown() = element.textContent at all times —
* no conversion is needed.
*/
.md-delim {
display:inline;
opacity: 0.3;
font-size: 0.85em;
font-weight: normal;
font-style: normal;
font-family: monospace;
}
.ribbit-editing {
background: #EEE;
}
.ribbit-editing > .md-delim {
display: inline;
opacity: 0.8;
}
/* List prefixes use a separate class so CSS can replace them with
real list bullets in view state while keeping them in textContent */
.md-list-prefix {
display: inline;
opacity: 0.8;
/*
font-family: monospace;
font-size: 0.85em;
*/
}
#ribbit.view .md-list-prefix {
display: none;
}
/* ── Inline formatting ──────────────────────────────────────────────────────── */
.md-bold,
.md-bold-italic {
font-weight: bold;
}
.md-italic,
.md-bold-italic {
font-style: italic;
}
.md-strikethrough {
text-decoration: line-through;
}
.md-code {
font-family: monospace;
}
.md-link {
cursor: pointer;
}
.md-link-text {
text-decoration: underline;
}
/* ── Block-level styling ────────────────────────────────────────────────────── */
/*
* Block divs use .md-{name} classes. In view state they render as
* their visual equivalents. In wysiwyg state they use monospace so
* the user can see the raw markdown while the formatting is applied.
*/
#ribbit.wysiwyg {
/* white-space: pre-wrap; */
}
.md-h1 { font-size: 2em; font-weight: bold; }
.md-h2 { font-size: 1.5em; font-weight: bold; }
.md-h3 { font-size: 1.17em; font-weight: bold; }
.md-h4 { font-size: 1em; font-weight: bold; }
.md-h5 { font-size: 0.83em; font-weight: bold; }
.md-h6 { font-size: 0.67em; font-weight: bold; }
.md-blockquote {
border-left: 3px solid currentColor;
opacity: 0.7;
padding-left: 1em;
}
/*
* List items: in wysiwyg state the .md-list-prefix span shows the
* raw markdown marker ("- " or "1. "). In view state we hide the
* prefix and use display:list-item to get a real browser bullet.
*/
#ribbit.view .md-list-item {
display: list-item;
margin-left: 1.5em;
list-style-type: disc;
}
#ribbit.view .md-ol-list-item {
display: list-item;
margin-left: 1.5em;
list-style-type: decimal;
}
.md-pre {
font-family: monospace;
white-space: pre;
}
#ribbit.wysiwyg .md {
opacity: 0.5;
}
.ribbit-editing::before,
.ribbit-editing::after {
opacity: 0.3;
font-weight: normal;
font-style: normal;
font-family: monospace;
font-size: 0.85em;
}
[data-speculative]::before,
[data-speculative]::after {
content: none !important;
}
#ribbit.wysiwyg strong.ribbit-editing::before,
#ribbit.wysiwyg strong.ribbit-editing::after {
content: "**";
}
#ribbit.wysiwyg em.ribbit-editing::before,
#ribbit.wysiwyg em.ribbit-editing::after {
content: "*";
}
#ribbit.wysiwyg code.ribbit-editing::before,
#ribbit.wysiwyg code.ribbit-editing::after {
content: "\`";
}
#ribbit.wysiwyg h1.ribbit-editing::before { content: "# "; font-size: 0.5em; }
#ribbit.wysiwyg h2.ribbit-editing::before { content: "## "; font-size: 0.5em; }
#ribbit.wysiwyg h3.ribbit-editing::before { content: "### "; font-size: 0.5em; }
#ribbit.wysiwyg h4.ribbit-editing::before { content: "#### "; font-size: 0.5em; }
#ribbit.wysiwyg h5.ribbit-editing::before { content: "##### "; font-size: 0.5em; }
#ribbit.wysiwyg h6.ribbit-editing::before { content: "###### "; font-size: 0.5em; }
#ribbit.wysiwyg blockquote.ribbit-editing::before {
content: "> ";
}
/* ── Vim mode indicators ────────────────────────────────────────────────────── */
#ribbit.vim-normal {
cursor: default;
+1
View File
@@ -0,0 +1 @@
../../../../node_modules/bootstrap-icons/icons
@@ -6,6 +6,11 @@
@import "../../ribbit-core.css";
body { font-family: sans-serif; margin: 20px; }
main { max-width: 960px; margin: auto }
#ribbit { border: 1px solid #ccc; border-radius: 4px; padding: 20px; min-height: 200px; }
a {
text-decoration: none;
}
@@ -50,3 +55,80 @@ code {
background: #EEE;
margin: 3px;
}
.ribbit-toolbar {
background: #f5f5f5;
border: 1px solid #ccc; border-radius: 4px; padding: 4px; margin-bottom: 8px;
}
.ribbit-toolbar ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 2px;
align-items: center;
}
.ribbit-toolbar button {
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
background-repeat: no-repeat;
background-position: center;
background-size: 1rem 1rem;
width: 2rem;
height: 2rem;
}
.ribbit-toolbar button:hover {
background-color: #DDD;
background-blend-mode: darken;
}
.ribbit-toolbar button.disabled {
opacity: 0.3;
cursor: default;
}
.ribbit-btn-fencedCode { background-image: url("icons/code-square.svg"); }
.ribbit-btn-blockquote { background-image: url("icons/blockquote-left.svg"); }
.ribbit-btn-hr { background-image: url("icons/hr.svg"); }
.ribbit-btn-table { background-image: url("icons/table.svg"); }
.ribbit-btn-code { background-image: url("icons/code.svg"); }
.ribbit-btn-link { background-image: url("icons/link.svg"); }
.ribbit-btn-boldItalic { background-image: url("icons/type-bold.svg"); }
.ribbit-btn-bold { background-image: url("icons/type-bold.svg"); }
.ribbit-btn-italic { background-image: url("icons/type-italic.svg"); }
.ribbit-btn-strikethrough { background-image: url("icons/type-strikethrough.svg"); }
.ribbit-btn-h1 { background-image: url("icons/type-h1.svg"); }
.ribbit-btn-h2 { background-image: url("icons/type-h2.svg"); }
.ribbit-btn-h3 { background-image: url("icons/type-h3.svg"); }
.ribbit-btn-h4 { background-image: url("icons/type-h4.svg"); }
.ribbit-btn-h5 { background-image: url("icons/type-h5.svg"); }
.ribbit-btn-h6 { background-image: url("icons/type-h6.svg"); }
.ribbit-btn-ul { background-image: url("icons/list-ul.svg"); }
.ribbit-btn-ol { background-image: url("icons/list-ol.svg"); }
.ribbit-btn-edit { background-image: url("icons/pen.svg"); }
.ribbit-btn-save { background-image: url("icons/floppy.svg"); }
.ribbit-btn-toggle { background-image: url("icons/toggle-off.svg"); }
.ribbit-toolbar .spacer {
width: 12px;
}
.ribbit-dropdown {
position: absolute;
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px;
z-index: 10;
}
.ribbit-dropdown button {
display: block;
width: 100%;
text-align: left;
margin: 1px 0;
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./ribbit";
export * from "./hopdown";
+751 -986
View File
File diff suppressed because it is too large Load Diff
+54 -48
View File
@@ -38,8 +38,6 @@ export class Ribbit {
api: unknown;
element: HTMLElement;
states: Record<string, string>;
cachedHTML: string | null;
cachedMarkdown: string | null;
state: string | null;
theme: RibbitTheme;
themes: ThemeManager;
@@ -51,6 +49,12 @@ export class Ribbit {
private emitter: RibbitEmitter;
private macros: MacroDef[];
// The markdown source as it existed before view() rendered it to HTML.
// Set by subclasses (RibbitEditor) before overwriting element.innerHTML.
// Allows getMarkdown() in view state to return the original source rather
// than textContent of the rendered HTML (which strips delimiters).
protected sourceMarkdown: string | null = null;
constructor(settings: RibbitSettings) {
this.api = settings.api || null;
this.element = document.getElementById(settings.editorId || 'ribbit')!;
@@ -60,8 +64,6 @@ export class Ribbit {
this.states = {
VIEW: 'view',
};
this.cachedHTML = null;
this.cachedMarkdown = null;
this.state = null;
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
@@ -69,7 +71,6 @@ export class Ribbit {
this.converter = theme.tags
? new HopDown({ tags: theme.tags, macros: this.macros })
: new HopDown({ macros: this.macros });
this.cachedHTML = null;
this.emitter.emit('themeChange', {
current: theme,
previous,
@@ -112,14 +113,13 @@ export class Ribbit {
settings.collaboration,
{
onRemoteUpdate: (content) => {
this.cachedMarkdown = content;
this.cachedHTML = null;
this.sourceMarkdown = content;
if (this.getState() !== this.states.VIEW) {
this.element.innerHTML = this.getHTML();
this.element.innerHTML = this.markdownToHTML(content);
}
this.emitter.emit('change', {
markdown: content,
html: this.getHTML(),
html: this.markdownToHTML(content),
});
},
onPeersChange: (peers) => {
@@ -188,7 +188,7 @@ export class Ribbit {
}
/**
* Current mode name ('view', 'edit', or 'wysiwyg').
* Current mode name ('view' or 'wysiwyg').
*
* if (editor.getState() === 'wysiwyg') { ... }
*/
@@ -200,7 +200,7 @@ export class Ribbit {
* Transition to a new mode. Updates CSS classes on the editor element
* so themes can style each mode differently, and fires modeChange.
*
* editor.setState('edit');
* editor.setState('wysiwyg');
*/
setState(newState: string): void {
const previous = this.state;
@@ -225,28 +225,26 @@ export class Ribbit {
}
/**
* Rendered HTML of the current content, cached until invalidated.
* Rendered HTML of the current content.
*
* document.getElementById('preview').innerHTML = viewer.getHTML();
*/
getHTML(): string {
if (this.cachedHTML === null) {
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
}
return this.cachedHTML;
return this.markdownToHTML(this.getMarkdown());
}
/**
* Raw markdown of the current content. In view mode this is the
* original text; in edit/wysiwyg mode it's derived from the DOM.
* Raw markdown of the current content. In view state reads from
* sourceMarkdown if set (preserved before rendering overwrote the
* element), otherwise falls back to element.textContent.
*
* fetch('/save', { body: editor.getMarkdown() });
*/
getMarkdown(): string {
if (this.cachedMarkdown === null) {
this.cachedMarkdown = this.element.textContent || '';
if (this.sourceMarkdown !== null) {
return this.sourceMarkdown;
}
return this.cachedMarkdown;
return this.element.textContent || '';
}
/**
@@ -270,26 +268,20 @@ export class Ribbit {
* editor.view();
*/
view(): void {
if (this.getState() === this.states.VIEW) return;
this.invalidateCache();
if (this.getState() === this.states.VIEW) {
return;
}
// Capture markdown before overwriting the element with rendered HTML.
// getMarkdown() on the base class reads element.textContent when
// sourceMarkdown is null — correct for the initial load case where
// the element contains raw markdown text.
this.sourceMarkdown = this.getMarkdown();
this.collaboration?.disconnect();
this.element.innerHTML = this.getHTML();
this.element.innerHTML = this.markdownToHTML(this.sourceMarkdown);
this.setState(this.states.VIEW);
this.element.contentEditable = 'false';
}
/**
* Force re-conversion on next getHTML()/getMarkdown() call.
* Call after programmatically changing element content.
*
* editor.element.innerHTML = newContent;
* editor.invalidateCache();
*/
invalidateCache(): void {
this.cachedMarkdown = null;
this.cachedHTML = null;
}
/**
* Request an advisory editing lock. Returns false if another user
* holds the lock. Requires a collaboration transport.
@@ -297,7 +289,9 @@ export class Ribbit {
* if (await editor.lockForEditing()) { editor.wysiwyg(); }
*/
async lockForEditing(): Promise<boolean> {
if (!this.collaboration) return false;
if (!this.collaboration) {
return false;
}
return this.collaboration.lock();
}
@@ -318,7 +312,9 @@ export class Ribbit {
* await editor.forceLockEditing();
*/
async forceLockEditing(): Promise<boolean> {
if (!this.collaboration) return false;
if (!this.collaboration) {
return false;
}
return this.collaboration.forceLock();
}
@@ -329,7 +325,9 @@ export class Ribbit {
* revisions.forEach(r => console.log(r.id, r.timestamp));
*/
async listRevisions(): Promise<Revision[]> {
if (!this.collaboration) return [];
if (!this.collaboration) {
return [];
}
return this.collaboration.listRevisions();
}
@@ -340,7 +338,9 @@ export class Ribbit {
* if (rev) { console.log(rev.content); }
*/
async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
if (!this.collaboration) return null;
if (!this.collaboration) {
return null;
}
return this.collaboration.getRevision(id);
}
@@ -351,18 +351,22 @@ export class Ribbit {
* await editor.restoreRevision('abc-123');
*/
async restoreRevision(id: string): Promise<void> {
if (!this.collaboration) return;
if (!this.collaboration) {
return;
}
const revision = await this.collaboration.getRevision(id);
if (!revision) return;
this.cachedMarkdown = revision.content;
this.cachedHTML = this.markdownToHTML(revision.content);
if (!revision) {
return;
}
this.sourceMarkdown = revision.content;
const html = this.markdownToHTML(revision.content);
this.collaboration.sendUpdate(revision.content);
if (this.getState() !== this.states.VIEW) {
this.element.innerHTML = this.cachedHTML;
this.element.innerHTML = html;
}
this.emitter.emit('change', {
markdown: revision.content,
html: this.cachedHTML,
html,
});
}
@@ -373,7 +377,9 @@ export class Ribbit {
* const rev = await editor.createRevision({ label: 'v1.0' });
*/
async createRevision(metadata?: RevisionMetadata): Promise<Revision | null> {
if (!this.collaboration) return null;
if (!this.collaboration) {
return null;
}
const revision = await this.collaboration.createRevision(this.getMarkdown(), metadata);
if (revision) {
this.emitter.emit('revisionCreated', { revision });
@@ -427,7 +433,7 @@ export function decodeHtmlEntities(html: string): string {
/**
* Encode characters that would be interpreted as HTML into numeric
* entities. Used when displaying raw markdown in contentEditable
* (edit mode) so the browser doesn't parse it as markup.
* so the browser doesn't parse it as markup.
*
* encodeHtmlEntities('<b>hi</b>') // '&#60;b&#62;hi&#60;/b&#62;'
*/
+4 -16
View File
@@ -25,7 +25,7 @@ const MACRO_ID_PREFIX = 'macro:';
const DROPDOWN_INDICATOR = ' ▾';
/** IDs of buttons that belong in the utility section, not the tag/macro area. */
const UTILITY_BUTTON_IDS = ['save', 'toggle', 'markdown'];
const UTILITY_BUTTON_IDS = ['save', 'edit'];
const MAX_HEADING_LEVEL = 6;
@@ -217,7 +217,7 @@ export class ToolbarManager {
action: 'custom',
handler: () => this.editor.save(),
});
this.register('toggle', {
this.register('edit', {
label: 'Edit',
shortcut: 'Ctrl+Shift+V',
action: 'custom',
@@ -229,18 +229,6 @@ export class ToolbarManager {
}
},
});
this.register('markdown', {
label: 'Source',
shortcut: 'Ctrl+/',
action: 'custom',
handler: () => {
if (this.editor.getState() === EDITOR_STATE_EDIT) {
this.editor.wysiwyg();
} else {
this.editor.edit();
}
},
});
}
/**
@@ -314,7 +302,7 @@ export class ToolbarManager {
items: macroIds,
});
}
slots.push('', 'markdown', 'save', 'toggle');
slots.push('', 'save', 'edit');
return slots;
}
@@ -430,7 +418,7 @@ export class ToolbarManager {
const listItem = document.createElement('li');
const buttonElement = document.createElement('button');
buttonElement.className = `ribbit-btn-${button.id}`;
buttonElement.textContent = button.label;
//buttonElement.textContent = button.label;
buttonElement.setAttribute('aria-label', button.label);
buttonElement.title = button.shortcut
? `${button.label} (${button.shortcut})`
-337
View File
@@ -1,337 +0,0 @@
/*
* vim.ts — vim keybinding handler for ribbit source edit mode.
*
* Two modes: normal and insert. Activated in source (edit) mode only.
* Esc enters normal mode, i/a/o/O enter insert mode.
*
* Normal mode commands:
* h/j/k/l — cursor movement
* w/b — word forward/back
* 0/$ — line start/end
* gg/G — document start/end
* i — insert before cursor
* a — insert after cursor
* o — new line below, insert
* O — new line above, insert
* x — delete char under cursor
* dd — delete line
* u — undo
* Ctrl+r — redo
*/
type VimMode = 'normal' | 'insert';
/** Direction constants for cursor movement to avoid magic strings. */
const DIRECTION = {
LEFT: 'left' as const,
RIGHT: 'right' as const,
UP: 'up' as const,
DOWN: 'down' as const,
};
/** Selection API direction mappings. */
const SELECTION_DIRECTION = {
BACKWARD: 'backward' as const,
FORWARD: 'forward' as const,
};
/** Selection API granularity mappings. */
const SELECTION_GRANULARITY = {
CHARACTER: 'character' as const,
LINE: 'line' as const,
WORD: 'word' as const,
LINE_BOUNDARY: 'lineboundary' as const,
};
/** Regex to match digit keys for count prefix accumulation. */
const DIGIT_PATTERN = /^[0-9]$/;
/** Default repeat count when no count prefix is given. */
const DEFAULT_REPEAT_COUNT = '1';
/** Radix for parsing count prefix strings. */
const DECIMAL_RADIX = 10;
/**
* Handles vim-style keybindings in ribbit's source edit mode.
*
* Supports normal and insert modes with standard vim motions,
* editing commands, and count prefixes.
*
* @example
* const vim = new VimHandler((mode) => {
* statusBar.textContent = mode;
* });
* vim.attach(editorElement);
*/
export class VimHandler {
mode: VimMode;
private element: HTMLElement | null;
private listener: ((event: KeyboardEvent) => void) | null;
private pending: string;
private count: string;
private onModeChange: (mode: VimMode) => void;
constructor(onModeChange: (mode: VimMode) => void) {
this.mode = 'insert';
this.element = null;
this.listener = null;
this.pending = '';
this.count = '';
this.onModeChange = onModeChange;
}
/**
* Bind vim keybindings to a DOM element.
*
* @example
* vim.attach(document.getElementById('editor'));
*/
attach(element: HTMLElement): void {
this.detach();
this.element = element;
this.pending = '';
this.listener = (event: KeyboardEvent) => this.handleKey(event);
this.element.addEventListener('keydown', this.listener);
this.setMode('insert');
}
/**
* Remove vim keybindings from the current element.
*
* @example
* vim.detach();
*/
detach(): void {
if (this.element && this.listener) {
this.element.removeEventListener('keydown', this.listener);
this.element.classList.remove('vim-normal', 'vim-insert');
}
this.element = null;
this.listener = null;
this.mode = 'insert';
this.pending = '';
}
private setMode(mode: VimMode): void {
this.mode = mode;
this.pending = '';
this.count = '';
this.onModeChange(mode);
}
/**
* Routes keystrokes to insert-mode or normal-mode handling.
* Insert mode only intercepts Escape; normal mode handles
* all vim commands and suppresses default text input.
*/
private handleKey(event: KeyboardEvent): void {
if (this.mode === 'insert') {
if (event.key === 'Escape') {
event.preventDefault();
this.setMode('normal');
}
return;
}
// Suppress default text input in normal mode
event.preventDefault();
if (event.ctrlKey) {
if (event.key === 'r') {
document.execCommand('redo');
}
return;
}
const key = event.key;
// Accumulate count prefix — 0 as first char is line-start, not count
if (DIGIT_PATTERN.test(key) && (this.count || key !== '0')) {
this.count += key;
return;
}
const repeat = parseInt(this.count || DEFAULT_REPEAT_COUNT, DECIMAL_RADIX);
this.count = '';
if (this.pending) {
const combo = this.pending + key;
this.pending = '';
for (let step = 0; step < repeat; step++) {
this.handlePending(combo);
}
return;
}
this.dispatchNormalKey(key, repeat);
}
/**
* Dispatches a normal-mode key to the appropriate command.
* Separated from handleKey to keep nesting shallow.
*/
private dispatchNormalKey(key: string, repeat: number): void {
switch (key) {
case 'i':
this.setMode('insert');
break;
case 'a':
this.moveCursor(DIRECTION.RIGHT);
this.setMode('insert');
break;
case 'o':
this.endOfLine();
this.insertNewline();
this.setMode('insert');
break;
case 'O':
this.startOfLine();
this.insertNewline();
this.moveCursor(DIRECTION.UP);
this.setMode('insert');
break;
case 'h':
for (let step = 0; step < repeat; step++) {
this.moveCursor(DIRECTION.LEFT);
}
break;
case 'j':
for (let step = 0; step < repeat; step++) {
this.moveCursor(DIRECTION.DOWN);
}
break;
case 'k':
for (let step = 0; step < repeat; step++) {
this.moveCursor(DIRECTION.UP);
}
break;
case 'l':
for (let step = 0; step < repeat; step++) {
this.moveCursor(DIRECTION.RIGHT);
}
break;
case 'w':
for (let step = 0; step < repeat; step++) {
this.wordForward();
}
break;
case 'b':
for (let step = 0; step < repeat; step++) {
this.wordBack();
}
break;
case '0':
this.startOfLine();
break;
case '$':
this.endOfLine();
break;
case 'G':
this.endOfDocument();
break;
case 'x':
for (let step = 0; step < repeat; step++) {
this.deleteChar();
}
break;
case 'u':
for (let step = 0; step < repeat; step++) {
document.execCommand('undo');
}
break;
// Two-char commands — preserve count for the second key
case 'd':
case 'g':
this.pending = key;
if (repeat > 1) {
this.count = String(repeat);
}
break;
}
}
private handlePending(combo: string): void {
switch (combo) {
case 'dd':
this.deleteLine();
break;
case 'gg':
this.startOfDocument();
break;
}
}
private moveCursor(direction: 'left' | 'right' | 'up' | 'down'): void {
const selection = window.getSelection();
if (!selection) {
return;
}
const selectionDirection = (direction === DIRECTION.LEFT || direction === DIRECTION.UP)
? SELECTION_DIRECTION.BACKWARD
: SELECTION_DIRECTION.FORWARD;
const granularity = (direction === DIRECTION.UP || direction === DIRECTION.DOWN)
? SELECTION_GRANULARITY.LINE
: SELECTION_GRANULARITY.CHARACTER;
selection.modify('move', selectionDirection, granularity);
}
private wordForward(): void {
window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.WORD);
}
private wordBack(): void {
window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.WORD);
}
private startOfLine(): void {
window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
}
private endOfLine(): void {
window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
}
private startOfDocument(): void {
const selection = window.getSelection();
if (!selection || !this.element) {
return;
}
const range = document.createRange();
range.setStart(this.element, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
private endOfDocument(): void {
const selection = window.getSelection();
if (!selection || !this.element) {
return;
}
const range = document.createRange();
range.selectNodeContents(this.element);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
private deleteChar(): void {
document.execCommand('forwardDelete');
}
private deleteLine(): void {
this.startOfLine();
window.getSelection()?.modify('extend', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
document.execCommand('delete');
// Remove the trailing newline left after deleting line content
document.execCommand('forwardDelete');
}
private insertNewline(): void {
document.execCommand('insertLineBreak');
}
}