This commit is contained in:
evilchili 2026-05-15 11:57:10 -07:00
parent 818ee418d5
commit 2bbb0ba25f
2 changed files with 1001 additions and 0 deletions

View File

@ -1,3 +1,151 @@
/*
* ribbit-core.css functional editor styles. Always load this.
*
* 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;
}
#ribbit.loaded {
display: block;
}
/* ── 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: none;
}
#ribbit.wysiwyg .ribbit-editing > .md-delim {
display: inline;
opacity: 0.4;
font-weight: normal;
font-style: normal;
font-family: monospace;
font-size: 0.85em;
}
/* 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.4;
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 {
font-family: monospace;
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;
}
/* ── Vim mode indicators ────────────────────────────────────────────────────── */
#ribbit.vim-normal {
cursor: default;
caret-color: transparent;
border-left: 3px solid #4af;
}
#ribbit.vim-insert {
border-left: 3px solid #4f4;
}
/* /*
* ribbit-core.css functional editor styles. Always load this. * ribbit-core.css functional editor styles. Always load this.
* These styles control editor state visibility and behavior. * These styles control editor state visibility and behavior.

View File

@ -1,3 +1,856 @@
/*
* ribbit-editor.ts Styled-source editing extension for Ribbit.
*
* The editor is always a markdown text editor. There is no separate
* WYSIWYG mode the user edits markdown directly, but CSS styling
* makes it look like rendered output. Delimiters (**, *, `, etc.)
* are hidden when the cursor is outside their span, and revealed
* when the cursor enters it.
*
* Two CSS states replace the old three-mode system:
* editing: contentEditable="true", delimiters revealed on focus
* viewing: contentEditable="false", all delimiters hidden
*
* The DOM is identical in both states only CSS changes. This
* eliminates all conversion-during-editing bugs and removes the
* flattenrebuild pipeline entirely.
*
* getMarkdown() reads element.textContent directly. Because every
* delimiter character lives in a text node inside a .md-delim span,
* textContent always equals the original markdown source no
* conversion is needed.
*
* getHTML() runs the existing HopDown tokenizer on demand (export,
* save, API calls) never during editing.
*/
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
import { defaultTheme } from './default-theme';
import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
import { VimHandler } from './vim';
import type { Tag } from './types';
import { type MacroDef } from './macros';
// ─── Constants ────────────────────────────────────────────────────────────────
// CSS class applied to the formatting span the cursor is currently inside.
// CSS uses this to reveal .md-delim children for that span only.
const EDITING_CONTEXT_CLASS = 'ribbit-editing';
// CSS class prefix for all styled-source block divs.
// Each block div gets one of: md-paragraph, md-h1…md-h6,
// md-blockquote, md-list-item, md-ol-list-item, md-pre.
const BLOCK_CLASS_PREFIX = 'md-';
// CSS class applied to all delimiter spans (e.g. the ** in **bold**).
// CSS hides these in viewing state, reveals them in editing state.
const DELIM_CLASS = 'md-delim';
// CSS class applied to list-item prefix spans (e.g. "- " or "1. ").
// Kept in textContent so getMarkdown() sees the marker; CSS hides
// it in viewing state and replaces it with a real list-item bullet.
const LIST_PREFIX_CLASS = 'md-list-prefix';
// data- attribute on inline formatting spans, used to identify them
// during selectionchange so we can toggle EDITING_CONTEXT_CLASS.
const INLINE_SPAN_ATTR = 'data-md-span';
// ─── Block classification ─────────────────────────────────────────────────────
// A block rule maps a test against a raw markdown line to a CSS class
// and the length of the prefix that should be wrapped in .md-delim.
// Rules are checked in order; the first match wins.
interface BlockRule {
// Name used to build the CSS class: md-{name}
name: string;
// Returns the length of the block prefix (e.g. 3 for "## "),
// or null if this rule does not match the line.
prefixLength: (line: string) => number | null;
// True if the prefix should use LIST_PREFIX_CLASS instead of DELIM_CLASS.
isList?: boolean;
}
const HEADING_PATTERN = /^(?<hashes>#{1,6}) /;
const BLOCKQUOTE_PATTERN = /^> /;
const UNORDERED_LIST_PATTERN = /^[-*+] /;
const ORDERED_LIST_PATTERN = /^\d+\. /;
// Block rules in priority order. Paragraph is the implicit fallback.
const BLOCK_RULES: BlockRule[] = [
{
name: 'pre',
prefixLength: (line) => {
const FENCE_PATTERN = /^(?<fence>`{3,}|~{3,})/;
const match = line.match(FENCE_PATTERN);
return match ? match[0].length : null;
},
},
{
name: 'heading',
prefixLength: (line) => {
const match = line.match(HEADING_PATTERN);
return match ? match[0].length : null;
},
},
{
name: 'blockquote',
prefixLength: (line) => BLOCKQUOTE_PATTERN.test(line) ? 2 : null,
},
{
name: 'list-item',
prefixLength: (line) => {
if (!UNORDERED_LIST_PATTERN.test(line)) {
return null;
}
return line.indexOf(' ') + 1;
},
isList: true,
},
{
name: 'ol-list-item',
prefixLength: (line) => {
if (!ORDERED_LIST_PATTERN.test(line)) {
return null;
}
return line.indexOf(' ') + 1;
},
isList: true,
},
];
// ─── Inline rules ─────────────────────────────────────────────────────────────
// An inline rule describes how to detect and wrap a delimiter pair
// in a single line of text. Rules are applied left-to-right.
interface InlineRule {
// The CSS class applied to the wrapper span (e.g. 'md-bold').
cls: string;
// The delimiter string on both sides (e.g. '**').
delimiter: string;
// The regex to match a complete delimited run. Must have a
// named capture group 'content' for the text between delimiters.
pattern: RegExp;
}
// Link is special: it has two delimiters and an href, so it gets
// its own handling in parseInline rather than going through InlineRule.
const LINK_PATTERN = /\[(?<text>[^\]]+)\]\((?<href>[^)]+)\)/g;
// Inline rules in priority order (longer/higher-precedence delimiters first
// so *** is tried before ** which is tried before *). Derived from the
// existing tag definitions in defaultInlineTags wherever possible [C10].
const INLINE_RULES: InlineRule[] = [
{
cls: 'md-bold-italic',
delimiter: '***',
pattern: /\*\*\*(?<content>.+?)\*\*\*/g,
},
{
cls: 'md-bold',
delimiter: '**',
pattern: /\*\*(?<content>.+?)\*\*/g,
},
{
cls: 'md-italic',
delimiter: '*',
pattern: /\*(?<content>.+?)\*/g,
},
{
cls: 'md-strikethrough',
delimiter: '~~',
pattern: /~~(?<content>.+?)~~/g,
},
{
cls: 'md-code',
delimiter: '`',
pattern: /`(?<content>[^`]+)`/g,
},
];
// ─── RibbitEditor ─────────────────────────────────────────────────────────────
/**
* Styled-source WYSIWYG editor. Extends Ribbit's read-only viewer with
* contentEditable support. The user always edits raw markdown; CSS renders
* it visually. Replaces the old flattenrebuild pipeline with a per-line
* incremental DOM update [see STYLED_SOURCE_DESIGN.md].
*
* const editor = new RibbitEditor({ editorId: 'my-element' });
* editor.run();
* editor.wysiwyg();
*/
export class RibbitEditor extends Ribbit {
private vim?: VimHandler;
// The formatting span the cursor was last inside. Tracked so we
// can remove EDITING_CONTEXT_CLASS when the cursor moves away.
private activeFormattingSpan: HTMLElement | null = null;
/**
* Initialize the editor with view/wysiwyg states, bind DOM events,
* and optionally attach vim keybindings.
*
* const editor = new RibbitEditor({ editorId: 'content' });
* editor.run();
* editor.wysiwyg(); // enter styled-source editing
*/
run(): void {
this.states = {
VIEW: 'view',
WYSIWYG: 'wysiwyg',
};
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');
}
});
}
this.#bindEvents();
this.element.classList.add('loaded');
if (this.autoToolbar) {
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
}
this.view();
this.emitReady();
}
// ── Event binding ──────────────────────────────────────────────────────────
#bindEvents(): void {
let debounceTimer: number | undefined;
this.element.addEventListener('input', () => {
if (this.state !== this.states.WYSIWYG) {
return;
}
this.#updateCurrentBlock();
clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
this.notifyChange();
}, 300);
});
this.element.addEventListener('keydown', (event: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
this.#dispatchKeydown(event);
});
this.element.addEventListener('keyup', (event: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
if (event.key.startsWith('Arrow')) {
this.#updateEditingContext();
}
});
document.addEventListener('selectionchange', () => {
if (this.state !== this.states.WYSIWYG) {
return;
}
this.#updateEditingContext();
});
document.addEventListener('click', (event: MouseEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
if (!this.element.contains(event.target as Node)) {
this.#clearEditingContext();
}
});
}
// ── Mode switching ─────────────────────────────────────────────────────────
/**
* Switch to styled-source editing mode. Renders the current markdown
* as a styled DOM (one block div per line) and enables contentEditable.
* The DOM is never rebuilt on mode switch only CSS changes.
*
* editor.wysiwyg();
* // user now edits markdown directly with CSS rendering
*/
wysiwyg(): void {
if (this.getState() === this.states.WYSIWYG) {
return;
}
this.invalidateCache();
this.vim?.detach();
this.collaboration?.connect();
this.element.innerHTML = '';
this.element.appendChild(this.#markdownToStyledDOM(this.getMarkdown()));
this.element.contentEditable = 'true';
// Macro islands are non-editable; their source is in data-source
for (const macroElement of Array.from(this.element.querySelectorAll('.macro'))) {
const htmlMacro = macroElement as HTMLElement;
if (htmlMacro.dataset.editable === 'false') {
htmlMacro.contentEditable = 'false';
}
}
this.setState(this.states.WYSIWYG);
}
/**
* Convert the editor's current styled DOM back to markdown.
* Because delimiter characters live in text nodes inside .md-delim
* spans, element.textContent == the original markdown source.
* No conversion needed [see STYLED_SOURCE_DESIGN.md §getMarkdown()].
*
* const markdown = editor.getMarkdown(); // "**hello** world"
*/
getMarkdown(): string {
if (this.getState() === this.states.WYSIWYG) {
// Each block div's textContent is one markdown line.
// Macro islands emit their data-source value instead.
return Array.from(this.element.children)
.map((block) => this.#blockToMarkdown(block as HTMLElement))
.join('\n');
}
// VIEW state: element contains rendered HTML — fall back to
// the cached markdown that was used to render it.
if (this.cachedMarkdown !== null) {
return this.cachedMarkdown;
}
return this.element.textContent || '';
}
/**
* Insert a DOM node at the current cursor position. Used by toolbar
* buttons and macros to inject content.
*
* const img = document.createElement('img');
* img.src = '/photo.jpg';
* editor.insertAtCursor(img);
*/
insertAtCursor(node: Node): void {
const selection = window.getSelection()!;
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(node);
range.setStartAfter(node);
this.element.focus();
selection.removeAllRanges();
selection.addRange(range);
}
// ── Styled DOM construction ────────────────────────────────────────────────
/**
* Convert a full markdown string to a styled-source DocumentFragment.
* One block <div> per line; inline delimiters wrapped in .md-delim spans.
* Called once on wysiwyg() entry and after paste.
*
* editor.element.appendChild(editor.#markdownToStyledDOM('# Hello\n**bold**'));
*/
#markdownToStyledDOM(markdown: string): DocumentFragment {
const fragment = document.createDocumentFragment();
for (const line of markdown.split('\n')) {
fragment.appendChild(this.#buildBlock(line));
}
return fragment;
}
/**
* Build a single styled block <div> from one markdown line.
* Classifies the line, wraps the block prefix in a .md-delim span,
* and parses inline formatting for the remaining content.
*
* this.#buildBlock('## Hello **world**')
* // <div class="md-heading">
* // <span class="md-delim">## </span>
* // Hello <span class="md-bold">…</span>
* // </div>
*/
#buildBlock(line: string): HTMLDivElement {
const block = document.createElement('div');
if (line === '') {
block.className = `${BLOCK_CLASS_PREFIX}paragraph`;
block.appendChild(document.createElement('br'));
return block;
}
for (const rule of BLOCK_RULES) {
const prefixLength = rule.prefixLength(line);
if (prefixLength === null) {
continue;
}
// Headings carry their level in the class name [C10]
if (rule.name === 'heading') {
const match = line.match(HEADING_PATTERN)!;
block.className = `${BLOCK_CLASS_PREFIX}h${match.groups!.hashes.length}`;
} else {
block.className = `${BLOCK_CLASS_PREFIX}${rule.name}`;
}
const prefixSpan = document.createElement('span');
prefixSpan.className = rule.isList ? LIST_PREFIX_CLASS : DELIM_CLASS;
prefixSpan.textContent = line.slice(0, prefixLength);
block.appendChild(prefixSpan);
block.appendChild(this.#parseInline(line.slice(prefixLength)));
return block;
}
// Fallback: plain paragraph
block.className = `${BLOCK_CLASS_PREFIX}paragraph`;
block.appendChild(this.#parseInline(line));
return block;
}
/**
* Parse an inline markdown string into a DocumentFragment of text
* nodes and styled <span> elements. Each span wraps its delimiters
* in .md-delim children so that span.textContent == the original
* markdown source (enabling getMarkdown() = textContent).
*
* this.#parseInline('hello **world** and `code`')
*/
#parseInline(text: string): DocumentFragment {
// Stage 1: tokenise into raw-text segments and matched parts.
// We walk all rules left-to-right, splitting segments as we go.
// Each segment is either raw (unmatched) or a matched inline rule.
interface RawSegment { raw: true; text: string }
interface RuleMatch { raw: false; rule: InlineRule; content: string; fullMatch: string }
interface LinkMatch { raw: false; isLink: true; text: string; href: string; fullMatch: string }
type Segment = RawSegment | RuleMatch | LinkMatch;
let segments: Segment[] = [{ raw: true, text }];
for (const rule of INLINE_RULES) {
const nextSegments: Segment[] = [];
for (const segment of segments) {
if (!segment.raw) {
nextSegments.push(segment);
continue;
}
let lastIndex = 0;
let match: RegExpExecArray | null;
rule.pattern.lastIndex = 0;
while ((match = rule.pattern.exec(segment.text)) !== null) {
if (match.index > lastIndex) {
nextSegments.push({ raw: true, text: segment.text.slice(lastIndex, match.index) });
}
nextSegments.push({
raw: false,
rule,
content: match.groups!.content,
fullMatch: match[0],
});
lastIndex = match.index + match[0].length;
}
if (lastIndex < segment.text.length) {
nextSegments.push({ raw: true, text: segment.text.slice(lastIndex) });
}
}
segments = nextSegments;
}
// Handle links in a second pass over raw segments only [C14]
const withLinks: Segment[] = [];
for (const segment of segments) {
if (!segment.raw) {
withLinks.push(segment);
continue;
}
let lastIndex = 0;
let match: RegExpExecArray | null;
LINK_PATTERN.lastIndex = 0;
while ((match = LINK_PATTERN.exec(segment.text)) !== null) {
if (match.index > lastIndex) {
withLinks.push({ raw: true, text: segment.text.slice(lastIndex, match.index) });
}
withLinks.push({
raw: false,
isLink: true,
text: match.groups!.text,
href: match.groups!.href,
fullMatch: match[0],
});
lastIndex = match.index + match[0].length;
}
if (lastIndex < segment.text.length) {
withLinks.push({ raw: true, text: segment.text.slice(lastIndex) });
}
}
// Stage 2: build DOM nodes from the token list
const fragment = document.createDocumentFragment();
for (const segment of withLinks) {
if (segment.raw) {
fragment.appendChild(document.createTextNode(segment.text));
continue;
}
const span = document.createElement('span');
span.setAttribute(INLINE_SPAN_ATTR, '1');
if ('isLink' in segment) {
// Link: [text](href)
// All three parts go into .md-delim spans so textContent
// reproduces the full markdown [( href )] syntax
span.className = 'md-link';
span.appendChild(this.#makeDelimSpan('['));
const linkTextNode = document.createElement('span');
linkTextNode.className = 'md-link-text';
linkTextNode.textContent = segment.text;
span.appendChild(linkTextNode);
span.appendChild(this.#makeDelimSpan(`](${segment.href})`));
} else {
// Standard delimiter pair: **content**
span.className = segment.rule.cls;
span.appendChild(this.#makeDelimSpan(segment.rule.delimiter));
span.appendChild(document.createTextNode(segment.content));
span.appendChild(this.#makeDelimSpan(segment.rule.delimiter));
}
fragment.appendChild(span);
}
return fragment;
}
/** Create a <span class="md-delim"> with the given text content. */
#makeDelimSpan(text: string): HTMLSpanElement {
const span = document.createElement('span');
span.className = DELIM_CLASS;
span.textContent = text;
return span;
}
// ── Per-line incremental update ────────────────────────────────────────────
/**
* On each input event, find the block div containing the cursor,
* read its textContent (which is valid markdown), and rebuild only
* that block's children. The rest of the document is untouched.
*
* Complexity: O(block length) per keystroke, not O(document length).
*/
#updateCurrentBlock(): void {
const block = this.#findCurrentBlock();
if (!block) {
return;
}
// Preserve the caret position across the rebuild
const caretOffset = this.#getCaretOffset(block);
// Normalize &nbsp; that browsers insert in contentEditable.
// Without this, NBSP characters in textContent break pattern
// matching for block classifiers like "## " or "> ".
const lineText = block.textContent!.replace(/\u00A0/g, ' ');
const newBlock = this.#buildBlock(lineText);
block.className = newBlock.className;
block.innerHTML = '';
while (newBlock.firstChild) {
block.appendChild(newBlock.firstChild);
}
this.#restoreCaret(block, caretOffset);
this.#updateEditingContext();
}
// ── Keyboard handling ──────────────────────────────────────────────────────
/**
* Handle Enter and Backspace ourselves; route all other keys to the
* block tag's handleKeydown if it has one. This replaces the old
* dispatchKeydown which routed through the full tag system [C14].
*/
#dispatchKeydown(event: KeyboardEvent): void {
// Dispatch to the block tag's own key handler first, so that
// tags like HeadingTag and ListTag can override Enter/Backspace.
const block = this.#findCurrentBlock();
if (block) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const tagForBlock = this.converter.getBlockTags().find((tag) => {
if (typeof tag.selector !== 'string') {
return false;
}
return tag.selector.split(',').some(
(selector) => block.tagName === selector.trim()
);
});
if (tagForBlock?.handleKeydown) {
const handled = tagForBlock.handleKeydown(block, event, selection, this);
if (handled) {
event.preventDefault();
return;
}
}
}
}
if (event.key === 'Enter') {
event.preventDefault();
this.#handleEnter();
return;
}
if (event.key === 'Backspace') {
if (this.#handleBackspace()) {
event.preventDefault();
}
}
}
/**
* Enter: split the current block at the caret into two block divs.
* Before: one div with text "hello|world"
* After: two divs "hello" and "world"
*/
#handleEnter(): void {
const block = this.#findCurrentBlock();
if (!block) {
return;
}
const offset = this.#getCaretOffset(block);
const text = block.textContent!.replace(/\u00A0/g, ' ');
const before = text.slice(0, offset);
const after = text.slice(offset);
const firstBlock = this.#buildBlock(before);
const secondBlock = this.#buildBlock(after || '');
block.className = firstBlock.className;
block.innerHTML = '';
while (firstBlock.firstChild) {
block.appendChild(firstBlock.firstChild);
}
block.after(secondBlock);
this.#restoreCaret(secondBlock, 0);
}
/**
* Backspace at offset 0: merge the current block with the previous one.
* Returns true if we handled it (caller should preventDefault), false
* to let the browser handle it normally.
*/
#handleBackspace(): boolean {
const block = this.#findCurrentBlock();
if (!block) {
return false;
}
const offset = this.#getCaretOffset(block);
if (offset !== 0) {
return false;
}
const previousBlock = block.previousElementSibling as HTMLElement | null;
if (!previousBlock) {
return false;
}
const previousLength = previousBlock.textContent!.length;
const merged = previousBlock.textContent! + block.textContent!.replace(/\u00A0/g, ' ');
const mergedBlock = this.#buildBlock(merged);
previousBlock.className = mergedBlock.className;
previousBlock.innerHTML = '';
while (mergedBlock.firstChild) {
previousBlock.appendChild(mergedBlock.firstChild);
}
block.remove();
this.#restoreCaret(previousBlock, previousLength);
return true;
}
// ── Cursor tracking ────────────────────────────────────────────────────────
/**
* Walk up from the cursor to find the direct child of the editor
* element that contains it. That is the current block div.
*
* const block = this.#findCurrentBlock(); // <div class="md-paragraph">
*/
#findCurrentBlock(): HTMLElement | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return null;
}
let node: Node | null = selection.anchorNode;
while (node && node !== this.element) {
if (node.nodeType === 1 && node.parentNode === this.element) {
return node as HTMLElement;
}
node = node.parentNode;
}
return null;
}
/**
* Walk the block's text nodes to get the total character offset
* from the start of the block to the cursor. Survives DOM rebuilds
* because it works on character counts, not node references.
*
* const offset = this.#getCaretOffset(block); // 7
*/
#getCaretOffset(block: HTMLElement): number {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return 0;
}
const range = document.createRange();
range.setStart(block, 0);
range.setEnd(selection.anchorNode!, selection.anchorOffset);
return range.toString().length;
}
/**
* Walk the block's text nodes and place the cursor at the given
* character offset. Called after every block rebuild to restore
* the caret to where the user was typing.
*
* this.#restoreCaret(block, 7);
*/
#restoreCaret(block: HTMLElement, offset: number): void {
const selection = window.getSelection();
if (!selection) {
return;
}
const range = document.createRange();
let remaining = offset;
const placed = this.#walkForCaret(block, range, remaining);
if (!placed) {
range.selectNodeContents(block);
range.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
}
/**
* Recursively walk text nodes in the subtree rooted at `node`,
* decrementing `remaining` for each character encountered. When
* remaining reaches zero, sets range.start and returns true.
*/
#walkForCaret(node: Node, range: Range, remaining: number): boolean {
if (node.nodeType === 3) {
const textNode = node as Text;
if (remaining <= textNode.length) {
range.setStart(textNode, remaining);
range.collapse(true);
return true;
}
// Mutate remaining via closure — TypeScript doesn't allow
// reassigning a parameter across recursive calls cleanly,
// so we use the return-value protocol: false = not placed yet,
// the caller subtracts and recurses.
return false;
}
let consumed = 0;
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === 3) {
const textNode = child as Text;
if (remaining - consumed <= textNode.length) {
range.setStart(textNode, remaining - consumed);
range.collapse(true);
return true;
}
consumed += textNode.length;
} else {
const childLength = (child.textContent || '').length;
if (remaining - consumed <= childLength) {
// Recurse into this subtree with adjusted remaining
const placed = this.#walkForCaret(child, range, remaining - consumed);
if (placed) {
return true;
}
}
consumed += childLength;
}
}
return false;
}
/**
* On selectionchange, find the nearest ancestor that is an inline
* formatting span and add EDITING_CONTEXT_CLASS to it so CSS reveals
* its delimiters. Remove it from the previous span.
*/
#updateEditingContext(): void {
this.#clearEditingContext();
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
let node: Node | null = selection.anchorNode;
while (node && node !== this.element) {
if (node.nodeType === 1) {
const element = node as HTMLElement;
if (element.hasAttribute(INLINE_SPAN_ATTR)) {
element.classList.add(EDITING_CONTEXT_CLASS);
this.activeFormattingSpan = element;
return;
}
}
node = node.parentNode;
}
}
/** Remove EDITING_CONTEXT_CLASS from the previously active span. */
#clearEditingContext(): void {
if (this.activeFormattingSpan) {
this.activeFormattingSpan.classList.remove(EDITING_CONTEXT_CLASS);
this.activeFormattingSpan = null;
}
}
// ── getMarkdown helpers ────────────────────────────────────────────────────
/**
* Serialize a single block div to its markdown line. Macro islands
* emit their data-source value; all other blocks emit textContent.
*
* this.#blockToMarkdown(block) // "## Hello **world**"
*/
#blockToMarkdown(block: HTMLElement): string {
// Empty block (just a <br>) → blank line
if (!block.textContent!.trim() && block.querySelector('br')) {
return '';
}
// Macro islands store their source text in data-source
if (block.dataset.macro) {
return block.dataset.source || '';
}
return block.textContent || '';
}
}
// Public API — matches the previous export shape so consumers don't break.
export { RibbitEditor as Editor };
export { Ribbit as Viewer };
export { inlineTag };
export { defaultTags, defaultBlockTags, defaultInlineTags };
export { defaultTheme };
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
export { ToolbarManager } from './toolbar';
export { VimHandler } from './vim';
export { CollaborationManager } from './collaboration';
export type { MacroDef };
/* /*
* ribbit-editor.ts WYSIWYG editing extension for Ribbit. * ribbit-editor.ts WYSIWYG editing extension for Ribbit.
*/ */