Compare commits
5 Commits
main
...
styled-sou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38af541c5d | ||
|
|
80c34a1483 | ||
|
|
c61b8e2f8b | ||
|
|
9748d12ede | ||
|
|
2bbb0ba25f |
|
|
@ -4,9 +4,6 @@ module.exports = {
|
|||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/test'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/test/integration/'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': ['ts-jest', {
|
||||
tsconfig: {
|
||||
|
|
|
|||
4321
package-lock.json
generated
4321
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -24,10 +24,14 @@
|
|||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"esbuild": "^0.28.0",
|
||||
"happy-dom": "^14.12.3",
|
||||
"happy-dom": "^20.9.0",
|
||||
"jest": "^29.7.0",
|
||||
"selenium-webdriver": "^4.43.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^6.0.3",
|
||||
"live-server": "^1.2.0",
|
||||
"node-watch": "^0.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
src/index.ts
Normal file
1
src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './ts';
|
||||
|
|
@ -1,9 +1,18 @@
|
|||
/*
|
||||
* ribbit-core.css — functional editor styles. Always load this.
|
||||
* These styles control editor state visibility and behavior.
|
||||
* They should not be overridden by themes.
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -12,55 +21,125 @@
|
|||
display: block;
|
||||
}
|
||||
|
||||
#ribbit.edit {
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
/* ── 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 .md {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ribbit-editing::before,
|
||||
.ribbit-editing::after {
|
||||
opacity: 0.3;
|
||||
#ribbit.wysiwyg .ribbit-editing > .md-delim {
|
||||
display: inline;
|
||||
opacity: 0.8;
|
||||
/*
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
*/
|
||||
}
|
||||
|
||||
[data-speculative]::before,
|
||||
[data-speculative]::after {
|
||||
content: none !important;
|
||||
/* 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.8;
|
||||
/*
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
*/
|
||||
}
|
||||
|
||||
#ribbit.wysiwyg strong.ribbit-editing::before,
|
||||
#ribbit.wysiwyg strong.ribbit-editing::after {
|
||||
content: "**";
|
||||
#ribbit.view .md-list-prefix {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#ribbit.wysiwyg em.ribbit-editing::before,
|
||||
#ribbit.wysiwyg em.ribbit-editing::after {
|
||||
content: "*";
|
||||
/* ── Inline formatting ──────────────────────────────────────────────────────── */
|
||||
|
||||
.md-bold,
|
||||
.md-bold-italic {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#ribbit.wysiwyg code.ribbit-editing::before,
|
||||
#ribbit.wysiwyg code.ribbit-editing::after {
|
||||
content: "\`";
|
||||
.md-italic,
|
||||
.md-bold-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#ribbit.wysiwyg h1.ribbit-editing::before { content: "# "; font-size: 0.5em; }
|
||||
#ribbit.wysiwyg h2.ribbit-editing::before { content: "## "; font-size: 0.5em; }
|
||||
#ribbit.wysiwyg h3.ribbit-editing::before { content: "### "; font-size: 0.5em; }
|
||||
#ribbit.wysiwyg h4.ribbit-editing::before { content: "#### "; font-size: 0.5em; }
|
||||
#ribbit.wysiwyg h5.ribbit-editing::before { content: "##### "; font-size: 0.5em; }
|
||||
#ribbit.wysiwyg h6.ribbit-editing::before { content: "###### "; font-size: 0.5em; }
|
||||
|
||||
#ribbit.wysiwyg blockquote.ribbit-editing::before {
|
||||
content: "> ";
|
||||
.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 {
|
||||
/* 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;
|
||||
|
|
|
|||
2
src/ts/index.ts
Normal file
2
src/ts/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./ribbit";
|
||||
export * from "./hopdown";
|
||||
File diff suppressed because it is too large
Load Diff
102
src/ts/ribbit.ts
102
src/ts/ribbit.ts
|
|
@ -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>') // '<b>hi</b>'
|
||||
*/
|
||||
|
|
|
|||
337
src/ts/vim.ts
337
src/ts/vim.ts
|
|
@ -1,337 +0,0 @@
|
|||
/*
|
||||
* vim.ts — vim keybinding handler for ribbit source edit mode.
|
||||
*
|
||||
* Two modes: normal and insert. Activated in source (edit) mode only.
|
||||
* Esc enters normal mode, i/a/o/O enter insert mode.
|
||||
*
|
||||
* Normal mode commands:
|
||||
* h/j/k/l — cursor movement
|
||||
* w/b — word forward/back
|
||||
* 0/$ — line start/end
|
||||
* gg/G — document start/end
|
||||
* i — insert before cursor
|
||||
* a — insert after cursor
|
||||
* o — new line below, insert
|
||||
* O — new line above, insert
|
||||
* x — delete char under cursor
|
||||
* dd — delete line
|
||||
* u — undo
|
||||
* Ctrl+r — redo
|
||||
*/
|
||||
|
||||
type VimMode = 'normal' | 'insert';
|
||||
|
||||
/** Direction constants for cursor movement to avoid magic strings. */
|
||||
const DIRECTION = {
|
||||
LEFT: 'left' as const,
|
||||
RIGHT: 'right' as const,
|
||||
UP: 'up' as const,
|
||||
DOWN: 'down' as const,
|
||||
};
|
||||
|
||||
/** Selection API direction mappings. */
|
||||
const SELECTION_DIRECTION = {
|
||||
BACKWARD: 'backward' as const,
|
||||
FORWARD: 'forward' as const,
|
||||
};
|
||||
|
||||
/** Selection API granularity mappings. */
|
||||
const SELECTION_GRANULARITY = {
|
||||
CHARACTER: 'character' as const,
|
||||
LINE: 'line' as const,
|
||||
WORD: 'word' as const,
|
||||
LINE_BOUNDARY: 'lineboundary' as const,
|
||||
};
|
||||
|
||||
/** Regex to match digit keys for count prefix accumulation. */
|
||||
const DIGIT_PATTERN = /^[0-9]$/;
|
||||
|
||||
/** Default repeat count when no count prefix is given. */
|
||||
const DEFAULT_REPEAT_COUNT = '1';
|
||||
|
||||
/** Radix for parsing count prefix strings. */
|
||||
const DECIMAL_RADIX = 10;
|
||||
|
||||
/**
|
||||
* Handles vim-style keybindings in ribbit's source edit mode.
|
||||
*
|
||||
* Supports normal and insert modes with standard vim motions,
|
||||
* editing commands, and count prefixes.
|
||||
*
|
||||
* @example
|
||||
* const vim = new VimHandler((mode) => {
|
||||
* statusBar.textContent = mode;
|
||||
* });
|
||||
* vim.attach(editorElement);
|
||||
*/
|
||||
export class VimHandler {
|
||||
mode: VimMode;
|
||||
private element: HTMLElement | null;
|
||||
private listener: ((event: KeyboardEvent) => void) | null;
|
||||
private pending: string;
|
||||
private count: string;
|
||||
private onModeChange: (mode: VimMode) => void;
|
||||
|
||||
constructor(onModeChange: (mode: VimMode) => void) {
|
||||
this.mode = 'insert';
|
||||
this.element = null;
|
||||
this.listener = null;
|
||||
this.pending = '';
|
||||
this.count = '';
|
||||
this.onModeChange = onModeChange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind vim keybindings to a DOM element.
|
||||
*
|
||||
* @example
|
||||
* vim.attach(document.getElementById('editor'));
|
||||
*/
|
||||
attach(element: HTMLElement): void {
|
||||
this.detach();
|
||||
this.element = element;
|
||||
this.pending = '';
|
||||
this.listener = (event: KeyboardEvent) => this.handleKey(event);
|
||||
this.element.addEventListener('keydown', this.listener);
|
||||
this.setMode('insert');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove vim keybindings from the current element.
|
||||
*
|
||||
* @example
|
||||
* vim.detach();
|
||||
*/
|
||||
detach(): void {
|
||||
if (this.element && this.listener) {
|
||||
this.element.removeEventListener('keydown', this.listener);
|
||||
this.element.classList.remove('vim-normal', 'vim-insert');
|
||||
}
|
||||
this.element = null;
|
||||
this.listener = null;
|
||||
this.mode = 'insert';
|
||||
this.pending = '';
|
||||
}
|
||||
|
||||
private setMode(mode: VimMode): void {
|
||||
this.mode = mode;
|
||||
this.pending = '';
|
||||
this.count = '';
|
||||
this.onModeChange(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes keystrokes to insert-mode or normal-mode handling.
|
||||
* Insert mode only intercepts Escape; normal mode handles
|
||||
* all vim commands and suppresses default text input.
|
||||
*/
|
||||
private handleKey(event: KeyboardEvent): void {
|
||||
if (this.mode === 'insert') {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.setMode('normal');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Suppress default text input in normal mode
|
||||
event.preventDefault();
|
||||
|
||||
if (event.ctrlKey) {
|
||||
if (event.key === 'r') {
|
||||
document.execCommand('redo');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key;
|
||||
|
||||
// Accumulate count prefix — 0 as first char is line-start, not count
|
||||
if (DIGIT_PATTERN.test(key) && (this.count || key !== '0')) {
|
||||
this.count += key;
|
||||
return;
|
||||
}
|
||||
|
||||
const repeat = parseInt(this.count || DEFAULT_REPEAT_COUNT, DECIMAL_RADIX);
|
||||
this.count = '';
|
||||
|
||||
if (this.pending) {
|
||||
const combo = this.pending + key;
|
||||
this.pending = '';
|
||||
for (let step = 0; step < repeat; step++) {
|
||||
this.handlePending(combo);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatchNormalKey(key, repeat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a normal-mode key to the appropriate command.
|
||||
* Separated from handleKey to keep nesting shallow.
|
||||
*/
|
||||
private dispatchNormalKey(key: string, repeat: number): void {
|
||||
switch (key) {
|
||||
case 'i':
|
||||
this.setMode('insert');
|
||||
break;
|
||||
case 'a':
|
||||
this.moveCursor(DIRECTION.RIGHT);
|
||||
this.setMode('insert');
|
||||
break;
|
||||
case 'o':
|
||||
this.endOfLine();
|
||||
this.insertNewline();
|
||||
this.setMode('insert');
|
||||
break;
|
||||
case 'O':
|
||||
this.startOfLine();
|
||||
this.insertNewline();
|
||||
this.moveCursor(DIRECTION.UP);
|
||||
this.setMode('insert');
|
||||
break;
|
||||
|
||||
case 'h':
|
||||
for (let step = 0; step < repeat; step++) {
|
||||
this.moveCursor(DIRECTION.LEFT);
|
||||
}
|
||||
break;
|
||||
case 'j':
|
||||
for (let step = 0; step < repeat; step++) {
|
||||
this.moveCursor(DIRECTION.DOWN);
|
||||
}
|
||||
break;
|
||||
case 'k':
|
||||
for (let step = 0; step < repeat; step++) {
|
||||
this.moveCursor(DIRECTION.UP);
|
||||
}
|
||||
break;
|
||||
case 'l':
|
||||
for (let step = 0; step < repeat; step++) {
|
||||
this.moveCursor(DIRECTION.RIGHT);
|
||||
}
|
||||
break;
|
||||
case 'w':
|
||||
for (let step = 0; step < repeat; step++) {
|
||||
this.wordForward();
|
||||
}
|
||||
break;
|
||||
case 'b':
|
||||
for (let step = 0; step < repeat; step++) {
|
||||
this.wordBack();
|
||||
}
|
||||
break;
|
||||
case '0':
|
||||
this.startOfLine();
|
||||
break;
|
||||
case '$':
|
||||
this.endOfLine();
|
||||
break;
|
||||
case 'G':
|
||||
this.endOfDocument();
|
||||
break;
|
||||
|
||||
case 'x':
|
||||
for (let step = 0; step < repeat; step++) {
|
||||
this.deleteChar();
|
||||
}
|
||||
break;
|
||||
case 'u':
|
||||
for (let step = 0; step < repeat; step++) {
|
||||
document.execCommand('undo');
|
||||
}
|
||||
break;
|
||||
|
||||
// Two-char commands — preserve count for the second key
|
||||
case 'd':
|
||||
case 'g':
|
||||
this.pending = key;
|
||||
if (repeat > 1) {
|
||||
this.count = String(repeat);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handlePending(combo: string): void {
|
||||
switch (combo) {
|
||||
case 'dd':
|
||||
this.deleteLine();
|
||||
break;
|
||||
case 'gg':
|
||||
this.startOfDocument();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private moveCursor(direction: 'left' | 'right' | 'up' | 'down'): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const selectionDirection = (direction === DIRECTION.LEFT || direction === DIRECTION.UP)
|
||||
? SELECTION_DIRECTION.BACKWARD
|
||||
: SELECTION_DIRECTION.FORWARD;
|
||||
const granularity = (direction === DIRECTION.UP || direction === DIRECTION.DOWN)
|
||||
? SELECTION_GRANULARITY.LINE
|
||||
: SELECTION_GRANULARITY.CHARACTER;
|
||||
selection.modify('move', selectionDirection, granularity);
|
||||
}
|
||||
|
||||
private wordForward(): void {
|
||||
window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.WORD);
|
||||
}
|
||||
|
||||
private wordBack(): void {
|
||||
window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.WORD);
|
||||
}
|
||||
|
||||
private startOfLine(): void {
|
||||
window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
|
||||
}
|
||||
|
||||
private endOfLine(): void {
|
||||
window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
|
||||
}
|
||||
|
||||
private startOfDocument(): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || !this.element) {
|
||||
return;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.setStart(this.element, 0);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
private endOfDocument(): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || !this.element) {
|
||||
return;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(this.element);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
private deleteChar(): void {
|
||||
document.execCommand('forwardDelete');
|
||||
}
|
||||
|
||||
private deleteLine(): void {
|
||||
this.startOfLine();
|
||||
window.getSelection()?.modify('extend', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
|
||||
document.execCommand('delete');
|
||||
// Remove the trailing newline left after deleting line content
|
||||
document.execCommand('forwardDelete');
|
||||
}
|
||||
|
||||
private insertNewline(): void {
|
||||
document.execCommand('insertLineBreak');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { HopDown } from '../src';
|
||||
|
||||
const lib = ribbit();
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ describe('Custom block tags', () => {
|
|||
selector: 'DETAILS',
|
||||
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: {
|
||||
'DETAILS': spoiler,
|
||||
...lib.defaultTags,
|
||||
|
|
@ -38,15 +39,15 @@ describe('Custom block tags', () => {
|
|||
|
||||
describe('HopDown({ exclude })', () => {
|
||||
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>');
|
||||
});
|
||||
it('excludes code', () => {
|
||||
const converter = new lib.HopDown({ exclude: ['code'] });
|
||||
const converter = new HopDown({ exclude: ['code'] });
|
||||
expect(converter.toHTML('`code`')).toBe('<p>`code`</p>');
|
||||
});
|
||||
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>');
|
||||
});
|
||||
});
|
||||
|
|
@ -59,7 +60,7 @@ describe('Collision detection', () => {
|
|||
htmlTag: 'span',
|
||||
precedence: 10,
|
||||
});
|
||||
expect(() => new lib.HopDown({
|
||||
expect(() => new HopDown({
|
||||
tags: {
|
||||
...lib.defaultTags,
|
||||
'SPAN': bad,
|
||||
|
|
@ -75,7 +76,7 @@ describe('Collision detection', () => {
|
|||
selector: 'STRONG',
|
||||
toMarkdown: () => '',
|
||||
};
|
||||
expect(() => new lib.HopDown({
|
||||
expect(() => new HopDown({
|
||||
tags: {
|
||||
...lib.defaultTags,
|
||||
'STRONG': dup,
|
||||
|
|
@ -98,7 +99,7 @@ describe('Collision detection', () => {
|
|||
});
|
||||
// Remove default strikethrough to avoid collision with the custom S/DEL tags
|
||||
const { 'DEL,S,STRIKE': _, ...tagsWithoutStrikethrough } = lib.defaultTags;
|
||||
expect(() => new lib.HopDown({
|
||||
expect(() => new HopDown({
|
||||
tags: {
|
||||
...tagsWithoutStrikethrough,
|
||||
'S': short,
|
||||
|
|
|
|||
|
|
@ -107,14 +107,6 @@ describe('RibbitEditor modes', () => {
|
|||
expect(editor.element.contentEditable).toBe('true');
|
||||
});
|
||||
|
||||
it('switches to edit', () => {
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
editor.edit();
|
||||
expect(editor.getState()).toBe('edit');
|
||||
});
|
||||
|
||||
it('switches back to view', () => {
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
|
|
@ -135,25 +127,10 @@ describe('RibbitEditor modes', () => {
|
|||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
editor.edit();
|
||||
editor.view();
|
||||
expect(modes).toEqual(['view', 'wysiwyg', 'edit', 'view']);
|
||||
expect(modes).toEqual(['view', 'wysiwyg', 'view']);
|
||||
});
|
||||
|
||||
it('sourceMode disabled blocks edit', () => {
|
||||
resetDOM();
|
||||
const editor = new lib.Editor({
|
||||
currentTheme: 'no-source',
|
||||
themes: [{
|
||||
name: 'no-source',
|
||||
features: { sourceMode: false },
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
editor.edit();
|
||||
expect(editor.getState()).toBe('wysiwyg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThemeManager', () => {
|
||||
|
|
@ -229,7 +206,6 @@ describe('defaultTheme', () => {
|
|||
it('has correct shape', () => {
|
||||
expect(lib.defaultTheme.name).toBe('ribbit-default');
|
||||
expect(lib.defaultTheme.tags).toBeDefined();
|
||||
expect(lib.defaultTheme.features.sourceMode).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -252,17 +228,26 @@ describe('Utility functions', () => {
|
|||
});
|
||||
|
||||
describe('Editor htmlToMarkdown', () => {
|
||||
beforeEach(() => resetDOM());
|
||||
|
||||
it('converts strong', () => {
|
||||
it('returns markdown in view state', () => {
|
||||
resetDOM('**bold**');
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
expect(editor.htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**');
|
||||
expect(editor.getMarkdown()).toBe('**bold**');
|
||||
});
|
||||
|
||||
it('converts em', () => {
|
||||
it('returns markdown in wysiwyg state', () => {
|
||||
resetDOM('**bold**');
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
expect(editor.htmlToMarkdown('<em>italic</em>')).toBe('*italic*');
|
||||
editor.wysiwyg();
|
||||
expect(editor.getMarkdown()).toBe('**bold**');
|
||||
});
|
||||
|
||||
it('round-trips inline formatting', () => {
|
||||
resetDOM('hello **world** and *italic*');
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
expect(editor.getMarkdown()).toBe('hello **world** and *italic*');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { ribbit } from './setup';
|
||||
|
||||
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 M = (html: string) => hopdown.toMarkdown(html);
|
||||
const rt = (md: string) => M(H(md));
|
||||
|
||||
|
||||
describe('Markdown → HTML', () => {
|
||||
describe('inline formatting', () => {
|
||||
it('bold', () => expect(H('**bold**')).toBe('<p><strong>bold</strong></p>'));
|
||||
|
|
|
|||
|
|
@ -1,106 +1,16 @@
|
|||
/**
|
||||
* Development server with livereload.
|
||||
*
|
||||
* Serves the test page and ribbit dist files. Watches src/ for
|
||||
* changes, rebuilds automatically, and notifies connected browsers
|
||||
* to reload via a simple EventSource stream.
|
||||
*
|
||||
* Run: npm run dev
|
||||
*/
|
||||
const { createServer } = require('./server');
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
var liveServer = require("live-server");
|
||||
|
||||
const PORT = 8080;
|
||||
const WATCH_DIRS = [
|
||||
path.join(__dirname, '..', '..', 'src'),
|
||||
path.join(__dirname, '..', '..', 'test', 'integration'),
|
||||
];
|
||||
const DEBOUNCE_MS = 300;
|
||||
var params = {
|
||||
port: 5023,
|
||||
host: "0.0.0.0",
|
||||
open: true,
|
||||
root: "test/integration",
|
||||
mount: [
|
||||
['/static', 'dist/ribbit'],
|
||||
],
|
||||
logLevel: 2, // 0 = errors only, 1 = some, 2 = lots
|
||||
};
|
||||
|
||||
const server = createServer(PORT);
|
||||
const reloadClients = [];
|
||||
|
||||
// Patch the server to add the livereload endpoint
|
||||
const originalServer = require('http').createServer;
|
||||
const httpServer = server._server || (() => {
|
||||
// Access the internal server by starting and intercepting
|
||||
let captured = null;
|
||||
const origListen = require('http').Server.prototype.listen;
|
||||
require('http').Server.prototype.listen = function (...args) {
|
||||
captured = this;
|
||||
return origListen.apply(this, args);
|
||||
};
|
||||
server.start();
|
||||
require('http').Server.prototype.listen = origListen;
|
||||
return captured;
|
||||
})();
|
||||
|
||||
// Simpler approach: create a standalone livereload server
|
||||
const reloadServer = require('http').createServer((request, response) => {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
reloadClients.push(response);
|
||||
request.on('close', () => {
|
||||
const index = reloadClients.indexOf(response);
|
||||
if (index >= 0) {
|
||||
reloadClients.splice(index, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function notifyReload() {
|
||||
for (const client of reloadClients) {
|
||||
client.write('data: reload\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
try {
|
||||
console.log('\n🔨 Rebuilding...');
|
||||
execSync('npm run build:js && npm run build:css', {
|
||||
cwd: path.join(__dirname, '..', '..'),
|
||||
stdio: 'pipe',
|
||||
});
|
||||
console.log('✅ Build complete');
|
||||
notifyReload();
|
||||
} catch (error) {
|
||||
console.error('❌ Build failed:', error.stderr?.toString().slice(0, 500));
|
||||
}
|
||||
}
|
||||
|
||||
let debounceTimer = null;
|
||||
function onFileChange(filename) {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
console.log(`📝 Changed: ${filename}`);
|
||||
debounceTimer = setTimeout(rebuild, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// Watch source directories
|
||||
for (const directory of WATCH_DIRS) {
|
||||
if (fs.existsSync(directory)) {
|
||||
fs.watch(directory, { recursive: true }, (eventType, filename) => {
|
||||
if (filename && !filename.includes('node_modules')) {
|
||||
onFileChange(filename);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
server.start().then(() => {
|
||||
reloadServer.listen(PORT + 1, () => {
|
||||
console.log(`\n🐸 Ribbit dev server`);
|
||||
console.log(` Editor: http://localhost:${PORT}`);
|
||||
console.log(` Livereload: http://localhost:${PORT + 1} (EventSource)`);
|
||||
console.log(` Watching: src/, test/integration/`);
|
||||
console.log(`\n Add this to the page to enable livereload:`);
|
||||
console.log(` <script>new EventSource('http://localhost:${PORT + 1}').onmessage = () => location.reload()</script>\n`);
|
||||
});
|
||||
});
|
||||
console.log(`\n🐸 Ribbit dev server running on http://localhost:${params['port']}`);
|
||||
liveServer.start(params);
|
||||
|
|
|
|||
|
|
@ -3,11 +3,22 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Ribbit Integration Test Page</title>
|
||||
<link rel="stylesheet" href="/ribbit/themes/ribbit-default/theme.css">
|
||||
<link rel="stylesheet" href="/static/themes/ribbit-default/theme.css">
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 20px; }
|
||||
#ribbit { border: 1px solid #ccc; padding: 20px; min-height: 200px; }
|
||||
.ribbit-toolbar { background: #f5f5f5; border: 1px solid #ccc; padding: 4px; margin-bottom: 8px; }
|
||||
main { max-width: 960px; margin: auto }
|
||||
#ribbit {
|
||||
border: 1px solid #ccc;
|
||||
padding: 20px;
|
||||
min-height: 200px;
|
||||
margin: auto;
|
||||
}
|
||||
.ribbit-toolbar {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ccc;
|
||||
padding: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ribbit-toolbar ul { list-style: none; margin: 0; padding: 0; display: flex; gap: 2px; }
|
||||
.ribbit-toolbar button { padding: 4px 8px; border: 1px solid #ddd; border-radius: 3px; background: white; cursor: pointer; font-size: 12px; }
|
||||
.ribbit-toolbar button.active { background: #d0d0ff; }
|
||||
|
|
@ -18,21 +29,21 @@
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<article id="ribbit">**bold** and *italic* and `code`
|
||||
|
||||
## Heading
|
||||
|
||||
- list item 1
|
||||
- list item 2
|
||||
| Type | To Get |
|
||||
|------|--------|
|
||||
| `*emphasis*` | *emphasis* |
|
||||
| `**bold**` | **bold** |
|
||||
| `abel](/link/address)` | [link label](/link/address) |
|
||||
| ``inline`` | `inline` |
|
||||
|
||||
> a blockquote
|
||||
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<script src="/ribbit/ribbit.js"></script>
|
||||
<script src="/static/ribbit.js"></script>
|
||||
<script>
|
||||
const editor = new ribbit.Editor({
|
||||
on: {
|
||||
|
|
@ -42,10 +53,5 @@
|
|||
editor.run();
|
||||
window.__ribbitEditor = editor;
|
||||
</script>
|
||||
<script>
|
||||
// Livereload — connects to dev server's EventSource endpoint.
|
||||
// Silently fails if the dev server isn't running.
|
||||
try { new EventSource('http://localhost:8081').onmessage = () => location.reload(); } catch(e) {}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { ribbit } from './setup';
|
||||
import { ribbit, resetDOM } from './setup';
|
||||
|
||||
const lib = ribbit();
|
||||
|
||||
const spacePattern = / /g;
|
||||
|
||||
const macros = [
|
||||
{
|
||||
name: 'user',
|
||||
|
|
@ -13,7 +11,7 @@ const macros = [
|
|||
name: 'npc',
|
||||
toHTML: ({ keywords }: any) => {
|
||||
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 M = (html: string) => converter.toMarkdown(html);
|
||||
|
||||
|
|
|
|||
150
test/vim.test.ts
150
test/vim.test.ts
|
|
@ -1,150 +0,0 @@
|
|||
import { ribbit, resetDOM } from './setup';
|
||||
|
||||
const lib = ribbit();
|
||||
|
||||
describe('VimHandler', () => {
|
||||
beforeEach(() => resetDOM('hello world'));
|
||||
|
||||
it('starts in insert mode', () => {
|
||||
const editor = new lib.Editor({
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
expect(editor.element.classList.contains('vim-insert')).toBe(true);
|
||||
});
|
||||
|
||||
it('Esc enters normal mode', () => {
|
||||
const editor = new lib.Editor({
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(editor.element.classList.contains('vim-normal')).toBe(true);
|
||||
expect(editor.element.classList.contains('vim-insert')).toBe(false);
|
||||
});
|
||||
|
||||
it('i returns to insert mode', () => {
|
||||
const editor = new lib.Editor({
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
// Enter normal mode
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
// Back to insert
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
|
||||
expect(editor.element.classList.contains('vim-insert')).toBe(true);
|
||||
expect(editor.element.classList.contains('vim-normal')).toBe(false);
|
||||
});
|
||||
|
||||
it('disables toolbar in normal mode', () => {
|
||||
const editor = new lib.Editor({
|
||||
autoToolbar: false,
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.toolbar.render();
|
||||
editor.edit();
|
||||
editor.toolbar.enable();
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
const bold = editor.toolbar.buttons.get('bold');
|
||||
expect(bold?.element?.classList.contains('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('re-enables toolbar in insert mode', () => {
|
||||
const editor = new lib.Editor({
|
||||
autoToolbar: false,
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.toolbar.render();
|
||||
editor.edit();
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
|
||||
const bold = editor.toolbar.buttons.get('bold');
|
||||
expect(bold?.element?.classList.contains('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('detaches when leaving edit mode', () => {
|
||||
const editor = new lib.Editor({
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(editor.element.classList.contains('vim-normal')).toBe(true);
|
||||
editor.wysiwyg();
|
||||
// vim classes should be gone after mode switch
|
||||
expect(editor.element.classList.contains('vim-normal')).toBe(false);
|
||||
expect(editor.element.classList.contains('vim-insert')).toBe(false);
|
||||
});
|
||||
|
||||
it('only activates in edit mode', () => {
|
||||
const editor = new lib.Editor({
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
// Esc in wysiwyg should not add vim classes
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(editor.element.classList.contains('vim-normal')).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user