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:
parent
5983ce50fd
commit
ac7a698c4f
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
src/static/ribbit-core.css
Normal file
22
src/static/ribbit-core.css
Normal 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;
|
||||||
|
}
|
||||||
52
src/static/themes/ribbit-default/theme.css
Normal file
52
src/static/themes/ribbit-default/theme.css
Normal 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
16
src/ts/default-theme.ts
Normal 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.
|
* 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;
|
||||||
|
|
@ -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
114
src/ts/theme-manager.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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)); }
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user