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

View File

@ -4,9 +4,6 @@ module.exports = {
testEnvironment: 'node', testEnvironment: 'node',
roots: ['<rootDir>/test'], roots: ['<rootDir>/test'],
testPathIgnorePatterns: ['/node_modules/', '/test/integration/'], testPathIgnorePatterns: ['/node_modules/', '/test/integration/'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: { transform: {
'^.+\\.tsx?$': ['ts-jest', { '^.+\\.tsx?$': ['ts-jest', {
tsconfig: { tsconfig: {

1
src/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './ts';

2
src/ts/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./ribbit";
export * from "./hopdown";

View File

@ -200,6 +200,19 @@ export class RibbitEditor extends Ribbit {
if (this.theme.features?.vim) { if (this.theme.features?.vim) {
// TODO // 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(); this.#bindEvents();
@ -265,7 +278,6 @@ export class RibbitEditor extends Ribbit {
/** /**
* Switch to styled-source editing mode. Renders the current markdown * Switch to styled-source editing mode. Renders the current markdown
* as a styled DOM (one block div per line) and enables contentEditable. * 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(); * editor.wysiwyg();
* // user now edits markdown directly with CSS rendering * // user now edits markdown directly with CSS rendering
@ -274,10 +286,14 @@ export class RibbitEditor extends Ribbit {
if (this.getState() === this.states.WYSIWYG) { if (this.getState() === this.states.WYSIWYG) {
return; 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.collaboration?.connect();
this.element.innerHTML = ''; this.element.innerHTML = '';
this.element.appendChild(this.#markdownToStyledDOM(this.getMarkdown())); this.element.appendChild(this.#markdownToStyledDOM(markdown));
this.element.contentEditable = 'true'; this.element.contentEditable = 'true';
// Macro islands are non-editable; their source is in data-source // Macro islands are non-editable; their source is in data-source
for (const macroElement of Array.from(this.element.querySelectorAll('.macro'))) { 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. * Convert the editor's current styled DOM back to markdown.
* Because delimiter characters live in text nodes inside .md-delim * In wysiwyg state reads directly from the styled DOM because
* spans, element.textContent == the original markdown source. * every delimiter lives in a .md-delim text node, textContent
* No conversion needed [see STYLED_SOURCE_DESIGN.md §getMarkdown()]. * 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" * const markdown = editor.getMarkdown(); // "**hello** world"
*/ */
@ -305,12 +322,9 @@ export class RibbitEditor extends Ribbit {
.map((block) => this.#blockToMarkdown(block as HTMLElement)) .map((block) => this.#blockToMarkdown(block as HTMLElement))
.join('\n'); .join('\n');
} }
// VIEW state: element contains rendered HTML — fall back to // VIEW state: delegate to base class, which reads sourceMarkdown
// the cached markdown that was used to render it. // (set by view() before rendering) or falls back to textContent.
if (this.cachedMarkdown !== null) { return super.getMarkdown();
return this.cachedMarkdown;
}
return this.element.textContent || '';
} }
/** /**
@ -355,7 +369,7 @@ export class RibbitEditor extends Ribbit {
* and parses inline formatting for the remaining content. * and parses inline formatting for the remaining content.
* *
* this.#buildBlock('## Hello **world**') * this.#buildBlock('## Hello **world**')
* // <div class="md-heading"> * // <div class="md-h2">
* // <span class="md-delim">## </span> * // <span class="md-delim">## </span>
* // Hello <span class="md-bold">…</span> * // Hello <span class="md-bold">…</span>
* // </div> * // </div>
@ -487,7 +501,7 @@ export class RibbitEditor extends Ribbit {
if ('isLink' in segment) { if ('isLink' in segment) {
// Link: [text](href) // Link: [text](href)
// All three parts go into .md-delim spans so textContent // 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.className = 'md-link';
span.appendChild(this.#makeDelimSpan('[')); span.appendChild(this.#makeDelimSpan('['));
const linkTextNode = document.createElement('span'); 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 * Handle Enter and Backspace ourselves; route all other keys to the
* block tag's handleKeydown if it has one. This replaces the old * block tag's handleKeydown if it has one.
* dispatchKeydown which routed through the full tag system [C14].
*/ */
#dispatchKeydown(event: KeyboardEvent): void { #dispatchKeydown(event: KeyboardEvent): void {
// Dispatch to the block tag's own key handler first, so that // Dispatch to the block tag's own key handler first, so that
@ -740,10 +753,6 @@ export class RibbitEditor extends Ribbit {
range.collapse(true); range.collapse(true);
return 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; return false;
} }
let consumed = 0; let consumed = 0;
@ -759,7 +768,6 @@ export class RibbitEditor extends Ribbit {
} else { } else {
const childLength = (child.textContent || '').length; const childLength = (child.textContent || '').length;
if (remaining - consumed <= childLength) { if (remaining - consumed <= childLength) {
// Recurse into this subtree with adjusted remaining
const placed = this.#walkForCaret(child, range, remaining - consumed); const placed = this.#walkForCaret(child, range, remaining - consumed);
if (placed) { if (placed) {
return true; return true;

View File

@ -38,8 +38,6 @@ export class Ribbit {
api: unknown; api: unknown;
element: HTMLElement; element: HTMLElement;
states: Record<string, string>; states: Record<string, string>;
cachedHTML: string | null;
cachedMarkdown: string | null;
state: string | null; state: string | null;
theme: RibbitTheme; theme: RibbitTheme;
themes: ThemeManager; themes: ThemeManager;
@ -51,6 +49,12 @@ export class Ribbit {
private emitter: RibbitEmitter; private emitter: RibbitEmitter;
private macros: MacroDef[]; 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) { constructor(settings: RibbitSettings) {
this.api = settings.api || null; this.api = settings.api || null;
this.element = document.getElementById(settings.editorId || 'ribbit')!; this.element = document.getElementById(settings.editorId || 'ribbit')!;
@ -60,8 +64,6 @@ export class Ribbit {
this.states = { this.states = {
VIEW: 'view', VIEW: 'view',
}; };
this.cachedHTML = null;
this.cachedMarkdown = null;
this.state = null; this.state = null;
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => { this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
@ -69,7 +71,6 @@ export class Ribbit {
this.converter = theme.tags this.converter = theme.tags
? new HopDown({ tags: theme.tags, macros: this.macros }) ? new HopDown({ tags: theme.tags, macros: this.macros })
: new HopDown({ macros: this.macros }); : new HopDown({ macros: this.macros });
this.cachedHTML = null;
this.emitter.emit('themeChange', { this.emitter.emit('themeChange', {
current: theme, current: theme,
previous, previous,
@ -112,14 +113,13 @@ export class Ribbit {
settings.collaboration, settings.collaboration,
{ {
onRemoteUpdate: (content) => { onRemoteUpdate: (content) => {
this.cachedMarkdown = content; this.sourceMarkdown = content;
this.cachedHTML = null;
if (this.getState() !== this.states.VIEW) { if (this.getState() !== this.states.VIEW) {
this.element.innerHTML = this.getHTML(); this.element.innerHTML = this.markdownToHTML(content);
} }
this.emitter.emit('change', { this.emitter.emit('change', {
markdown: content, markdown: content,
html: this.getHTML(), html: this.markdownToHTML(content),
}); });
}, },
onPeersChange: (peers) => { 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') { ... } * if (editor.getState() === 'wysiwyg') { ... }
*/ */
@ -200,7 +200,7 @@ export class Ribbit {
* Transition to a new mode. Updates CSS classes on the editor element * Transition to a new mode. Updates CSS classes on the editor element
* so themes can style each mode differently, and fires modeChange. * so themes can style each mode differently, and fires modeChange.
* *
* editor.setState('edit'); * editor.setState('wysiwyg');
*/ */
setState(newState: string): void { setState(newState: string): void {
const previous = this.state; 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(); * document.getElementById('preview').innerHTML = viewer.getHTML();
*/ */
getHTML(): string { getHTML(): string {
if (this.cachedHTML === null) { return this.markdownToHTML(this.getMarkdown());
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
}
return this.cachedHTML;
} }
/** /**
* Raw markdown of the current content. In view mode this is the * Raw markdown of the current content. In view state reads from
* original text; in edit/wysiwyg mode it's derived from the DOM. * sourceMarkdown if set (preserved before rendering overwrote the
* element), otherwise falls back to element.textContent.
* *
* fetch('/save', { body: editor.getMarkdown() }); * fetch('/save', { body: editor.getMarkdown() });
*/ */
getMarkdown(): string { getMarkdown(): string {
if (this.cachedMarkdown === null) { if (this.sourceMarkdown !== null) {
this.cachedMarkdown = this.element.textContent || ''; return this.sourceMarkdown;
} }
return this.cachedMarkdown; return this.element.textContent || '';
} }
/** /**
@ -270,26 +268,20 @@ export class Ribbit {
* editor.view(); * editor.view();
*/ */
view(): void { view(): void {
if (this.getState() === this.states.VIEW) return; if (this.getState() === this.states.VIEW) {
this.invalidateCache(); 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.collaboration?.disconnect();
this.element.innerHTML = this.getHTML(); this.element.innerHTML = this.markdownToHTML(this.sourceMarkdown);
this.setState(this.states.VIEW); this.setState(this.states.VIEW);
this.element.contentEditable = 'false'; 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 * Request an advisory editing lock. Returns false if another user
* holds the lock. Requires a collaboration transport. * holds the lock. Requires a collaboration transport.
@ -297,7 +289,9 @@ export class Ribbit {
* if (await editor.lockForEditing()) { editor.wysiwyg(); } * if (await editor.lockForEditing()) { editor.wysiwyg(); }
*/ */
async lockForEditing(): Promise<boolean> { async lockForEditing(): Promise<boolean> {
if (!this.collaboration) return false; if (!this.collaboration) {
return false;
}
return this.collaboration.lock(); return this.collaboration.lock();
} }
@ -318,7 +312,9 @@ export class Ribbit {
* await editor.forceLockEditing(); * await editor.forceLockEditing();
*/ */
async forceLockEditing(): Promise<boolean> { async forceLockEditing(): Promise<boolean> {
if (!this.collaboration) return false; if (!this.collaboration) {
return false;
}
return this.collaboration.forceLock(); return this.collaboration.forceLock();
} }
@ -329,7 +325,9 @@ export class Ribbit {
* revisions.forEach(r => console.log(r.id, r.timestamp)); * revisions.forEach(r => console.log(r.id, r.timestamp));
*/ */
async listRevisions(): Promise<Revision[]> { async listRevisions(): Promise<Revision[]> {
if (!this.collaboration) return []; if (!this.collaboration) {
return [];
}
return this.collaboration.listRevisions(); return this.collaboration.listRevisions();
} }
@ -340,7 +338,9 @@ export class Ribbit {
* if (rev) { console.log(rev.content); } * if (rev) { console.log(rev.content); }
*/ */
async getRevision(id: string): Promise<(Revision & { content: string }) | null> { async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
if (!this.collaboration) return null; if (!this.collaboration) {
return null;
}
return this.collaboration.getRevision(id); return this.collaboration.getRevision(id);
} }
@ -351,18 +351,22 @@ export class Ribbit {
* await editor.restoreRevision('abc-123'); * await editor.restoreRevision('abc-123');
*/ */
async restoreRevision(id: string): Promise<void> { async restoreRevision(id: string): Promise<void> {
if (!this.collaboration) return; if (!this.collaboration) {
return;
}
const revision = await this.collaboration.getRevision(id); const revision = await this.collaboration.getRevision(id);
if (!revision) return; if (!revision) {
this.cachedMarkdown = revision.content; return;
this.cachedHTML = this.markdownToHTML(revision.content); }
this.sourceMarkdown = revision.content;
const html = this.markdownToHTML(revision.content);
this.collaboration.sendUpdate(revision.content); this.collaboration.sendUpdate(revision.content);
if (this.getState() !== this.states.VIEW) { if (this.getState() !== this.states.VIEW) {
this.element.innerHTML = this.cachedHTML; this.element.innerHTML = html;
} }
this.emitter.emit('change', { this.emitter.emit('change', {
markdown: revision.content, markdown: revision.content,
html: this.cachedHTML, html,
}); });
} }
@ -373,7 +377,9 @@ export class Ribbit {
* const rev = await editor.createRevision({ label: 'v1.0' }); * const rev = await editor.createRevision({ label: 'v1.0' });
*/ */
async createRevision(metadata?: RevisionMetadata): Promise<Revision | null> { 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); const revision = await this.collaboration.createRevision(this.getMarkdown(), metadata);
if (revision) { if (revision) {
this.emitter.emit('revisionCreated', { 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 * Encode characters that would be interpreted as HTML into numeric
* entities. Used when displaying raw markdown in contentEditable * 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;' * encodeHtmlEntities('<b>hi</b>') // '&#60;b&#62;hi&#60;/b&#62;'
*/ */

View File

@ -1,491 +0,0 @@
import { ribbit, resetDOM } from './setup';
const lib = ribbit();
function mockTransport() {
const receiveListeners: Array<(update: Uint8Array) => void> = [];
const lockListeners: Array<(holder: any) => void> = [];
return {
connected: false,
sent: [] as Uint8Array[],
locked: false,
connect() {
this.connected = true;
},
disconnect() {
this.connected = false;
},
send(update: Uint8Array) {
this.sent.push(update);
},
onReceive(cb: (update: Uint8Array) => void) {
receiveListeners.push(cb);
},
simulateRemote(content: string) {
const encoded = new TextEncoder().encode(content);
receiveListeners.forEach(cb => cb(encoded));
},
lock: async function() {
this.locked = true;
return true;
},
unlock() {
this.locked = false;
},
forceLock: async function() {
this.locked = true;
return true;
},
onLockChange(cb: (holder: any) => void) {
lockListeners.push(cb);
},
simulateLock(holder: any) {
lockListeners.forEach(cb => cb(holder));
},
};
}
function mockPresence() {
const listeners: Array<(peers: any[]) => void> = [];
return {
lastSent: null as any,
send(info: any) {
this.lastSent = info;
},
onUpdate(cb: (peers: any[]) => void) {
listeners.push(cb);
},
simulatePeers(peers: any[]) {
listeners.forEach(cb => cb(peers));
},
};
}
function mockRevisions() {
const store: any[] = [];
return {
store,
list: async () => store,
get: async (id: string) => store.find((rev: any) => rev.id === id),
create: async (content: string, meta?: any) => {
const rev = {
id: String(store.length + 1),
timestamp: new Date().toISOString(),
content,
...meta,
};
store.push(rev);
return rev;
},
};
}
describe('CollaborationManager', () => {
beforeEach(() => resetDOM('initial'));
it('does not create manager without settings', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.collaboration).toBeUndefined();
});
it('creates manager with settings', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
expect(editor.collaboration).toBeDefined();
});
describe('connection lifecycle', () => {
it('connects on wysiwyg', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.wysiwyg();
expect(transport.connected).toBe(true);
});
it('connects on edit', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.edit();
expect(transport.connected).toBe(true);
});
it('disconnects on view', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.wysiwyg();
editor.view();
expect(transport.connected).toBe(false);
});
});
describe('source mode pausing', () => {
it('pauses on entering source mode', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.edit();
expect(editor.collaboration!.isPaused()).toBe(true);
});
it('counts remote changes while paused', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.edit();
transport.simulateRemote('change 1');
transport.simulateRemote('change 2');
expect(editor.collaboration!.getRemoteChangeCount()).toBe(2);
});
it('fires remoteActivity event while paused', (done) => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
on: {
remoteActivity: ({ count }: any) => {
if (count === 1) {
done();
}
},
},
});
editor.run();
editor.edit();
transport.simulateRemote('change');
});
it('resumes on switching to wysiwyg', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.edit();
editor.wysiwyg();
expect(editor.collaboration!.isPaused()).toBe(false);
});
});
describe('locking', () => {
it('lock returns true', async () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
expect(await editor.lockForEditing()).toBe(true);
});
it('forceLock returns true', async () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
expect(await editor.forceLockEditing()).toBe(true);
});
it('fires lockChange event', (done) => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
on: {
lockChange: ({ holder }: any) => {
if (holder?.userId === 'alice') {
done();
}
},
},
});
editor.run();
transport.simulateLock({
userId: 'alice',
displayName: 'Alice',
status: 'active',
lastActive: Date.now(),
});
});
});
describe('presence', () => {
it('sends cursor with status', () => {
const transport = mockTransport();
const presence = mockPresence();
const editor = new lib.Editor({
collaboration: {
transport,
presence,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
color: '#f00',
},
},
});
editor.run();
editor.wysiwyg();
editor.collaboration!.sendCursor(42);
expect(presence.lastSent.status).toBe('active');
expect(presence.lastSent.cursor).toBe(42);
});
it('sends editing status when paused', () => {
const transport = mockTransport();
const presence = mockPresence();
const editor = new lib.Editor({
collaboration: {
transport,
presence,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.edit();
editor.collaboration!.sendCursor(10);
expect(presence.lastSent.status).toBe('editing');
});
it('applies idle status to peers', () => {
const transport = mockTransport();
const presence = mockPresence();
const editor = new lib.Editor({
collaboration: {
transport,
presence,
idleTimeout: 100,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
presence.simulatePeers([
{
userId: 'a',
displayName: 'A',
status: 'active',
lastActive: Date.now() - 200,
},
{
userId: 'b',
displayName: 'B',
status: 'active',
lastActive: Date.now(),
},
]);
const peers = editor.collaboration!.getPeers();
expect(peers[0].status).toBe('idle');
expect(peers[1].status).toBe('active');
});
});
describe('revisions', () => {
it('lists revisions', async () => {
const transport = mockTransport();
const revisions = mockRevisions();
await revisions.create('v1', { author: 'test' });
const editor = new lib.Editor({
collaboration: {
transport,
revisions,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
const list = await editor.listRevisions();
expect(list).toHaveLength(1);
});
it('creates revision', async () => {
const transport = mockTransport();
const revisions = mockRevisions();
const editor = new lib.Editor({
collaboration: {
transport,
revisions,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
const rev = await editor.createRevision({
author: 'test',
summary: 'test rev',
});
expect(rev).toBeDefined();
expect(revisions.store).toHaveLength(1);
});
it('restores revision', async () => {
const transport = mockTransport();
const revisions = mockRevisions();
await revisions.create('old content', { author: 'test' });
const editor = new lib.Editor({
collaboration: {
transport,
revisions,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.wysiwyg();
await editor.restoreRevision('1');
expect(editor.getMarkdown()).toBe('old content');
});
it('fires revisionCreated event', async () => {
const transport = mockTransport();
const revisions = mockRevisions();
let fired = false;
const editor = new lib.Editor({
collaboration: {
transport,
revisions,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
on: {
revisionCreated: () => {
fired = true;
},
},
});
editor.run();
await editor.createRevision({ author: 'test' });
expect(fired).toBe(true);
});
});
});

View File

@ -1,4 +1,5 @@
import { ribbit, resetDOM } from './setup'; import { ribbit, resetDOM } from './setup';
import { HopDown } from '../src';
const lib = ribbit(); const lib = ribbit();
@ -25,7 +26,7 @@ describe('Custom block tags', () => {
selector: 'DETAILS', selector: 'DETAILS',
toMarkdown: (element: any, convert: any) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n', toMarkdown: (element: any, convert: any) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
}; };
const converter = new lib.HopDown({ const converter = new HopDown({
tags: { tags: {
'DETAILS': spoiler, 'DETAILS': spoiler,
...lib.defaultTags, ...lib.defaultTags,
@ -38,15 +39,15 @@ describe('Custom block tags', () => {
describe('HopDown({ exclude })', () => { describe('HopDown({ exclude })', () => {
it('excludes table', () => { it('excludes table', () => {
const converter = new lib.HopDown({ exclude: ['table'] }); const converter = new HopDown({ exclude: ['table'] });
expect(converter.toHTML('| a |\n|---|\n| 1 |')).not.toContain('<table>'); expect(converter.toHTML('| a |\n|---|\n| 1 |')).not.toContain('<table>');
}); });
it('excludes code', () => { it('excludes code', () => {
const converter = new lib.HopDown({ exclude: ['code'] }); const converter = new HopDown({ exclude: ['code'] });
expect(converter.toHTML('`code`')).toBe('<p>`code`</p>'); expect(converter.toHTML('`code`')).toBe('<p>`code`</p>');
}); });
it('other tags still work', () => { it('other tags still work', () => {
const converter = new lib.HopDown({ exclude: ['table'] }); const converter = new HopDown({ exclude: ['table'] });
expect(converter.toHTML('**bold**')).toContain('<strong>bold</strong>'); expect(converter.toHTML('**bold**')).toContain('<strong>bold</strong>');
}); });
}); });
@ -59,7 +60,7 @@ describe('Collision detection', () => {
htmlTag: 'span', htmlTag: 'span',
precedence: 10, precedence: 10,
}); });
expect(() => new lib.HopDown({ expect(() => new HopDown({
tags: { tags: {
...lib.defaultTags, ...lib.defaultTags,
'SPAN': bad, 'SPAN': bad,
@ -75,7 +76,7 @@ describe('Collision detection', () => {
selector: 'STRONG', selector: 'STRONG',
toMarkdown: () => '', toMarkdown: () => '',
}; };
expect(() => new lib.HopDown({ expect(() => new HopDown({
tags: { tags: {
...lib.defaultTags, ...lib.defaultTags,
'STRONG': dup, 'STRONG': dup,
@ -98,7 +99,7 @@ describe('Collision detection', () => {
}); });
// Remove default strikethrough to avoid collision with the custom S/DEL tags // Remove default strikethrough to avoid collision with the custom S/DEL tags
const { 'DEL,S,STRIKE': _, ...tagsWithoutStrikethrough } = lib.defaultTags; const { 'DEL,S,STRIKE': _, ...tagsWithoutStrikethrough } = lib.defaultTags;
expect(() => new lib.HopDown({ expect(() => new HopDown({
tags: { tags: {
...tagsWithoutStrikethrough, ...tagsWithoutStrikethrough,
'S': short, 'S': short,

View File

@ -128,7 +128,7 @@ describe('RibbitEditor modes', () => {
editor.run(); editor.run();
editor.wysiwyg(); editor.wysiwyg();
editor.view(); editor.view();
expect(modes).toEqual(['view', 'wysiwyg', 'edit', 'view']); expect(modes).toEqual(['view', 'wysiwyg', 'view']);
}); });
}); });
@ -231,9 +231,7 @@ describe('Editor htmlToMarkdown', () => {
it('returns markdown in view state', () => { it('returns markdown in view state', () => {
resetDOM('**bold**'); resetDOM('**bold**');
const editor = new lib.Editor({}); const editor = new lib.Editor({});
console.log(editor.getMarkdown());
editor.run(); editor.run();
console.log(editor.getMarkdown());
expect(editor.getMarkdown()).toBe('**bold**'); expect(editor.getMarkdown()).toBe('**bold**');
}); });

View File

@ -1,11 +1,14 @@
import { ribbit } from './setup'; import { ribbit } from './setup';
const lib = ribbit(); const lib = ribbit();
const hopdown = new lib.HopDown(); const editor = new lib.Editor({});
const hopdown = editor.converter;
const H = (md: string) => hopdown.toHTML(md); const H = (md: string) => hopdown.toHTML(md);
const M = (html: string) => hopdown.toMarkdown(html); const M = (html: string) => hopdown.toMarkdown(html);
const rt = (md: string) => M(H(md)); const rt = (md: string) => M(H(md));
describe('Markdown → HTML', () => { describe('Markdown → HTML', () => {
describe('inline formatting', () => { describe('inline formatting', () => {
it('bold', () => expect(H('**bold**')).toBe('<p><strong>bold</strong></p>')); it('bold', () => expect(H('**bold**')).toBe('<p><strong>bold</strong></p>'));

View File

@ -1,9 +1,7 @@
import { ribbit } from './setup'; import { ribbit, resetDOM } from './setup';
const lib = ribbit(); const lib = ribbit();
const spacePattern = / /g;
const macros = [ const macros = [
{ {
name: 'user', name: 'user',
@ -13,7 +11,7 @@ const macros = [
name: 'npc', name: 'npc',
toHTML: ({ keywords }: any) => { toHTML: ({ keywords }: any) => {
const name = keywords.join(' '); const name = keywords.join(' ');
return '<a href="/NPC/' + name.replace(spacePattern, '') + '">' + name + '</a>'; return '<a href="/NPC/' + name.replace(/ /g, '') + '">' + name + '</a>';
}, },
}, },
{ {
@ -26,7 +24,9 @@ const macros = [
}, },
]; ];
const converter = new lib.HopDown({ macros }); const editor = new lib.Editor({macros: macros});
const converter = editor.converter;
const H = (md: string) => converter.toHTML(md); const H = (md: string) => converter.toHTML(md);
const M = (html: string) => converter.toMarkdown(html); const M = (html: string) => converter.toMarkdown(html);