2026-04-28 09:59:30 -07:00
|
|
|
/*
|
|
|
|
|
* ribbit-editor.ts — WYSIWYG editing extension for Ribbit.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { HopDown } from './hopdown';
|
|
|
|
|
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
|
2026-04-28 18:17:32 -07:00
|
|
|
import { defaultTheme } from './default-theme';
|
2026-04-28 23:08:20 -07:00
|
|
|
import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
2026-04-29 00:32:54 -07:00
|
|
|
import { VimHandler } from './vim';
|
2026-04-28 20:18:19 -07:00
|
|
|
import { type MacroDef } from './macros';
|
2026-04-28 09:59:30 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
|
|
|
|
|
*
|
|
|
|
|
* Extends Ribbit with contentEditable support and bidirectional
|
|
|
|
|
* markdown↔HTML conversion on mode switches.
|
|
|
|
|
*
|
|
|
|
|
* Usage:
|
|
|
|
|
* const editor = new RibbitEditor({ editorId: 'my-element' });
|
|
|
|
|
* editor.run();
|
|
|
|
|
* editor.wysiwyg(); // switch to WYSIWYG mode
|
|
|
|
|
* editor.edit(); // switch to source editing mode
|
|
|
|
|
* editor.view(); // switch to read-only view
|
|
|
|
|
*/
|
|
|
|
|
export class RibbitEditor extends Ribbit {
|
2026-04-29 00:48:06 -07:00
|
|
|
private vim?: VimHandler;
|
2026-04-28 09:59:30 -07:00
|
|
|
|
|
|
|
|
run(): void {
|
|
|
|
|
this.states = {
|
|
|
|
|
VIEW: 'view',
|
|
|
|
|
EDIT: 'edit',
|
|
|
|
|
WYSIWYG: 'wysiwyg'
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-29 00:48:06 -07:00
|
|
|
if (this.theme.features?.vim) {
|
|
|
|
|
this.vim = new VimHandler((mode) => {
|
|
|
|
|
if (mode === 'normal') {
|
|
|
|
|
this.toolbar.disable();
|
|
|
|
|
this.element.classList.add('vim-normal');
|
|
|
|
|
this.element.classList.remove('vim-insert');
|
|
|
|
|
} else {
|
|
|
|
|
this.toolbar.enable();
|
|
|
|
|
this.element.classList.add('vim-insert');
|
|
|
|
|
this.element.classList.remove('vim-normal');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-29 00:32:54 -07:00
|
|
|
|
2026-04-28 09:59:30 -07:00
|
|
|
this.#bindEvents();
|
|
|
|
|
this.element.classList.add('loaded');
|
2026-04-28 23:08:20 -07:00
|
|
|
if (this.autoToolbar) {
|
|
|
|
|
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
|
|
|
|
|
}
|
2026-04-28 09:59:30 -07:00
|
|
|
this.view();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#bindEvents(): void {
|
2026-04-28 20:18:19 -07:00
|
|
|
let debounceTimer: number | undefined;
|
|
|
|
|
let lastThrottle = 0;
|
|
|
|
|
|
2026-04-28 09:59:30 -07:00
|
|
|
this.element.addEventListener('input', () => {
|
2026-04-28 20:18:19 -07:00
|
|
|
if (this.state === this.states.VIEW) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.invalidateCache();
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - lastThrottle >= 150) {
|
|
|
|
|
lastThrottle = now;
|
|
|
|
|
this.refreshPreview();
|
2026-04-28 09:59:30 -07:00
|
|
|
}
|
2026-04-28 20:18:19 -07:00
|
|
|
|
|
|
|
|
clearTimeout(debounceTimer);
|
|
|
|
|
debounceTimer = window.setTimeout(() => {
|
|
|
|
|
this.refreshPreview();
|
|
|
|
|
this.notifyChange();
|
|
|
|
|
}, 150);
|
2026-04-28 09:59:30 -07:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 20:18:19 -07:00
|
|
|
/**
|
|
|
|
|
* Re-render the WYSIWYG preview from the current content.
|
|
|
|
|
* Applies speculative rendering for unclosed inline delimiters
|
|
|
|
|
* at the cursor position, and uses toHtmlPreview for visible syntax.
|
|
|
|
|
*/
|
|
|
|
|
refreshPreview(): void {
|
|
|
|
|
if (this.state !== this.states.WYSIWYG) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cursorInfo = this.getCursorInfo();
|
|
|
|
|
const text = this.element.textContent || '';
|
|
|
|
|
const lines = text.split('\n');
|
|
|
|
|
|
|
|
|
|
// Speculatively close unclosed delimiters on the cursor line
|
|
|
|
|
if (cursorInfo) {
|
|
|
|
|
const inlineTags = this.converter.getInlineTags();
|
|
|
|
|
const sorted = [...inlineTags].sort((a, b) =>
|
|
|
|
|
((a as any).precedence ?? 50) - ((b as any).precedence ?? 50)
|
|
|
|
|
);
|
|
|
|
|
for (const tag of sorted) {
|
|
|
|
|
if (tag.openPattern && tag.delimiter) {
|
|
|
|
|
const before = lines[cursorInfo.lineIndex].slice(0, cursorInfo.offset);
|
|
|
|
|
const escaped = tag.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
|
const re = new RegExp(escaped, 'g');
|
|
|
|
|
const count = (before.match(re) || []).length;
|
|
|
|
|
if (count % 2 === 1) {
|
|
|
|
|
lines[cursorInfo.lineIndex] = lines[cursorInfo.lineIndex] + tag.delimiter;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const html = this.converter.toHTML(lines.join('\n'));
|
|
|
|
|
this.updatePreview(html, cursorInfo);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Track which formatting element contains the cursor and toggle
|
|
|
|
|
* the .ribbit-editing class so CSS ::before/::after show delimiters.
|
|
|
|
|
*/
|
|
|
|
|
private updateEditingContext(): void {
|
|
|
|
|
const prev = this.element.querySelector('.ribbit-editing');
|
|
|
|
|
if (prev) {
|
|
|
|
|
prev.classList.remove('ribbit-editing');
|
|
|
|
|
}
|
|
|
|
|
const sel = window.getSelection();
|
|
|
|
|
if (!sel || sel.rangeCount === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let node: Node | null = sel.anchorNode;
|
|
|
|
|
while (node && node !== this.element) {
|
|
|
|
|
if (node.nodeType === 1) {
|
|
|
|
|
const el = node as HTMLElement;
|
|
|
|
|
if (el.matches('strong, b, em, i, code, h1, h2, h3, h4, h5, h6, blockquote')) {
|
|
|
|
|
el.classList.add('ribbit-editing');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
node = node.parentNode;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the cursor's line index and offset within that line.
|
|
|
|
|
*/
|
|
|
|
|
private getCursorInfo(): { lineIndex: number; offset: number; absoluteOffset: number } | null {
|
|
|
|
|
const sel = window.getSelection();
|
|
|
|
|
if (!sel || sel.rangeCount === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const range = sel.getRangeAt(0);
|
|
|
|
|
const preRange = document.createRange();
|
|
|
|
|
preRange.selectNodeContents(this.element);
|
|
|
|
|
preRange.setEnd(range.startContainer, range.startOffset);
|
|
|
|
|
const absoluteOffset = preRange.toString().length;
|
|
|
|
|
|
|
|
|
|
const text = this.element.textContent || '';
|
|
|
|
|
const beforeCursor = text.slice(0, absoluteOffset);
|
|
|
|
|
const lineIndex = beforeCursor.split('\n').length - 1;
|
|
|
|
|
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
|
|
|
|
|
const offset = absoluteOffset - lineStart;
|
|
|
|
|
|
|
|
|
|
return { lineIndex, offset, absoluteOffset };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Replace the editor's HTML and restore the cursor to its
|
|
|
|
|
* previous text offset position.
|
|
|
|
|
*/
|
|
|
|
|
private updatePreview(html: string, cursorInfo: { absoluteOffset: number } | null): void {
|
|
|
|
|
this.element.innerHTML = html;
|
|
|
|
|
|
|
|
|
|
if (!cursorInfo) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT);
|
|
|
|
|
let remaining = cursorInfo.absoluteOffset;
|
|
|
|
|
let node: Text | null;
|
|
|
|
|
|
|
|
|
|
while ((node = walker.nextNode() as Text | null)) {
|
|
|
|
|
if (remaining <= node.length) {
|
|
|
|
|
const sel = window.getSelection()!;
|
|
|
|
|
const range = document.createRange();
|
|
|
|
|
range.setStart(node, remaining);
|
|
|
|
|
range.collapse(true);
|
|
|
|
|
sel.removeAllRanges();
|
|
|
|
|
sel.addRange(range);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
remaining -= node.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.updateEditingContext();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 09:59:30 -07:00
|
|
|
htmlToMarkdown(html?: string): string {
|
2026-04-28 18:17:32 -07:00
|
|
|
return this.converter.toMarkdown(html || this.element.innerHTML);
|
2026-04-28 09:59:30 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getMarkdown(): string {
|
2026-04-28 20:18:19 -07:00
|
|
|
if (this.cachedMarkdown !== null) {
|
|
|
|
|
return this.cachedMarkdown;
|
|
|
|
|
}
|
2026-04-28 09:59:30 -07:00
|
|
|
if (this.getState() === this.states.EDIT) {
|
|
|
|
|
let html = this.element.innerHTML;
|
|
|
|
|
html = html.replace(/<(?:div|br)>/ig, '');
|
|
|
|
|
html = html.replace(/<\/div>/ig, '\n');
|
|
|
|
|
this.cachedMarkdown = decodeHtmlEntities(html);
|
|
|
|
|
} else if (this.getState() === this.states.WYSIWYG) {
|
|
|
|
|
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
|
2026-04-28 20:18:19 -07:00
|
|
|
} else {
|
2026-04-28 09:59:30 -07:00
|
|
|
this.cachedMarkdown = this.element.textContent || '';
|
|
|
|
|
}
|
|
|
|
|
return this.cachedMarkdown;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wysiwyg(): void {
|
|
|
|
|
if (this.getState() === this.states.WYSIWYG) return;
|
2026-04-29 00:48:06 -07:00
|
|
|
this.vim?.detach();
|
2026-04-28 09:59:30 -07:00
|
|
|
this.element.contentEditable = 'true';
|
|
|
|
|
this.element.innerHTML = this.getHTML();
|
|
|
|
|
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
|
|
|
|
const macroEl = el as HTMLElement;
|
|
|
|
|
if (macroEl.dataset.editable === 'false') {
|
|
|
|
|
macroEl.contentEditable = 'false';
|
|
|
|
|
macroEl.style.opacity = '0.5';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
this.setState(this.states.WYSIWYG);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
edit(): void {
|
2026-04-28 18:17:32 -07:00
|
|
|
if (!this.theme.features?.sourceMode) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-28 09:59:30 -07:00
|
|
|
if (this.state === this.states.EDIT) return;
|
|
|
|
|
this.element.contentEditable = 'true';
|
|
|
|
|
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
2026-04-29 00:48:06 -07:00
|
|
|
this.vim?.attach(this.element);
|
2026-04-28 09:59:30 -07:00
|
|
|
this.setState(this.states.EDIT);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
insertAtCursor(node: Node): void {
|
|
|
|
|
const sel = window.getSelection()!;
|
|
|
|
|
const range = sel.getRangeAt(0);
|
|
|
|
|
range.deleteContents();
|
|
|
|
|
range.insertNode(node);
|
|
|
|
|
range.setStartAfter(node);
|
|
|
|
|
this.element.focus();
|
|
|
|
|
sel.removeAllRanges();
|
|
|
|
|
sel.addRange(range);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 18:39:16 -07:00
|
|
|
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
|
|
|
|
|
export { RibbitEditor as Editor };
|
|
|
|
|
export { Ribbit as Viewer };
|
|
|
|
|
export { HopDown };
|
|
|
|
|
export { inlineTag };
|
|
|
|
|
export { defaultTags, defaultBlockTags, defaultInlineTags };
|
|
|
|
|
export { defaultTheme };
|
|
|
|
|
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
|
2026-04-28 23:08:20 -07:00
|
|
|
export { ToolbarManager } from './toolbar';
|
2026-04-29 00:32:54 -07:00
|
|
|
export { VimHandler } from './vim';
|
2026-04-28 20:03:58 -07:00
|
|
|
export type { MacroDef };
|