wip; tests passing

This commit is contained in:
evilchili
2026-05-15 14:34:20 -07:00
parent 9748d12ede
commit c61b8e2f8b
10 changed files with 110 additions and 585 deletions
+1
View File
@@ -0,0 +1 @@
export * from './ts';
+2
View File
@@ -0,0 +1,2 @@
export * from "./ribbit";
export * from "./hopdown";
+35 -27
View File
@@ -69,10 +69,10 @@ interface BlockRule {
isList?: boolean;
}
const HEADING_PATTERN = /^(?<hashes>#{1,6}) /;
const BLOCKQUOTE_PATTERN = /^> /;
const HEADING_PATTERN = /^(?<hashes>#{1,6}) /;
const BLOCKQUOTE_PATTERN = /^> /;
const UNORDERED_LIST_PATTERN = /^[-*+] /;
const ORDERED_LIST_PATTERN = /^\d+\. /;
const ORDERED_LIST_PATTERN = /^\d+\. /;
// Block rules in priority order. Paragraph is the implicit fallback.
const BLOCK_RULES: BlockRule[] = [
@@ -200,6 +200,19 @@ export class RibbitEditor extends Ribbit {
if (this.theme.features?.vim) {
// TODO
/*
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();
@@ -265,7 +278,6 @@ export class RibbitEditor extends Ribbit {
/**
* 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
@@ -274,10 +286,14 @@ export class RibbitEditor extends Ribbit {
if (this.getState() === this.states.WYSIWYG) {
return;
}
this.invalidateCache();
// Capture markdown before building the styled DOM, so getMarkdown()
// in wysiwyg state reads from the live styled DOM rather than
// sourceMarkdown (which belongs to view state).
const markdown = this.getMarkdown();
this.sourceMarkdown = null;
this.collaboration?.connect();
this.element.innerHTML = '';
this.element.appendChild(this.#markdownToStyledDOM(this.getMarkdown()));
this.element.appendChild(this.#markdownToStyledDOM(markdown));
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'))) {
@@ -291,9 +307,10 @@ export class RibbitEditor extends Ribbit {
/**
* 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()].
* In wysiwyg state reads directly from the styled DOM — because
* every delimiter lives in a .md-delim text node, textContent
* always equals the original markdown source [see STYLED_SOURCE_DESIGN.md].
* In view state delegates to the base class which reads sourceMarkdown.
*
* const markdown = editor.getMarkdown(); // "**hello** world"
*/
@@ -305,12 +322,9 @@ export class RibbitEditor extends Ribbit {
.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 || '';
// VIEW state: delegate to base class, which reads sourceMarkdown
// (set by view() before rendering) or falls back to textContent.
return super.getMarkdown();
}
/**
@@ -355,7 +369,7 @@ export class RibbitEditor extends Ribbit {
* and parses inline formatting for the remaining content.
*
* this.#buildBlock('## Hello **world**')
* // <div class="md-heading">
* // <div class="md-h2">
* // <span class="md-delim">## </span>
* // Hello <span class="md-bold">…</span>
* // </div>
@@ -409,9 +423,9 @@ export class RibbitEditor extends Ribbit {
// 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 }
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 }];
@@ -487,7 +501,7 @@ export class RibbitEditor extends Ribbit {
if ('isLink' in segment) {
// Link: [text](href)
// All three parts go into .md-delim spans so textContent
// reproduces the full markdown [( href )] syntax
// reproduces the full markdown [text](href) syntax
span.className = 'md-link';
span.appendChild(this.#makeDelimSpan('['));
const linkTextNode = document.createElement('span');
@@ -555,8 +569,7 @@ export class RibbitEditor extends Ribbit {
/**
* 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].
* block tag's handleKeydown if it has one.
*/
#dispatchKeydown(event: KeyboardEvent): void {
// Dispatch to the block tag's own key handler first, so that
@@ -740,10 +753,6 @@ export class RibbitEditor extends Ribbit {
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;
@@ -759,7 +768,6 @@ export class RibbitEditor extends Ribbit {
} 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;
+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;'
*/