Add support for wysiwyg markdown preview
This commit is contained in:
parent
86d59877f1
commit
4237a3f6a2
103
README.md
103
README.md
|
|
@ -1,44 +1,97 @@
|
|||
# ribbit
|
||||
|
||||
Zero-dependency WYSIWYG markdown editor
|
||||
Zero-dependency WYSIWYG markdown editor for the browser.
|
||||
|
||||
## Files
|
||||
## Source Layout
|
||||
|
||||
- `src/hopdown.js` — Markdown ↔ HTML converter (`HopDown.toHTML()`, `HopDown.toMarkdown()`)
|
||||
- `src/ribbit.js` — Base viewer class (`Ribbit`), plugin base class (`RibbitPlugin`), utilities
|
||||
- `src/ribbit-editor.js` — Editor class (`RibbitEditor`) with VIEW/EDIT/WYSIWYG modes
|
||||
- `src/ribbit.css` — Editor and content styles
|
||||
- `src/ts/` — TypeScript source files
|
||||
- `types.ts` — shared interfaces (Tag, SourceToken, Converter, etc.)
|
||||
- `tags.ts` — tag definitions and `inlineTag()` factory
|
||||
- `hopdown.ts` — configurable markdown↔HTML converter (HopDown class)
|
||||
- `macros.ts` — macro parsing and Tag generation
|
||||
- `ribbit.ts` — Ribbit viewer, RibbitPlugin, utilities
|
||||
- `ribbit-editor.ts` — RibbitEditor with WYSIWYG support, public API exports
|
||||
- `default-theme.ts` — built-in theme definition
|
||||
- `theme-manager.ts` — theme registration and switching
|
||||
- `events.ts` — typed event emitter
|
||||
- `src/static/` — CSS and static assets
|
||||
- `ribbit-core.css` — functional editor styles (always load)
|
||||
- `themes/ribbit-default/theme.css` — default theme
|
||||
|
||||
## Build Output
|
||||
|
||||
```
|
||||
dist/ribbit/
|
||||
├── ribbit.js # readable IIFE bundle + source map
|
||||
├── ribbit.min.js # minified bundle
|
||||
├── ribbit-core.css # functional styles
|
||||
└── themes/
|
||||
└── ribbit-default/
|
||||
└── theme.css # default theme (imports ribbit-core.css)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="ribbit/src/ribbit.css">
|
||||
<link rel="stylesheet" href="ribbit/themes/ribbit-default/theme.css">
|
||||
<article id="ribbit">your markdown here</article>
|
||||
|
||||
<script src="ribbit/src/hopdown.js"></script>
|
||||
<script src="ribbit/src/ribbit.js"></script>
|
||||
<script src="ribbit/src/ribbit-editor.js"></script>
|
||||
<script src="ribbit/ribbit.js"></script>
|
||||
<script>
|
||||
const editor = new RibbitEditor({ plugins: [] });
|
||||
const editor = new ribbit.Editor({
|
||||
on: {
|
||||
save: ({ markdown }) => {
|
||||
fetch('/api/save', { method: 'POST', body: markdown });
|
||||
},
|
||||
},
|
||||
macros: [
|
||||
{
|
||||
name: 'npc',
|
||||
toHTML: ({ keywords }) => {
|
||||
const name = keywords.join(' ');
|
||||
return `<a href="/NPC/${name}">${name}</a>`;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
editor.run();
|
||||
|
||||
// Switch modes
|
||||
editor.wysiwyg(); // WYSIWYG editing
|
||||
editor.edit(); // Source editing
|
||||
editor.view(); // Read-only view
|
||||
|
||||
// Get content
|
||||
editor.getMarkdown();
|
||||
editor.getHTML();
|
||||
editor.wysiwyg();
|
||||
</script>
|
||||
```
|
||||
|
||||
## Custom Block Tags
|
||||
|
||||
```javascript
|
||||
const spoiler = {
|
||||
name: 'spoiler',
|
||||
match: (context) => {
|
||||
if (!/^\|{3,}/.test(context.lines[context.index])) return null;
|
||||
const content = [];
|
||||
let i = context.index + 1;
|
||||
while (i < context.lines.length && !/^\|{3,}/.test(context.lines[i]))
|
||||
content.push(context.lines[i++]);
|
||||
return { content: content.join('\n'), raw: '', consumed: i + 1 - context.index };
|
||||
},
|
||||
toHTML: (token, convert) =>
|
||||
'<details><summary>Spoiler</summary>' + convert.block(token.content) + '</details>',
|
||||
selector: 'DETAILS',
|
||||
toMarkdown: (element, convert) =>
|
||||
'\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
|
||||
};
|
||||
|
||||
const converter = new ribbit.HopDown({
|
||||
tags: { ...ribbit.defaultTags, 'DETAILS': spoiler },
|
||||
});
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
npm test
|
||||
```
|
||||
|
||||
## Supported Markdown
|
||||
|
||||
Bold, italic, inline code, links, headings (h1-h6), unordered/ordered/nested lists,
|
||||
blockquotes, fenced code blocks with language, horizontal rules, GFM tables with
|
||||
column alignment, and paragraphs. Arbitrary nesting of all inline formatting.
|
||||
|
||||
## Tests
|
||||
|
||||
Open `test/test_ribbit-down.html` in a browser.
|
||||
column alignment, paragraphs, and macros (@name syntax).
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@
|
|||
"name": "ribbit",
|
||||
"version": "1.0.0",
|
||||
"description": "Zero-dependency WYSIWYG markdown editor for the browser",
|
||||
"main": "dist/ribbit.js",
|
||||
"types": "dist/ribbit.d.ts",
|
||||
"main": "dist/ribbit/ribbit.js",
|
||||
"files": [
|
||||
"dist/",
|
||||
"src/"
|
||||
"dist/ribbit/"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:css",
|
||||
|
|
|
|||
|
|
@ -20,3 +20,38 @@
|
|||
#ribbit.wysiwyg .md {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ribbit-editing::before,
|
||||
.ribbit-editing::after {
|
||||
opacity: 0.3;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
#ribbit.wysiwyg strong.ribbit-editing::before,
|
||||
#ribbit.wysiwyg strong.ribbit-editing::after {
|
||||
content: "**";
|
||||
}
|
||||
|
||||
#ribbit.wysiwyg em.ribbit-editing::before,
|
||||
#ribbit.wysiwyg em.ribbit-editing::after {
|
||||
content: "*";
|
||||
}
|
||||
|
||||
#ribbit.wysiwyg code.ribbit-editing::before,
|
||||
#ribbit.wysiwyg code.ribbit-editing::after {
|
||||
content: "\`";
|
||||
}
|
||||
|
||||
#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: "> ";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export class HopDown {
|
|||
|
||||
this.blockTags = allTags.filter(tag =>
|
||||
defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
|
||||
(!defaultInlineNames.has(tag.name) && !(tag as any).pattern)
|
||||
(!defaultInlineNames.has(tag.name) && !tag.pattern)
|
||||
);
|
||||
|
||||
// Ensure macro block tag runs after fencedCode but before everything else
|
||||
|
|
@ -83,7 +83,7 @@ export class HopDown {
|
|||
});
|
||||
|
||||
this.inlineTags = allTags.filter(tag =>
|
||||
defaultInlineNames.has(tag.name) || (tag as any).pattern
|
||||
defaultInlineNames.has(tag.name) || tag.pattern
|
||||
);
|
||||
|
||||
this.tags = new Map();
|
||||
|
|
@ -113,11 +113,11 @@ export class HopDown {
|
|||
*/
|
||||
private validateInlineTags(): void {
|
||||
const withDelimiters = this.inlineTags
|
||||
.filter(tag => (tag as any).delimiter)
|
||||
.filter(tag => tag.delimiter)
|
||||
.map(tag => ({
|
||||
name: tag.name,
|
||||
delimiter: (tag as any).delimiter as string,
|
||||
precedence: (tag as any).precedence as number ?? 50,
|
||||
delimiter: tag.delimiter as string,
|
||||
precedence: tag.precedence as number ?? 50,
|
||||
}));
|
||||
|
||||
for (let i = 0; i < withDelimiters.length; i++) {
|
||||
|
|
@ -159,6 +159,20 @@ export class HopDown {
|
|||
return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the block tags for external iteration (e.g. speculative rendering).
|
||||
*/
|
||||
getBlockTags(): Tag[] {
|
||||
return this.blockTags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the inline tags for external iteration (e.g. speculative rendering).
|
||||
*/
|
||||
getInlineTags(): Tag[] {
|
||||
return this.inlineTags;
|
||||
}
|
||||
|
||||
private processBlocks(md: string): string {
|
||||
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
||||
const output: string[] = [];
|
||||
|
|
@ -186,6 +200,7 @@ export class HopDown {
|
|||
output.push(result.html);
|
||||
index = result.end;
|
||||
} else {
|
||||
|
||||
output.push(tag.toHTML(token, this.makeConverter()));
|
||||
index += token.consumed;
|
||||
}
|
||||
|
|
@ -216,13 +231,11 @@ export class HopDown {
|
|||
|
||||
// Pass 1: extract links and non-recursive tags into placeholders before escaping
|
||||
for (const tag of sorted) {
|
||||
const recursive = (tag as any).recursive ?? true;
|
||||
const recursive = tag.recursive ?? true;
|
||||
|
||||
if (tag.name === 'link') {
|
||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => {
|
||||
// Process link text: restore earlier placeholders, then run inline on any remaining markdown
|
||||
let inner = linkText;
|
||||
// Check if link text contains placeholders (already-processed content)
|
||||
const hasPlaceholders = /\x00P\d+\x00/.test(inner);
|
||||
if (hasPlaceholders) {
|
||||
inner = inner.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
|
||||
|
|
@ -232,9 +245,10 @@ export class HopDown {
|
|||
placeholders.push('<a href="' + escapeHtml(href) + '">' + inner + '</a>');
|
||||
return '\x00P' + (placeholders.length - 1) + '\x00';
|
||||
});
|
||||
} else if (!recursive && (tag as any).pattern) {
|
||||
const globalPattern = (tag as any).pattern as RegExp;
|
||||
} else if (!recursive && tag.pattern) {
|
||||
const globalPattern = tag.pattern as RegExp;
|
||||
globalPattern.lastIndex = 0;
|
||||
|
||||
text = text.replace(globalPattern, (_, content: string) => {
|
||||
placeholders.push(tag.toHTML(
|
||||
{ content, raw: '', consumed: 0 },
|
||||
|
|
@ -247,21 +261,20 @@ export class HopDown {
|
|||
|
||||
text = escapeHtml(text);
|
||||
|
||||
// Pass 2: apply recursive tags in precedence order (longest delimiter first).
|
||||
// Content matched here is already HTML-escaped and has had earlier
|
||||
// passes applied, so we wrap directly without re-processing.
|
||||
// Pass 2: apply recursive tags in precedence order.
|
||||
// Content is already HTML-escaped from pass 1, so we wrap directly
|
||||
// without re-processing through convert.inline().
|
||||
for (const tag of sorted) {
|
||||
const recursive = (tag as any).recursive ?? true;
|
||||
const recursive = tag.recursive ?? true;
|
||||
if (tag.name === 'link' || !recursive) {
|
||||
continue;
|
||||
}
|
||||
const globalPattern = (tag as any).pattern as RegExp | undefined;
|
||||
const globalPattern = tag.pattern as RegExp | undefined;
|
||||
if (globalPattern) {
|
||||
globalPattern.lastIndex = 0;
|
||||
text = text.replace(globalPattern, (_, content: string) => {
|
||||
// Restore any placeholders in the captured content
|
||||
const restored = content.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
|
||||
const htmlTag = (tag as any).name === 'boldItalic'
|
||||
const htmlTag = tag.name === 'boldItalic'
|
||||
? null
|
||||
: ((tag.selector as string) || '').split(',')[0].toLowerCase();
|
||||
if (tag.name === 'boldItalic') {
|
||||
|
|
@ -272,7 +285,6 @@ export class HopDown {
|
|||
}
|
||||
}
|
||||
|
||||
// Restore placeholders
|
||||
text = text.replace(/\x00P(\d+)\x00/g, (_, index: string) => placeholders[parseInt(index)]);
|
||||
return text;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { HopDown } from './hopdown';
|
|||
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
|
||||
import { defaultTheme } from './default-theme';
|
||||
import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
||||
import { type MacroDef } from './macros';
|
||||
|
||||
/**
|
||||
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
|
||||
|
|
@ -38,18 +39,156 @@ export class RibbitEditor extends Ribbit {
|
|||
}
|
||||
|
||||
#bindEvents(): void {
|
||||
let debounceTimer: number | undefined;
|
||||
let lastThrottle = 0;
|
||||
|
||||
this.element.addEventListener('input', () => {
|
||||
if (this.state !== this.states.VIEW) {
|
||||
this.notifyChange();
|
||||
if (this.state === this.states.VIEW) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.invalidateCache();
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastThrottle >= 150) {
|
||||
lastThrottle = now;
|
||||
this.refreshPreview();
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = window.setTimeout(() => {
|
||||
this.refreshPreview();
|
||||
this.notifyChange();
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render the WYSIWYG preview from the current content.
|
||||
* Applies speculative rendering for unclosed inline delimiters
|
||||
* at the cursor position, and uses toHtmlPreview for visible syntax.
|
||||
*/
|
||||
refreshPreview(): void {
|
||||
if (this.state !== this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorInfo = this.getCursorInfo();
|
||||
const text = this.element.textContent || '';
|
||||
const lines = text.split('\n');
|
||||
|
||||
// Speculatively close unclosed delimiters on the cursor line
|
||||
if (cursorInfo) {
|
||||
const inlineTags = this.converter.getInlineTags();
|
||||
const sorted = [...inlineTags].sort((a, b) =>
|
||||
((a as any).precedence ?? 50) - ((b as any).precedence ?? 50)
|
||||
);
|
||||
for (const tag of sorted) {
|
||||
if (tag.openPattern && tag.delimiter) {
|
||||
const before = lines[cursorInfo.lineIndex].slice(0, cursorInfo.offset);
|
||||
const escaped = tag.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(escaped, 'g');
|
||||
const count = (before.match(re) || []).length;
|
||||
if (count % 2 === 1) {
|
||||
lines[cursorInfo.lineIndex] = lines[cursorInfo.lineIndex] + tag.delimiter;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const html = this.converter.toHTML(lines.join('\n'));
|
||||
this.updatePreview(html, cursorInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track which formatting element contains the cursor and toggle
|
||||
* the .ribbit-editing class so CSS ::before/::after show delimiters.
|
||||
*/
|
||||
private updateEditingContext(): void {
|
||||
const prev = this.element.querySelector('.ribbit-editing');
|
||||
if (prev) {
|
||||
prev.classList.remove('ribbit-editing');
|
||||
}
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
let node: Node | null = sel.anchorNode;
|
||||
while (node && node !== this.element) {
|
||||
if (node.nodeType === 1) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.matches('strong, b, em, i, code, h1, h2, h3, h4, h5, h6, blockquote')) {
|
||||
el.classList.add('ribbit-editing');
|
||||
return;
|
||||
}
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cursor's line index and offset within that line.
|
||||
*/
|
||||
private getCursorInfo(): { lineIndex: number; offset: number; absoluteOffset: number } | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
const range = sel.getRangeAt(0);
|
||||
const preRange = document.createRange();
|
||||
preRange.selectNodeContents(this.element);
|
||||
preRange.setEnd(range.startContainer, range.startOffset);
|
||||
const absoluteOffset = preRange.toString().length;
|
||||
|
||||
const text = this.element.textContent || '';
|
||||
const beforeCursor = text.slice(0, absoluteOffset);
|
||||
const lineIndex = beforeCursor.split('\n').length - 1;
|
||||
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
|
||||
const offset = absoluteOffset - lineStart;
|
||||
|
||||
return { lineIndex, offset, absoluteOffset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the editor's HTML and restore the cursor to its
|
||||
* previous text offset position.
|
||||
*/
|
||||
private updatePreview(html: string, cursorInfo: { absoluteOffset: number } | null): void {
|
||||
this.element.innerHTML = html;
|
||||
|
||||
if (!cursorInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT);
|
||||
let remaining = cursorInfo.absoluteOffset;
|
||||
let node: Text | null;
|
||||
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
if (remaining <= node.length) {
|
||||
const sel = window.getSelection()!;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, remaining);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
break;
|
||||
}
|
||||
remaining -= node.length;
|
||||
}
|
||||
|
||||
this.updateEditingContext();
|
||||
}
|
||||
|
||||
htmlToMarkdown(html?: string): string {
|
||||
return this.converter.toMarkdown(html || this.element.innerHTML);
|
||||
}
|
||||
|
||||
getMarkdown(): string {
|
||||
if (this.cachedMarkdown !== null) {
|
||||
return this.cachedMarkdown;
|
||||
}
|
||||
if (this.getState() === this.states.EDIT) {
|
||||
let html = this.element.innerHTML;
|
||||
html = html.replace(/<(?:div|br)>/ig, '');
|
||||
|
|
@ -57,8 +196,7 @@ export class RibbitEditor extends Ribbit {
|
|||
this.cachedMarkdown = decodeHtmlEntities(html);
|
||||
} else if (this.getState() === this.states.WYSIWYG) {
|
||||
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
|
||||
}
|
||||
if (!this.cachedMarkdown) {
|
||||
} else {
|
||||
this.cachedMarkdown = this.element.textContent || '';
|
||||
}
|
||||
return this.cachedMarkdown;
|
||||
|
|
@ -66,7 +204,6 @@ export class RibbitEditor extends Ribbit {
|
|||
|
||||
wysiwyg(): void {
|
||||
if (this.getState() === this.states.WYSIWYG) return;
|
||||
this.changed = false;
|
||||
this.element.contentEditable = 'true';
|
||||
this.element.innerHTML = this.getHTML();
|
||||
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
||||
|
|
@ -84,7 +221,6 @@ export class RibbitEditor extends Ribbit {
|
|||
return;
|
||||
}
|
||||
if (this.state === this.states.EDIT) return;
|
||||
this.changed = false;
|
||||
this.element.contentEditable = 'true';
|
||||
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
||||
this.setState(this.states.EDIT);
|
||||
|
|
@ -102,8 +238,6 @@ export class RibbitEditor extends Ribbit {
|
|||
}
|
||||
}
|
||||
|
||||
import { type MacroDef } from './macros';
|
||||
|
||||
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
|
||||
export { RibbitEditor as Editor };
|
||||
export { Ribbit as Viewer };
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { HopDown } from './hopdown';
|
|||
import { defaultTheme } from './default-theme';
|
||||
import { ThemeManager } from './theme-manager';
|
||||
import { RibbitEmitter, type RibbitEventMap } from './events';
|
||||
import { buildMacroTags, type MacroDef } from './macros';
|
||||
import { type MacroDef } from './macros';
|
||||
import type { RibbitTheme } from './types';
|
||||
|
||||
export interface RibbitSettings {
|
||||
|
|
@ -174,14 +174,11 @@ export class Ribbit {
|
|||
|
||||
setState(newState: string): void {
|
||||
const previous = this.state;
|
||||
if (previous) {
|
||||
this.element.classList.remove(previous);
|
||||
}
|
||||
this.state = newState;
|
||||
Object.values(this.states).forEach(state => {
|
||||
if (state === newState) {
|
||||
this.element.classList.add(state);
|
||||
} else {
|
||||
this.element.classList.remove(state);
|
||||
}
|
||||
});
|
||||
this.element.classList.add(newState);
|
||||
this.emitter.emit('modeChange', {
|
||||
current: newState,
|
||||
previous,
|
||||
|
|
@ -193,14 +190,14 @@ export class Ribbit {
|
|||
}
|
||||
|
||||
getHTML(): string {
|
||||
if (this.changed || !this.cachedHTML) {
|
||||
if (this.cachedHTML === null) {
|
||||
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
|
||||
}
|
||||
return this.cachedHTML;
|
||||
}
|
||||
|
||||
getMarkdown(): string {
|
||||
if (!this.cachedMarkdown) {
|
||||
if (this.cachedMarkdown === null) {
|
||||
this.cachedMarkdown = this.element.textContent || '';
|
||||
}
|
||||
return this.cachedMarkdown;
|
||||
|
|
@ -226,12 +223,21 @@ export class Ribbit {
|
|||
this.element.contentEditable = 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached markdown and HTML. Called when content changes.
|
||||
* The next call to getMarkdown() or getHTML() will recompute.
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.changed = true;
|
||||
this.cachedMarkdown = null;
|
||||
this.cachedHTML = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify that content has changed. Called internally by the editor
|
||||
* on input events. Fires the 'change' event with current content.
|
||||
*/
|
||||
notifyChange(): void {
|
||||
this.changed = true;
|
||||
this.emitter.emit('change', {
|
||||
markdown: this.getMarkdown(),
|
||||
html: this.getHTML(),
|
||||
|
|
|
|||
|
|
@ -19,10 +19,11 @@ import type { Tag, MatchContext, SourceToken, Converter, ListItem, ListResult, I
|
|||
* inlineTag({ name: 'code', delimiter: '`', htmlTag: 'code', recursive: false, precedence: 10 })
|
||||
* inlineTag({ name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE' })
|
||||
*/
|
||||
export function inlineTag(def: InlineTagDef): Tag & { precedence: number; recursive: boolean; pattern: RegExp; delimiter: string } {
|
||||
export function inlineTag(def: InlineTagDef): Tag {
|
||||
const escaped = def.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped);
|
||||
const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g');
|
||||
const openPattern = new RegExp(escaped + '(.+)$');
|
||||
const upperTag = def.htmlTag.toUpperCase();
|
||||
const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(',');
|
||||
const recursive = def.recursive !== false;
|
||||
|
|
@ -32,6 +33,7 @@ export function inlineTag(def: InlineTagDef): Tag & { precedence: number; recurs
|
|||
precedence: def.precedence ?? 50,
|
||||
recursive,
|
||||
pattern: globalPattern,
|
||||
openPattern,
|
||||
delimiter: def.delimiter,
|
||||
match: (context) => {
|
||||
const matched = context.text.slice(context.offset).match(matchPattern);
|
||||
|
|
@ -69,7 +71,7 @@ export function escapeHtml(source: string): string {
|
|||
/**
|
||||
* Generate a camelCase ID from heading text, for use as an anchor.
|
||||
*/
|
||||
export function camelId(text: string): string {
|
||||
function camelId(text: string): string {
|
||||
return text.trim().split(/\s+/).map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||
).join('');
|
||||
|
|
@ -115,7 +117,7 @@ export function parseListBlock(lines: string[], start: number, indent: number, i
|
|||
* Convert an HTML list element back to markdown, recursing into
|
||||
* nested sublists with 2-space indentation per depth level.
|
||||
*/
|
||||
export function listToMd(node: HTMLElement, depth: number, convert: Converter): string {
|
||||
function listToMd(node: HTMLElement, depth: number, convert: Converter): string {
|
||||
const isOl = node.nodeName === 'OL';
|
||||
const indent = ' '.repeat(depth);
|
||||
const lines: string[] = [];
|
||||
|
|
@ -141,7 +143,7 @@ export function listToMd(node: HTMLElement, depth: number, convert: Converter):
|
|||
* Test whether a line begins a block-level element (used to detect
|
||||
* paragraph boundaries).
|
||||
*/
|
||||
export function isBlockStart(lines: string[], index: number): boolean {
|
||||
function isBlockStart(lines: string[], index: number): boolean {
|
||||
const line = lines[index];
|
||||
if (/^(`{3,})/.test(line)) return true;
|
||||
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) return true;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
*/
|
||||
|
||||
import type { RibbitTheme } from './types';
|
||||
import { HopDown } from './hopdown';
|
||||
|
||||
export class ThemeManager {
|
||||
private registered: Map<string, RibbitTheme>;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,22 @@ export interface Tag {
|
|||
toHTML: (token: SourceToken, convert: Converter) => string;
|
||||
selector: string | ((element: HTMLElement) => boolean);
|
||||
toMarkdown: (element: HTMLElement, convert: Converter) => string;
|
||||
/**
|
||||
* The regex pattern that matches an unclosed opening delimiter.
|
||||
* Used by the live preview to speculatively close incomplete syntax.
|
||||
* Auto-generated by inlineTag().
|
||||
*/
|
||||
openPattern?: RegExp;
|
||||
/**
|
||||
* The markdown delimiter string. Auto-generated by inlineTag().
|
||||
*/
|
||||
delimiter?: string;
|
||||
/** Lower runs first in inline processing. Default 50. Auto-generated by inlineTag(). */
|
||||
precedence?: number;
|
||||
/** Whether inner content is processed for nested markdown. Auto-generated by inlineTag(). */
|
||||
recursive?: boolean;
|
||||
/** Global regex for matching this tag's delimiter pair. Auto-generated by inlineTag(). */
|
||||
pattern?: RegExp;
|
||||
}
|
||||
|
||||
export interface ListItem {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ function not(name, actual, sub) {
|
|||
}
|
||||
}
|
||||
|
||||
function section(n) { /* silent */ }
|
||||
function section(n) { console.log(' ' + n); }
|
||||
|
||||
// ── 1. Inline formatting ────────────────────────────────
|
||||
section('1. Inline Formatting → HTML');
|
||||
|
|
@ -497,6 +497,109 @@ has('macro: unknown block renders error', MH('@bogus(args\ncontent\n)'), 'ribbit
|
|||
eq('macro: npc round-trip', mrt('@npc(Goblin King)'), '@npc(Goblin King)');
|
||||
eq('macro: user round-trip', mrt('hello @user world'), 'hello @user world');
|
||||
|
||||
// ── 25. Preview CSS (via .ribbit-editing pseudo-elements) ───
|
||||
// Preview styling is handled by CSS ::before/::after on .ribbit-editing,
|
||||
// not by JS. We verify the converter output is clean HTML without syntax spans.
|
||||
not('preview: no syntax spans in toHTML',
|
||||
H('**bold**'), 'ribbit-syntax');
|
||||
not('preview: no syntax spans in heading',
|
||||
H('## Title'), 'ribbit-syntax');
|
||||
|
||||
// ── 26. openPattern — unclosed delimiter detection ──────
|
||||
var inlineTags = hopdown.getInlineTags();
|
||||
function findTag(name) {
|
||||
return inlineTags.find(function(t) { return t.name === name; });
|
||||
}
|
||||
|
||||
var boldTag = findTag('bold');
|
||||
var italicTag = findTag('italic');
|
||||
var codeTag = findTag('code');
|
||||
var boldItalicTag = findTag('boldItalic');
|
||||
|
||||
eq('openPattern: bold has pattern', String(!!boldTag.openPattern), 'true');
|
||||
eq('openPattern: italic has pattern', String(!!italicTag.openPattern), 'true');
|
||||
eq('openPattern: code has pattern', String(!!codeTag.openPattern), 'true');
|
||||
|
||||
// Unclosed bold matches
|
||||
eq('openPattern: unclosed ** odd count',
|
||||
String((('hello **world').match(/\*\*/g) || []).length % 2 === 1), 'true');
|
||||
// Closed bold — even count
|
||||
eq('openPattern: closed ** even count',
|
||||
String((('hello **world**').match(/\*\*/g) || []).length % 2 === 1), 'false');
|
||||
// Unclosed italic
|
||||
eq('openPattern: unclosed * odd count',
|
||||
String((('hello *world').match(/\*/g) || []).length % 2 === 1), 'true');
|
||||
// Unclosed code
|
||||
eq('openPattern: unclosed ` odd count',
|
||||
String((('hello `world').match(/`/g) || []).length % 2 === 1), 'true');
|
||||
|
||||
// ── 27. Speculative patching ────────────────────────────
|
||||
function specPatch(md, cursorLine, cursorOffset) {
|
||||
var lines = md.split('\n');
|
||||
var sorted = inlineTags.slice().sort(function(a, b) {
|
||||
return ((a).precedence || 50) - ((b).precedence || 50);
|
||||
});
|
||||
for (var i = 0; i < sorted.length; i++) {
|
||||
var tag = sorted[i];
|
||||
if (tag.openPattern && tag.delimiter) {
|
||||
var before = lines[cursorLine].slice(0, cursorOffset);
|
||||
var escaped = tag.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
var re = new RegExp(escaped, 'g');
|
||||
var count = (before.match(re) || []).length;
|
||||
if (count % 2 === 1) {
|
||||
lines[cursorLine] = lines[cursorLine] + tag.delimiter;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return hopdown.toHTML(lines.join('\n'));
|
||||
}
|
||||
|
||||
has('speculate: unclosed bold',
|
||||
specPatch('hello **world', 0, 13), '<strong>world</strong>');
|
||||
has('speculate: unclosed italic',
|
||||
specPatch('hello *world', 0, 12), '<em>world</em>');
|
||||
has('speculate: unclosed code',
|
||||
specPatch('hello `world', 0, 12), '<code>world</code>');
|
||||
has('speculate: unclosed bold+italic',
|
||||
specPatch('hello ***world', 0, 14), '<em><strong>world</strong></em>');
|
||||
|
||||
// Already closed — no double closing
|
||||
eq('speculate: closed bold unchanged',
|
||||
specPatch('hello **world**', 0, 15), '<p>hello <strong>world</strong></p>');
|
||||
eq('speculate: closed italic unchanged',
|
||||
specPatch('hello *world*', 0, 13), '<p>hello <em>world</em></p>');
|
||||
|
||||
// Only cursor line patched
|
||||
has('speculate: multiline patches cursor only',
|
||||
specPatch('normal\nhello **world', 1, 13), '<strong>world</strong>');
|
||||
not('speculate: other line untouched',
|
||||
specPatch('normal\nhello **world', 1, 13), '<strong>normal</strong>');
|
||||
|
||||
// No unclosed delimiter — no change
|
||||
eq('speculate: no delimiter no-op',
|
||||
specPatch('hello world', 0, 11), '<p>hello world</p>');
|
||||
|
||||
// ** wins over * (precedence)
|
||||
has('speculate: ** wins over *',
|
||||
specPatch('hello **world', 0, 13), '<strong>');
|
||||
not('speculate: ** not italic',
|
||||
specPatch('hello **world', 0, 13), '<em>world</em>');
|
||||
|
||||
// Delimiter with no content — speculation appends but nothing to format
|
||||
eq('speculate: bare delimiter no content',
|
||||
specPatch('hello **', 0, 8), '<p>hello <em>*</em>*</p>');
|
||||
|
||||
// Even count — all closed
|
||||
eq('speculate: even count no-op',
|
||||
specPatch('**a** **b**', 0, 11), '<p><strong>a</strong> <strong>b</strong></p>');
|
||||
|
||||
// Block tags need no speculation
|
||||
eq('speculate: list works as-is',
|
||||
H('- '), '<ul><li></li></ul>');
|
||||
has('speculate: blockquote works as-is',
|
||||
H('> '), '<blockquote>');
|
||||
|
||||
// ── Results ─────────────────────────────────────────────
|
||||
const total = passed + failed;
|
||||
console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user