Add themes support

Usage:

  const editor = new RibbitEditor({
    themes: [
        { name: 'dark', features: { sourceMode: false } },
        { name: 'minimal', tags: minimalTags },
    ],
    currentTheme: 'dark',
  });

The built-in theme is 'ribbit-default' and is always available.
Additional themes from the themes array are registered on top.
This commit is contained in:
gsb 2026-04-29 01:17:32 +00:00
parent 5983ce50fd
commit ac7a698c4f
13 changed files with 264 additions and 70 deletions

View File

@ -9,10 +9,11 @@
"src/" "src/"
], ],
"scripts": { "scripts": {
"build": "npm run build:check && npm run build:js && npm run build:min", "build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:css",
"build:check": "tsc --noEmit", "build:check": "tsc --noEmit",
"build:js": "esbuild src/ribbit-editor.ts --bundle --format=iife --sourcemap --outfile=dist/ribbit.js", "build:js": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --sourcemap --outfile=dist/ribbit/ribbit.js",
"build:min": "esbuild src/ribbit-editor.ts --bundle --format=iife --minify --outfile=dist/ribbit.min.js", "build:min": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --minify --outfile=dist/ribbit/ribbit.min.js",
"build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/",
"test": "npm run build && node test/test_hopdown.js" "test": "npm run build && node test/test_hopdown.js"
}, },
"license": "MIT", "license": "MIT",

View File

@ -1,58 +0,0 @@
/*
* ribbit.css editor styles for the ribbit WYSIWYG markdown editor.
*
* Provides base content formatting and editor state styles.
* Override with your own theme CSS for custom look and feel.
*/
/* ── Content formatting ──────────────────────────────── */
a { text-decoration: none; }
q, blockquote {
margin-left: 30px;
font-size: 1.3em;
font-style: italic;
color: #555;
}
table { width: 100%; }
th { border-bottom: 1px solid #000; padding: 3px; }
th, td { padding: 2px; }
table td table { max-width: 95%; }
pre {
border: 1px dashed black;
border-radius: 5px;
padding: 10px;
margin: 5px;
background: #EEE;
}
code {
display: inline-block;
border: 1px dashed black;
border-radius: 5px;
padding: 5px;
background: #EEE;
margin: 3px;
}
/* ── Editor states ───────────────────────────────────── */
#ribbit {
display: none;
}
#ribbit.loaded {
display: block;
}
#ribbit.edit {
font-family: monospace;
white-space: pre;
}
#ribbit.wysiwyg .md {
opacity: 0.5;
}

View File

@ -0,0 +1,22 @@
/*
* ribbit-core.css functional editor styles. Always load this.
* These styles control editor state visibility and behavior.
* They should not be overridden by themes.
*/
#ribbit {
display: none;
}
#ribbit.loaded {
display: block;
}
#ribbit.edit {
font-family: monospace;
white-space: pre;
}
#ribbit.wysiwyg .md {
opacity: 0.5;
}

View File

@ -0,0 +1,52 @@
/*
* default.css the default ribbit theme.
* Provides basic aesthetic styling for rendered markdown content.
* Replace this file with your own theme to customize the look.
*/
@import "../ribbit-core.css";
a {
text-decoration: none;
}
blockquote {
margin-left: 30px;
font-size: 1.3em;
font-style: italic;
color: #555;
}
table {
width: 100%;
}
th {
border-bottom: 1px solid #000;
padding: 3px;
}
th, td {
padding: 2px;
}
table td table {
max-width: 95%;
}
pre {
border: 1px dashed black;
border-radius: 5px;
padding: 10px;
margin: 5px;
background: #EEE;
}
code {
display: inline-block;
border: 1px dashed black;
border-radius: 5px;
padding: 5px;
background: #EEE;
margin: 3px;
}

16
src/ts/default-theme.ts Normal file
View File

@ -0,0 +1,16 @@
/*
* default-theme.ts the default ribbit theme.
*
* Enables all default tags and all editor features.
*/
import type { RibbitTheme } from './types';
import { defaultTags } from './tags';
export const defaultTheme: RibbitTheme = {
name: 'ribbit-default',
tags: defaultTags,
features: {
sourceMode: true,
},
};

View File

@ -2,9 +2,9 @@
* ribbit-editor.ts WYSIWYG editing extension for Ribbit. * ribbit-editor.ts WYSIWYG editing extension for Ribbit.
*/ */
import hopdown from './hopdown';
import { HopDown } from './hopdown'; import { HopDown } from './hopdown';
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags'; import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
import { defaultTheme } from './default-theme';
import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit'; import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
/** /**
@ -46,7 +46,7 @@ export class RibbitEditor extends Ribbit {
} }
htmlToMarkdown(html?: string): string { htmlToMarkdown(html?: string): string {
return hopdown.toMarkdown(html || this.element.innerHTML); return this.converter.toMarkdown(html || this.element.innerHTML);
} }
getMarkdown(): string { getMarkdown(): string {
@ -80,6 +80,9 @@ export class RibbitEditor extends Ribbit {
} }
edit(): void { edit(): void {
if (!this.theme.features?.sourceMode) {
return;
}
if (this.state === this.states.EDIT) return; if (this.state === this.states.EDIT) return;
this.changed = false; this.changed = false;
this.element.contentEditable = 'true'; this.element.contentEditable = 'true';
@ -101,11 +104,11 @@ export class RibbitEditor extends Ribbit {
// Attach public API to window for <script> tag usage. // Attach public API to window for <script> tag usage.
(window as any).HopDown = HopDown; (window as any).HopDown = HopDown;
(window as any).hopdown = hopdown;
(window as any).inlineTag = inlineTag; (window as any).inlineTag = inlineTag;
(window as any).defaultTags = defaultTags; (window as any).defaultTags = defaultTags;
(window as any).defaultBlockTags = defaultBlockTags; (window as any).defaultBlockTags = defaultBlockTags;
(window as any).defaultInlineTags = defaultInlineTags; (window as any).defaultInlineTags = defaultInlineTags;
(window as any).defaultTheme = defaultTheme;
(window as any).Ribbit = Ribbit; (window as any).Ribbit = Ribbit;
(window as any).RibbitEditor = RibbitEditor; (window as any).RibbitEditor = RibbitEditor;
(window as any).RibbitPlugin = RibbitPlugin; (window as any).RibbitPlugin = RibbitPlugin;

View File

@ -2,12 +2,18 @@
* ribbit.ts core editor classes for the ribbit WYSIWYG markdown editor. * ribbit.ts core editor classes for the ribbit WYSIWYG markdown editor.
*/ */
import hopdown from './hopdown'; import { HopDown } from './hopdown';
import { defaultTheme } from './default-theme';
import { ThemeManager } from './theme-manager';
import type { RibbitTheme } from './types';
export interface RibbitSettings { export interface RibbitSettings {
api?: unknown; api?: unknown;
editorId?: string; editorId?: string;
plugins?: Array<{ new(settings: { name: string; wiki: Ribbit }): RibbitPlugin; name: string }>; plugins?: Array<{ new(settings: { name: string; wiki: Ribbit }): RibbitPlugin; name: string }>;
currentTheme?: string;
themes?: RibbitTheme[];
themesPath?: string;
} }
/** /**
@ -53,10 +59,15 @@ export class Ribbit {
state: string | null; state: string | null;
changed: boolean; changed: boolean;
enabledPlugins: Record<string, RibbitPlugin>; enabledPlugins: Record<string, RibbitPlugin>;
theme: RibbitTheme;
themes: ThemeManager;
converter: HopDown;
themesPath: string;
constructor(settings: RibbitSettings) { constructor(settings: RibbitSettings) {
this.api = settings.api || null; this.api = settings.api || null;
this.element = document.getElementById(settings.editorId || 'ribbit')!; this.element = document.getElementById(settings.editorId || 'ribbit')!;
this.themesPath = settings.themesPath || './themes';
this.states = { this.states = {
VIEW: 'view', VIEW: 'view',
}; };
@ -66,6 +77,29 @@ export class Ribbit {
this.changed = false; this.changed = false;
this.enabledPlugins = {}; this.enabledPlugins = {};
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme) => {
this.theme = theme;
this.converter = theme.tags
? new HopDown({ tags: theme.tags })
: new HopDown();
this.cachedHTML = null;
if (this.getState() === this.states.VIEW) {
this.state = null;
this.view();
}
});
(settings.themes || []).forEach(theme => {
this.themes.add(theme);
});
const activeName = settings.currentTheme || defaultTheme.name;
this.themes.set(activeName);
this.theme = this.themes.current();
this.converter = this.theme.tags
? new HopDown({ tags: this.theme.tags })
: new HopDown();
(settings.plugins || []).forEach(plugin => { (settings.plugins || []).forEach(plugin => {
this.enabledPlugins[plugin.name] = new plugin({ this.enabledPlugins[plugin.name] = new plugin({
name: plugin.name, name: plugin.name,
@ -99,7 +133,7 @@ export class Ribbit {
} }
markdownToHTML(md: string): string { markdownToHTML(md: string): string {
return hopdown.toHTML(md); return this.converter.toHTML(md);
} }
getHTML(): string { getHTML(): string {

114
src/ts/theme-manager.ts Normal file
View File

@ -0,0 +1,114 @@
/*
* theme-manager.ts manages theme registration and activation for a Ribbit instance.
*/
import type { RibbitTheme } from './types';
import { HopDown } from './hopdown';
export class ThemeManager {
private registered: Map<string, RibbitTheme>;
private disabled: Set<string>;
private active: RibbitTheme;
private themeLink: HTMLLinkElement | null;
private themesPath: string;
private onSwitch: (theme: RibbitTheme) => void;
constructor(initial: RibbitTheme, themesPath: string, onSwitch: (theme: RibbitTheme) => void) {
this.registered = new Map();
this.disabled = new Set();
this.themeLink = null;
this.themesPath = themesPath;
this.onSwitch = onSwitch;
this.active = initial;
this.add(initial);
}
/**
* Register a theme. Themes must be added before they can be enabled.
*/
add(theme: RibbitTheme): void {
this.registered.set(theme.name, theme);
}
/**
* Unregister a theme by name. Cannot remove the active theme.
*/
remove(name: string): void {
if (this.active.name === name) {
throw new Error(`Cannot remove the active theme "${name}".`);
}
this.registered.delete(name);
}
/**
* Return the names of all registered and enabled themes.
*/
list(): string[] {
return Array.from(this.registered.keys()).filter(name => !this.disabled.has(name));
}
/**
* Get a registered theme by name, or undefined if not found.
*/
get(name: string): RibbitTheme | undefined {
return this.registered.get(name);
}
/**
* Return the currently active theme.
*/
current(): RibbitTheme {
return this.active;
}
/**
* Switch to a registered theme by name. The theme must be
* registered and enabled. Loads the theme's CSS and notifies
* the editor to rebuild its converter.
*/
set(name: string): void {
const theme = this.registered.get(name);
if (!theme) {
throw new Error(`Theme "${name}" is not registered. Call add() first.`);
}
if (this.disabled.has(name)) {
throw new Error(`Theme "${name}" is disabled. Call enable() first.`);
}
this.active = theme;
this.loadCSS(name);
this.onSwitch(theme);
}
/**
* Mark a theme as available for selection via set().
* Themes are enabled by default when added.
*/
enable(name: string): void {
if (!this.registered.has(name)) {
throw new Error(`Theme "${name}" is not registered. Call add() first.`);
}
this.disabled.delete(name);
}
/**
* Mark a theme as unavailable for selection via set().
* Does not affect the current theme if it is already active.
*/
disable(name: string): void {
if (!this.registered.has(name)) {
throw new Error(`Theme "${name}" is not registered.`);
}
this.disabled.add(name);
}
private loadCSS(name: string): void {
if (this.themeLink) {
this.themeLink.remove();
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `${this.themesPath}/${name}/theme.css`;
document.head.appendChild(link);
this.themeLink = link;
}
}

View File

@ -54,3 +54,13 @@ export interface InlineTagDef {
/** Process inner content for nested markdown? Default true. False for code spans. */ /** Process inner content for nested markdown? Default true. False for code spans. */
recursive?: boolean; recursive?: boolean;
} }
export interface RibbitThemeFeatures {
sourceMode?: boolean;
}
export interface RibbitTheme {
name: string;
tags?: Record<string, Tag>;
features?: RibbitThemeFeatures;
}

View File

@ -13,10 +13,10 @@ global.HTMLElement = dom.window.HTMLElement;
global.Node = dom.window.Node; global.Node = dom.window.Node;
// Load the compiled bundle (attaches globals to window) // Load the compiled bundle (attaches globals to window)
const bundle = fs.readFileSync(path.join(__dirname, '..', 'dist', 'ribbit.js'), 'utf8'); const bundle = fs.readFileSync(path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8');
dom.window.eval(bundle); dom.window.eval(bundle);
const hopdown = dom.window.hopdown; const hopdown = new dom.window.HopDown();
const H = hopdown.toHTML.bind(hopdown); const H = hopdown.toHTML.bind(hopdown);
const M = hopdown.toMarkdown.bind(hopdown); const M = hopdown.toMarkdown.bind(hopdown);
function rt(md) { return M(H(md)); } function rt(md) { return M(H(md)); }

View File

@ -5,10 +5,10 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src/ts",
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,
"lib": ["ES2019", "DOM"] "lib": ["ES2019", "DOM"]
}, },
"include": ["src/**/*.ts"] "include": ["src/ts/**/*.ts"]
} }