wip; tests passing
This commit is contained in:
parent
9748d12ede
commit
c61b8e2f8b
|
|
@ -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
1
src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './ts';
|
||||||
2
src/ts/index.ts
Normal file
2
src/ts/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./ribbit";
|
||||||
|
export * from "./hopdown";
|
||||||
|
|
@ -69,10 +69,10 @@ interface BlockRule {
|
||||||
isList?: boolean;
|
isList?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEADING_PATTERN = /^(?<hashes>#{1,6}) /;
|
const HEADING_PATTERN = /^(?<hashes>#{1,6}) /;
|
||||||
const BLOCKQUOTE_PATTERN = /^> /;
|
const BLOCKQUOTE_PATTERN = /^> /;
|
||||||
const UNORDERED_LIST_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.
|
// Block rules in priority order. Paragraph is the implicit fallback.
|
||||||
const BLOCK_RULES: BlockRule[] = [
|
const BLOCK_RULES: BlockRule[] = [
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -409,9 +423,9 @@ export class RibbitEditor extends Ribbit {
|
||||||
// Stage 1: tokenise into raw-text segments and matched parts.
|
// Stage 1: tokenise into raw-text segments and matched parts.
|
||||||
// We walk all rules left-to-right, splitting segments as we go.
|
// We walk all rules left-to-right, splitting segments as we go.
|
||||||
// Each segment is either raw (unmatched) or a matched inline rule.
|
// Each segment is either raw (unmatched) or a matched inline rule.
|
||||||
interface RawSegment { raw: true; text: string }
|
interface RawSegment { raw: true; text: string }
|
||||||
interface RuleMatch { raw: false; rule: InlineRule; content: string; fullMatch: string }
|
interface RuleMatch { raw: false; rule: InlineRule; content: string; fullMatch: string }
|
||||||
interface LinkMatch { raw: false; isLink: true; text: string; href: string; fullMatch: string }
|
interface LinkMatch { raw: false; isLink: true; text: string; href: string; fullMatch: string }
|
||||||
type Segment = RawSegment | RuleMatch | LinkMatch;
|
type Segment = RawSegment | RuleMatch | LinkMatch;
|
||||||
|
|
||||||
let segments: Segment[] = [{ raw: true, text }];
|
let segments: Segment[] = [{ raw: true, text }];
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
102
src/ts/ribbit.ts
102
src/ts/ribbit.ts
|
|
@ -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>') // '<b>hi</b>'
|
* encodeHtmlEntities('<b>hi</b>') // '<b>hi</b>'
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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**');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>'));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user