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:20:55 +00:00
parent 5983ce50fd
commit ac7a698c4f
13 changed files with 264 additions and 70 deletions
-58
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;
}
+22
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;
}
@@ -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
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,
},
};
@@ -2,9 +2,9 @@
* ribbit-editor.ts WYSIWYG editing extension for Ribbit.
*/
import hopdown from './hopdown';
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';
/**
@@ -46,7 +46,7 @@ export class RibbitEditor extends Ribbit {
}
htmlToMarkdown(html?: string): string {
return hopdown.toMarkdown(html || this.element.innerHTML);
return this.converter.toMarkdown(html || this.element.innerHTML);
}
getMarkdown(): string {
@@ -80,6 +80,9 @@ export class RibbitEditor extends Ribbit {
}
edit(): void {
if (!this.theme.features?.sourceMode) {
return;
}
if (this.state === this.states.EDIT) return;
this.changed = false;
this.element.contentEditable = 'true';
@@ -101,11 +104,11 @@ export class RibbitEditor extends Ribbit {
// Attach public API to window for <script> tag usage.
(window as any).HopDown = HopDown;
(window as any).hopdown = hopdown;
(window as any).inlineTag = inlineTag;
(window as any).defaultTags = defaultTags;
(window as any).defaultBlockTags = defaultBlockTags;
(window as any).defaultInlineTags = defaultInlineTags;
(window as any).defaultTheme = defaultTheme;
(window as any).Ribbit = Ribbit;
(window as any).RibbitEditor = RibbitEditor;
(window as any).RibbitPlugin = RibbitPlugin;
+36 -2
View File
@@ -2,12 +2,18 @@
* 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 {
api?: unknown;
editorId?: 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;
changed: boolean;
enabledPlugins: Record<string, RibbitPlugin>;
theme: RibbitTheme;
themes: ThemeManager;
converter: HopDown;
themesPath: string;
constructor(settings: RibbitSettings) {
this.api = settings.api || null;
this.element = document.getElementById(settings.editorId || 'ribbit')!;
this.themesPath = settings.themesPath || './themes';
this.states = {
VIEW: 'view',
};
@@ -66,6 +77,29 @@ export class Ribbit {
this.changed = false;
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 => {
this.enabledPlugins[plugin.name] = new plugin({
name: plugin.name,
@@ -99,7 +133,7 @@ export class Ribbit {
}
markdownToHTML(md: string): string {
return hopdown.toHTML(md);
return this.converter.toHTML(md);
}
getHTML(): string {
View File
+114
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;
}
}
+10
View File
@@ -54,3 +54,13 @@ export interface InlineTagDef {
/** Process inner content for nested markdown? Default true. False for code spans. */
recursive?: boolean;
}
export interface RibbitThemeFeatures {
sourceMode?: boolean;
}
export interface RibbitTheme {
name: string;
tags?: Record<string, Tag>;
features?: RibbitThemeFeatures;
}