all tests pass but only if md-delim display: inline
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from './ts';
|
||||
+128
-46
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./ribbit";
|
||||
export * from "./hopdown";
|
||||
+751
-986
File diff suppressed because it is too large
Load Diff
+54
-48
@@ -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>') // '<b>hi</b>'
|
||||
*/
|
||||
|
||||
+4
-16
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user