all tests pass but only if md-delim display: inline
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
function disambiguateAsteriskRuns(line: string): string {
|
||||
const SENTINEL = '\u200C';
|
||||
const ASTERISK_RUN = /\*{1,3}/g;
|
||||
const stack: ('*' | '**')[] = [];
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
console.log(`DISAMBIGUATE: '${line}'`);
|
||||
|
||||
while ((match = ASTERISK_RUN.exec(line)) !== null) {
|
||||
|
||||
result += line.slice(lastIndex, match.index);
|
||||
const run = match[0];
|
||||
|
||||
console.log(`DISAMBIGUATE: run == '${run}', stack == ${stack}`);
|
||||
|
||||
if (run.length === 3 && stack.length === 2) {
|
||||
const innerCloser = stack.pop()!;
|
||||
const outerCloser = stack.pop()!;
|
||||
result += innerCloser + SENTINEL + outerCloser;
|
||||
} else if (run.length === 3 && stack.length === 0) {
|
||||
if (match.index == 0) {
|
||||
result += '***';
|
||||
} else {
|
||||
result += '*' + SENTINEL + '**';
|
||||
stack.push('**');
|
||||
stack.push('*');
|
||||
}
|
||||
} else if (run.length === 3) {
|
||||
result += run;
|
||||
} else if (stack.length > 0 && stack[stack.length - 1] === run) {
|
||||
stack.pop();
|
||||
result += run;
|
||||
} else {
|
||||
stack.push(run as '*' | '**');
|
||||
result += run;
|
||||
}
|
||||
|
||||
lastIndex = match.index + run.length;
|
||||
}
|
||||
|
||||
result += line.slice(lastIndex);
|
||||
console.log(`DISAMBIGUATE: '${result}'`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) { throw new Error(message); }
|
||||
}
|
||||
|
||||
function rewrite(line: string): string {
|
||||
const SENTINEL = '|' //'\u200C';
|
||||
const ASTERISK_RUN = /\*{1,3}/g;
|
||||
const stack: ('*' | '**' | '***')[] = [];
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
const bold = '**';
|
||||
const italic = '*';
|
||||
|
||||
let lastSequence = '';
|
||||
|
||||
while ((match = ASTERISK_RUN.exec(line)) !== null) {
|
||||
result += line.slice(lastIndex, match.index);
|
||||
const sequence = match[0];
|
||||
|
||||
if (sequence.length === 3) {
|
||||
|
||||
// split *** into opening ** and *
|
||||
if (stack.length === 0) {
|
||||
result += sequence;
|
||||
stack.push('***');
|
||||
|
||||
// closing ***, close the stack
|
||||
} else if (stack.length === 1 && stack[0] === sequence) {
|
||||
result = result.replace('***', bold + SENTINEL + italic);
|
||||
result += italic + SENTINEL + bold;
|
||||
stack.pop();
|
||||
|
||||
} else if (stack.length === 2) {
|
||||
const inner = stack.pop();
|
||||
const outer = stack.pop();
|
||||
result += inner + SENTINEL + outer;
|
||||
|
||||
} else if (stack.length === 1) {
|
||||
console.warn(`Cannot parsed line '${line}': invalid sequence ${sequence} with stack ${stack}!`);
|
||||
|
||||
} else {
|
||||
console.warn(`UNHANDLED '${sequence}', last sequence '${lastSequence}'`);
|
||||
}
|
||||
} else if (stack.length) {
|
||||
if (stack[stack.length - 1] === sequence) {
|
||||
result += stack.pop();
|
||||
} else if (stack.length === 1 && stack[0] === '***') {
|
||||
const opener = stack[0].substring(0, stack[0].length - sequence.length);
|
||||
result = result.replace('***', opener + SENTINEL + sequence);
|
||||
result += sequence;
|
||||
stack[0] = opener as '*' | '**';
|
||||
} else {
|
||||
result += sequence;
|
||||
stack.push(sequence as '*' | '**');
|
||||
}
|
||||
} else {
|
||||
stack.push(sequence as '*' | '**');
|
||||
result += sequence;
|
||||
}
|
||||
|
||||
lastIndex = match.index + sequence.length;
|
||||
}
|
||||
|
||||
result += line.slice(lastIndex);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
function test_it() {
|
||||
console.log(rewrite('*italic **bold***'));
|
||||
let cases = [
|
||||
{
|
||||
'input': '***bold-italic***',
|
||||
'expected': '**|*bold-italic*|**'
|
||||
},
|
||||
{
|
||||
'input': '***bold** italic*',
|
||||
'expected': '*|**bold** italic*',
|
||||
},
|
||||
{
|
||||
'input': '***italic* bold**',
|
||||
'expected': '**|*italic* bold**',
|
||||
},
|
||||
{
|
||||
'input': '**bold, *italic***',
|
||||
'expected': '**bold, *italic*|**'
|
||||
},
|
||||
{
|
||||
'input': '*italic, **bold***',
|
||||
'expected': '*italic, **bold**|*'
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
let result = rewrite(testCase.input);
|
||||
assert(result == testCase.expected, `'${result}' (${result.length}) != '${testCase.expected}' (${testCase.expected.length})`);
|
||||
}
|
||||
};
|
||||
|
||||
test_it();
|
||||
+16
-31
@@ -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>'));
|
||||
|
||||
+14
-103
@@ -1,106 +1,17 @@
|
||||
/**
|
||||
* 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'],
|
||||
['/test', 'test/integration'],
|
||||
],
|
||||
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);
|
||||
|
||||
+11
-29
@@ -3,36 +3,23 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Ribbit Integration Test Page</title>
|
||||
<link rel="stylesheet" href="/ribbit/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; }
|
||||
.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; }
|
||||
.ribbit-toolbar button.disabled { opacity: 0.3; }
|
||||
.ribbit-toolbar .spacer { width: 12px; }
|
||||
.ribbit-dropdown { position: absolute; background: white; border: 1px solid #ccc; padding: 4px; }
|
||||
.ribbit-dropdown button { display: block; width: 100%; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/themes/ribbit-default/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<article id="ribbit">**bold** and *italic* and `code`
|
||||
<main>
|
||||
<article id="ribbit">
|
||||
|
||||
## Heading
|
||||
| Type | To Get |
|
||||
|------|--------|
|
||||
| `*emphasis*` | *emphasis* |
|
||||
| `**bold**` | **bold** |
|
||||
| `abel](/link/address)` | [link label](/link/address) |
|
||||
| ``inline`` | `inline` |
|
||||
|
||||
- list item 1
|
||||
- list item 2
|
||||
|
||||
> 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 +29,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>
|
||||
|
||||
+625
-560
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user