Compare commits
5 Commits
2b88d2c10b
...
2e28598243
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e28598243 | ||
|
|
3368e719fd | ||
|
|
8bef75e59f | ||
|
|
1f523cbc0f | ||
|
|
98719ec8cd |
|
|
@ -7,10 +7,12 @@
|
||||||
"dist/ribbit/"
|
"dist/ribbit/"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:css",
|
"build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:core && npm run build:core-min && npm run build:css",
|
||||||
"build:check": "tsc --noEmit",
|
"build:check": "tsc --noEmit",
|
||||||
"build:js": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit.js",
|
"build:js": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit.js",
|
||||||
"build:min": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit.min.js",
|
"build:min": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit.min.js",
|
||||||
|
"build:core": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit-core.js",
|
||||||
|
"build:core-min": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit-core.min.js",
|
||||||
"build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/",
|
"build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/",
|
||||||
"test": "npm run build && jest --verbose",
|
"test": "npm run build && jest --verbose",
|
||||||
"test:coverage": "npm run build && jest --coverage"
|
"test:coverage": "npm run build && jest --coverage"
|
||||||
|
|
|
||||||
|
|
@ -55,3 +55,13 @@
|
||||||
#ribbit.wysiwyg blockquote.ribbit-editing::before {
|
#ribbit.wysiwyg blockquote.ribbit-editing::before {
|
||||||
content: "> ";
|
content: "> ";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ribbit.vim-normal {
|
||||||
|
cursor: default;
|
||||||
|
caret-color: transparent;
|
||||||
|
border-left: 3px solid #4af;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.vim-insert {
|
||||||
|
border-left: 3px solid #4f4;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,13 +52,9 @@ export class HopDown {
|
||||||
// Build macro tags if macros are provided
|
// Build macro tags if macros are provided
|
||||||
this.macroMap = new Map();
|
this.macroMap = new Map();
|
||||||
if (options.macros && options.macros.length > 0) {
|
if (options.macros && options.macros.length > 0) {
|
||||||
const { blockTag, selectorEntries, macroMap } = buildMacroTags(options.macros);
|
const { blockTag, selectorTag, macroMap } = buildMacroTags(options.macros);
|
||||||
this.macroMap = macroMap;
|
this.macroMap = macroMap;
|
||||||
tagMap = {
|
tagMap['[data-macro]'] = selectorTag;
|
||||||
...tagMap,
|
|
||||||
...selectorEntries,
|
|
||||||
};
|
|
||||||
// Insert macro block tag — will be placed after fencedCode below
|
|
||||||
tagMap['_macro'] = blockTag;
|
tagMap['_macro'] = blockTag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
132
src/ts/macros.ts
132
src/ts/macros.ts
|
|
@ -1,9 +1,9 @@
|
||||||
/*
|
/*
|
||||||
* macros.ts — macro parsing and Tag generation for ribbit.
|
* macros.ts — macro parsing and Tag generation for ribbit.
|
||||||
*
|
*
|
||||||
* Macros use @name(...) syntax. Everything lives inside the parens:
|
* Macros use @name(...) syntax. Ribbit automatically wraps macro output
|
||||||
* args on the first line, content on subsequent lines. The closing )
|
* in an element with data- attributes that preserve the original source.
|
||||||
* on its own line ends a block macro.
|
* Round-tripping is handled generically — consumers only write toHTML.
|
||||||
*
|
*
|
||||||
* Syntax:
|
* Syntax:
|
||||||
* @user — bare, no args
|
* @user — bare, no args
|
||||||
|
|
@ -18,18 +18,18 @@
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Tag, SourceToken, Converter, MatchContext } from './types';
|
import type { Tag, Converter, ToolbarButton } from './types';
|
||||||
import { escapeHtml } from './tags';
|
import { escapeHtml } from './tags';
|
||||||
|
|
||||||
export interface MacroDef {
|
export interface MacroDef {
|
||||||
name: string;
|
name: string;
|
||||||
/**
|
/**
|
||||||
* Render the macro to HTML.
|
* Render the macro's inner HTML. Ribbit wraps the result in an
|
||||||
|
* element with data- attributes for round-tripping.
|
||||||
*
|
*
|
||||||
* { name: 'npc', toHTML: ({ keywords }) => {
|
* { name: 'user', toHTML: () => '<a href="/User/gsb">gsb</a>' }
|
||||||
* const name = keywords.join(' ');
|
* { name: 'style', toHTML: ({ keywords, content }) =>
|
||||||
* return `<a href="/NPC/${name}">${name}</a>`;
|
* `<div class="${keywords.join(' ')}">${content}</div>` }
|
||||||
* }}
|
|
||||||
*/
|
*/
|
||||||
toHTML: (context: {
|
toHTML: (context: {
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
|
|
@ -38,16 +38,10 @@ export interface MacroDef {
|
||||||
convert: Converter;
|
convert: Converter;
|
||||||
}) => string;
|
}) => string;
|
||||||
/**
|
/**
|
||||||
* CSS selector for the HTML this macro produces.
|
* Toolbar button. Set to false to hide from the macros dropdown.
|
||||||
* Required for HTML→markdown round-tripping.
|
* Default: auto-generated from the macro name.
|
||||||
*/
|
*/
|
||||||
selector?: string;
|
button?: ToolbarButton | false;
|
||||||
/**
|
|
||||||
* Convert the macro's HTML back to macro syntax.
|
|
||||||
*
|
|
||||||
* toMarkdown: (el) => `@npc(${el.textContent})`
|
|
||||||
*/
|
|
||||||
toMarkdown?: (element: HTMLElement, convert: Converter) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedMacro {
|
interface ParsedMacro {
|
||||||
|
|
@ -84,10 +78,64 @@ function macroError(name: string): string {
|
||||||
return `<span class="ribbit-error">Unknown macro: @${escapeHtml(name)}</span>`;
|
return `<span class="ribbit-error">Unknown macro: @${escapeHtml(name)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a macro's rendered HTML with data- attributes for round-tripping.
|
||||||
|
* Block macros (with content) use <div>, inline macros use <span>.
|
||||||
|
*/
|
||||||
|
function wrapMacro(
|
||||||
|
name: string,
|
||||||
|
keywords: string[],
|
||||||
|
params: Record<string, string>,
|
||||||
|
verbatim: boolean,
|
||||||
|
hasContent: boolean,
|
||||||
|
innerHtml: string,
|
||||||
|
): string {
|
||||||
|
const tag = hasContent ? 'div' : 'span';
|
||||||
|
let attrs = ` data-macro="${escapeHtml(name)}"`;
|
||||||
|
if (keywords.length) {
|
||||||
|
attrs += ` data-keywords="${escapeHtml(keywords.join(' '))}"`;
|
||||||
|
}
|
||||||
|
for (const [key, val] of Object.entries(params)) {
|
||||||
|
attrs += ` data-param-${escapeHtml(key)}="${escapeHtml(val)}"`;
|
||||||
|
}
|
||||||
|
if (verbatim) {
|
||||||
|
attrs += ` data-verbatim="true"`;
|
||||||
|
}
|
||||||
|
return `<${tag}${attrs}>${innerHtml}</${tag}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct macro source from a DOM element's data- attributes.
|
||||||
|
* This is the generic toMarkdown for all macros.
|
||||||
|
*/
|
||||||
|
function macroToMarkdown(element: HTMLElement, convert: Converter): string {
|
||||||
|
const name = element.dataset.macro || '';
|
||||||
|
const keywords = element.dataset.keywords || '';
|
||||||
|
const verbatim = element.dataset.verbatim === 'true';
|
||||||
|
|
||||||
|
const paramParts: string[] = [];
|
||||||
|
for (const [key, val] of Object.entries(element.dataset)) {
|
||||||
|
if (key.startsWith('param') && key.length > 5) {
|
||||||
|
const paramName = key.slice(5).toLowerCase();
|
||||||
|
paramParts.push(`${paramName}="${val}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allKeywords = verbatim
|
||||||
|
? [keywords, 'verbatim'].filter(Boolean).join(' ')
|
||||||
|
: keywords;
|
||||||
|
const args = [allKeywords, paramParts.join(' ')].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
const isBlock = element.tagName === 'DIV';
|
||||||
|
if (isBlock) {
|
||||||
|
const content = convert.children(element);
|
||||||
|
return `\n\n@${name}(${args}\n${content}\n)\n\n`;
|
||||||
|
}
|
||||||
|
return args ? `@${name}(${args})` : `@${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to parse a block macro starting at the given line index.
|
* Try to parse a block macro starting at the given line index.
|
||||||
* Matches: @name(args at end of line (no closing paren),
|
|
||||||
* with content until a line containing only )
|
|
||||||
*/
|
*/
|
||||||
function parseBlockMacro(lines: string[], index: number): ParsedMacro | null {
|
function parseBlockMacro(lines: string[], index: number): ParsedMacro | null {
|
||||||
const line = lines[index];
|
const line = lines[index];
|
||||||
|
|
@ -126,10 +174,6 @@ function parseBlockMacro(lines: string[], index: number): ParsedMacro | null {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Inline macro pattern. Matches @name, @name(), or @name(args).
|
|
||||||
* The @ must be preceded by whitespace, start of string, or markdown delimiters.
|
|
||||||
*/
|
|
||||||
const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(\w+)(?:\(([^)]*)\))?/g;
|
const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(\w+)(?:\(([^)]*)\))?/g;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -137,7 +181,7 @@ const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(\w+)(?:\(([^)]*)\))?/g;
|
||||||
*/
|
*/
|
||||||
export function buildMacroTags(
|
export function buildMacroTags(
|
||||||
macros: MacroDef[],
|
macros: MacroDef[],
|
||||||
): { blockTag: Tag; selectorEntries: Record<string, Tag>; macroMap: Map<string, MacroDef> } {
|
): { blockTag: Tag; selectorTag: Tag; macroMap: Map<string, MacroDef> } {
|
||||||
const macroMap = new Map<string, MacroDef>();
|
const macroMap = new Map<string, MacroDef>();
|
||||||
for (const macro of macros) {
|
for (const macro of macros) {
|
||||||
macroMap.set(macro.name, macro);
|
macroMap.set(macro.name, macro);
|
||||||
|
|
@ -175,37 +219,38 @@ export function buildMacroTags(
|
||||||
content = convert.block(content);
|
content = convert.block(content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return macro.toHTML({
|
const innerHtml = macro.toHTML({
|
||||||
keywords: parsed.keywords,
|
keywords: parsed.keywords,
|
||||||
params: parsed.params,
|
params: parsed.params,
|
||||||
content,
|
content,
|
||||||
convert,
|
convert,
|
||||||
});
|
});
|
||||||
|
return wrapMacro(
|
||||||
|
parsed.name, parsed.keywords, parsed.params,
|
||||||
|
parsed.verbatim, true, innerHtml,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
selector: '[data-macro]',
|
selector: '[data-macro]',
|
||||||
toMarkdown: () => '',
|
toMarkdown: () => '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectorEntries: Record<string, Tag> = {};
|
/**
|
||||||
for (const macro of macros) {
|
* Generic selector tag that matches any element with data-macro
|
||||||
if (macro.selector && macro.toMarkdown) {
|
* and reconstructs the macro source from data- attributes.
|
||||||
const macroCopy = macro;
|
*/
|
||||||
selectorEntries[macro.selector] = {
|
const selectorTag: Tag = {
|
||||||
name: `macro:${macro.name}`,
|
name: 'macro:generic',
|
||||||
match: () => null,
|
match: () => null,
|
||||||
toHTML: () => '',
|
toHTML: () => '',
|
||||||
selector: macro.selector,
|
selector: '[data-macro]',
|
||||||
toMarkdown: (element, convert) => macroCopy.toMarkdown!(element, convert),
|
toMarkdown: macroToMarkdown,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { blockTag, selectorEntries, macroMap };
|
return { blockTag, selectorTag, macroMap };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process inline macros in a text string, replacing them with rendered HTML.
|
* Process inline macros in a text string, replacing them with rendered HTML.
|
||||||
* Called during inline processing pass 1 (placeholder extraction).
|
|
||||||
*/
|
*/
|
||||||
export function processInlineMacros(
|
export function processInlineMacros(
|
||||||
text: string,
|
text: string,
|
||||||
|
|
@ -220,12 +265,13 @@ export function processInlineMacros(
|
||||||
return '\x00P' + (placeholders.length - 1) + '\x00';
|
return '\x00P' + (placeholders.length - 1) + '\x00';
|
||||||
}
|
}
|
||||||
const { keywords, params } = parseArgs(argsStr);
|
const { keywords, params } = parseArgs(argsStr);
|
||||||
const html = macro.toHTML({
|
const innerHtml = macro.toHTML({
|
||||||
keywords,
|
keywords,
|
||||||
params,
|
params,
|
||||||
convert,
|
convert,
|
||||||
});
|
});
|
||||||
placeholders.push(html);
|
const wrapped = wrapMacro(nameStr, keywords, params, false, false, innerHtml);
|
||||||
|
placeholders.push(wrapped);
|
||||||
return '\x00P' + (placeholders.length - 1) + '\x00';
|
return '\x00P' + (placeholders.length - 1) + '\x00';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
src/ts/ribbit-core.ts
Normal file
21
src/ts/ribbit-core.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* ribbit-core.ts — lightweight entry point without optional features (vim).
|
||||||
|
*
|
||||||
|
* Same API as ribbit-editor.ts but excludes VimHandler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HopDown } from './hopdown';
|
||||||
|
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
|
||||||
|
import { defaultTheme } from './default-theme';
|
||||||
|
import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
||||||
|
import { type MacroDef } from './macros';
|
||||||
|
|
||||||
|
export { RibbitEditor as Editor } from './ribbit-editor';
|
||||||
|
export { Ribbit as Viewer };
|
||||||
|
export { HopDown };
|
||||||
|
export { inlineTag };
|
||||||
|
export { defaultTags, defaultBlockTags, defaultInlineTags };
|
||||||
|
export { defaultTheme };
|
||||||
|
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
|
||||||
|
export { ToolbarManager } from './toolbar';
|
||||||
|
export type { MacroDef };
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
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 { defaultTheme } from './default-theme';
|
||||||
import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
||||||
|
import { VimHandler } from './vim';
|
||||||
import { type MacroDef } from './macros';
|
import { type MacroDef } from './macros';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -22,6 +23,7 @@ import { type MacroDef } from './macros';
|
||||||
* editor.view(); // switch to read-only view
|
* editor.view(); // switch to read-only view
|
||||||
*/
|
*/
|
||||||
export class RibbitEditor extends Ribbit {
|
export class RibbitEditor extends Ribbit {
|
||||||
|
private vim?: VimHandler;
|
||||||
|
|
||||||
run(): void {
|
run(): void {
|
||||||
this.states = {
|
this.states = {
|
||||||
|
|
@ -30,11 +32,25 @@ export class RibbitEditor extends Ribbit {
|
||||||
WYSIWYG: 'wysiwyg'
|
WYSIWYG: 'wysiwyg'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (this.theme.features?.vim) {
|
||||||
|
this.vim = new VimHandler((mode) => {
|
||||||
|
if (mode === 'normal') {
|
||||||
|
this.toolbar.disable();
|
||||||
|
this.element.classList.add('vim-normal');
|
||||||
|
this.element.classList.remove('vim-insert');
|
||||||
|
} else {
|
||||||
|
this.toolbar.enable();
|
||||||
|
this.element.classList.add('vim-insert');
|
||||||
|
this.element.classList.remove('vim-normal');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.#bindEvents();
|
this.#bindEvents();
|
||||||
this.plugins().forEach(plugin => {
|
|
||||||
plugin.setEditable();
|
|
||||||
});
|
|
||||||
this.element.classList.add('loaded');
|
this.element.classList.add('loaded');
|
||||||
|
if (this.autoToolbar) {
|
||||||
|
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
|
||||||
|
}
|
||||||
this.view();
|
this.view();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,6 +220,7 @@ export class RibbitEditor extends Ribbit {
|
||||||
|
|
||||||
wysiwyg(): void {
|
wysiwyg(): void {
|
||||||
if (this.getState() === this.states.WYSIWYG) return;
|
if (this.getState() === this.states.WYSIWYG) return;
|
||||||
|
this.vim?.detach();
|
||||||
this.element.contentEditable = 'true';
|
this.element.contentEditable = 'true';
|
||||||
this.element.innerHTML = this.getHTML();
|
this.element.innerHTML = this.getHTML();
|
||||||
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
||||||
|
|
@ -223,6 +240,7 @@ export class RibbitEditor extends Ribbit {
|
||||||
if (this.state === this.states.EDIT) return;
|
if (this.state === this.states.EDIT) return;
|
||||||
this.element.contentEditable = 'true';
|
this.element.contentEditable = 'true';
|
||||||
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
||||||
|
this.vim?.attach(this.element);
|
||||||
this.setState(this.states.EDIT);
|
this.setState(this.states.EDIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,10 +259,11 @@ export class RibbitEditor extends Ribbit {
|
||||||
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
|
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
|
||||||
export { RibbitEditor as Editor };
|
export { RibbitEditor as Editor };
|
||||||
export { Ribbit as Viewer };
|
export { Ribbit as Viewer };
|
||||||
export { RibbitPlugin as Plugin };
|
|
||||||
export { HopDown };
|
export { HopDown };
|
||||||
export { inlineTag };
|
export { inlineTag };
|
||||||
export { defaultTags, defaultBlockTags, defaultInlineTags };
|
export { defaultTags, defaultBlockTags, defaultInlineTags };
|
||||||
export { defaultTheme };
|
export { defaultTheme };
|
||||||
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
|
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
|
||||||
|
export { ToolbarManager } from './toolbar';
|
||||||
|
export { VimHandler } from './vim';
|
||||||
export type { MacroDef };
|
export type { MacroDef };
|
||||||
|
|
|
||||||
105
src/ts/ribbit.ts
105
src/ts/ribbit.ts
|
|
@ -7,57 +7,24 @@ import { defaultTheme } from './default-theme';
|
||||||
import { ThemeManager } from './theme-manager';
|
import { ThemeManager } from './theme-manager';
|
||||||
import { RibbitEmitter, type RibbitEventMap } from './events';
|
import { RibbitEmitter, type RibbitEventMap } from './events';
|
||||||
import { type MacroDef } from './macros';
|
import { type MacroDef } from './macros';
|
||||||
import type { RibbitTheme } from './types';
|
import { ToolbarManager } from './toolbar';
|
||||||
|
import type { RibbitTheme, ToolbarSlot } 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 }>;
|
|
||||||
currentTheme?: string;
|
currentTheme?: string;
|
||||||
themes?: RibbitTheme[];
|
themes?: RibbitTheme[];
|
||||||
themesPath?: string;
|
themesPath?: string;
|
||||||
macros?: MacroDef[];
|
macros?: MacroDef[];
|
||||||
|
toolbar?: ToolbarSlot[];
|
||||||
|
/** Set to false to prevent auto-rendering the toolbar. Default true. */
|
||||||
|
autoToolbar?: boolean;
|
||||||
on?: Partial<RibbitEventMap>;
|
on?: Partial<RibbitEventMap>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Base class for editor plugins. Subclass and override toHTML/toMarkdown
|
|
||||||
* to add custom processing hooks.
|
|
||||||
*/
|
|
||||||
export class RibbitPlugin {
|
|
||||||
name: string;
|
|
||||||
wiki: Ribbit;
|
|
||||||
precedence: number;
|
|
||||||
|
|
||||||
constructor(settings: { name: string; wiki: Ribbit }) {
|
|
||||||
this.name = settings.name;
|
|
||||||
this.wiki = settings.wiki;
|
|
||||||
this.precedence = 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditable(): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
toMarkdown(html: string): string {
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
toHTML(md: string): string {
|
|
||||||
return md;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only markdown viewer. Renders markdown content into an HTML element.
|
* Read-only markdown viewer. Renders markdown content into an HTML element.
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* const viewer = new Ribbit({
|
|
||||||
* editorId: 'my-element',
|
|
||||||
* on: {
|
|
||||||
* ready: ({ mode, theme }) => console.log(`Ready in ${mode}`),
|
|
||||||
* },
|
|
||||||
* });
|
|
||||||
* viewer.run();
|
|
||||||
*/
|
*/
|
||||||
export class Ribbit {
|
export class Ribbit {
|
||||||
api: unknown;
|
api: unknown;
|
||||||
|
|
@ -67,11 +34,12 @@ export class Ribbit {
|
||||||
cachedMarkdown: string | null;
|
cachedMarkdown: string | null;
|
||||||
state: string | null;
|
state: string | null;
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
enabledPlugins: Record<string, RibbitPlugin>;
|
|
||||||
theme: RibbitTheme;
|
theme: RibbitTheme;
|
||||||
themes: ThemeManager;
|
themes: ThemeManager;
|
||||||
converter: HopDown;
|
converter: HopDown;
|
||||||
themesPath: string;
|
themesPath: string;
|
||||||
|
toolbar: ToolbarManager;
|
||||||
|
protected autoToolbar: boolean;
|
||||||
private emitter: RibbitEmitter;
|
private emitter: RibbitEmitter;
|
||||||
private macros: MacroDef[];
|
private macros: MacroDef[];
|
||||||
|
|
||||||
|
|
@ -88,7 +56,6 @@ export class Ribbit {
|
||||||
this.cachedMarkdown = null;
|
this.cachedMarkdown = null;
|
||||||
this.state = null;
|
this.state = null;
|
||||||
this.changed = false;
|
this.changed = false;
|
||||||
this.enabledPlugins = {};
|
|
||||||
|
|
||||||
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
|
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
|
||||||
this.theme = theme;
|
this.theme = theme;
|
||||||
|
|
@ -117,13 +84,6 @@ export class Ribbit {
|
||||||
? new HopDown({ tags: this.theme.tags, macros: this.macros })
|
? new HopDown({ tags: this.theme.tags, macros: this.macros })
|
||||||
: new HopDown({ macros: this.macros });
|
: new HopDown({ macros: this.macros });
|
||||||
|
|
||||||
(settings.plugins || []).forEach(plugin => {
|
|
||||||
this.enabledPlugins[plugin.name] = new plugin({
|
|
||||||
name: plugin.name,
|
|
||||||
wiki: this,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (settings.on) {
|
if (settings.on) {
|
||||||
for (const [event, handler] of Object.entries(settings.on)) {
|
for (const [event, handler] of Object.entries(settings.on)) {
|
||||||
if (handler) {
|
if (handler) {
|
||||||
|
|
@ -131,30 +91,29 @@ export class Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.toolbar = new ToolbarManager(
|
||||||
|
this,
|
||||||
|
this.theme.tags || {},
|
||||||
|
this.macros,
|
||||||
|
settings.toolbar,
|
||||||
|
);
|
||||||
|
this.autoToolbar = settings.autoToolbar !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a callback for an event.
|
|
||||||
*
|
|
||||||
* editor.on('save', ({ markdown }) => {
|
|
||||||
* fetch('/api/save', { method: 'POST', body: markdown });
|
|
||||||
* });
|
|
||||||
*/
|
|
||||||
on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
||||||
this.emitter.on(event, callback);
|
this.emitter.on(event, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a previously registered callback.
|
|
||||||
*
|
|
||||||
* editor.off('change', myHandler);
|
|
||||||
*/
|
|
||||||
off<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
off<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
||||||
this.emitter.off(event, callback);
|
this.emitter.off(event, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
run(): void {
|
run(): void {
|
||||||
this.element.classList.add('loaded');
|
this.element.classList.add('loaded');
|
||||||
|
if (this.autoToolbar) {
|
||||||
|
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
|
||||||
|
}
|
||||||
this.view();
|
this.view();
|
||||||
this.emitter.emit('ready', {
|
this.emitter.emit('ready', {
|
||||||
markdown: this.getMarkdown(),
|
markdown: this.getMarkdown(),
|
||||||
|
|
@ -164,10 +123,6 @@ export class Ribbit {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
plugins(): RibbitPlugin[] {
|
|
||||||
return Object.values(this.enabledPlugins).sort((a, b) => a.precedence - b.precedence);
|
|
||||||
}
|
|
||||||
|
|
||||||
getState(): string | null {
|
getState(): string | null {
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
@ -203,12 +158,6 @@ export class Ribbit {
|
||||||
return this.cachedMarkdown;
|
return this.cachedMarkdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Request a save. Fires the 'save' event with the current content.
|
|
||||||
* The consumer's callback handles persistence.
|
|
||||||
*
|
|
||||||
* editor.save(); // triggers on.save({ markdown, html })
|
|
||||||
*/
|
|
||||||
save(): void {
|
save(): void {
|
||||||
this.emitter.emit('save', {
|
this.emitter.emit('save', {
|
||||||
markdown: this.getMarkdown(),
|
markdown: this.getMarkdown(),
|
||||||
|
|
@ -223,20 +172,12 @@ export class Ribbit {
|
||||||
this.element.contentEditable = 'false';
|
this.element.contentEditable = 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate cached markdown and HTML. Called when content changes.
|
|
||||||
* The next call to getMarkdown() or getHTML() will recompute.
|
|
||||||
*/
|
|
||||||
invalidateCache(): void {
|
invalidateCache(): void {
|
||||||
this.changed = true;
|
this.changed = true;
|
||||||
this.cachedMarkdown = null;
|
this.cachedMarkdown = null;
|
||||||
this.cachedHTML = 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 {
|
notifyChange(): void {
|
||||||
this.emitter.emit('change', {
|
this.emitter.emit('change', {
|
||||||
markdown: this.getMarkdown(),
|
markdown: this.getMarkdown(),
|
||||||
|
|
@ -245,10 +186,6 @@ export class Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a string to title case, splitting on whitespace.
|
|
||||||
* Returns an array of capitalized words.
|
|
||||||
*/
|
|
||||||
export function camelCase(words: string): string[] {
|
export function camelCase(words: string): string[] {
|
||||||
return words.trim().split(/\s+/g).map(word => {
|
return words.trim().split(/\s+/g).map(word => {
|
||||||
const lc = word.toLowerCase();
|
const lc = word.toLowerCase();
|
||||||
|
|
@ -256,18 +193,12 @@ export function camelCase(words: string): string[] {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode HTML entities in a string using a textarea element.
|
|
||||||
*/
|
|
||||||
export function decodeHtmlEntities(html: string): string {
|
export function decodeHtmlEntities(html: string): string {
|
||||||
const txt = document.createElement('textarea');
|
const txt = document.createElement('textarea');
|
||||||
txt.innerHTML = html;
|
txt.innerHTML = html;
|
||||||
return txt.value;
|
return txt.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode HTML-significant characters as numeric entities.
|
|
||||||
*/
|
|
||||||
export function encodeHtmlEntities(str: string): string {
|
export function encodeHtmlEntities(str: string): string {
|
||||||
return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';');
|
return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* with rules for matching, converting to HTML, and converting back.
|
* with rules for matching, converting to HTML, and converting back.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Tag, MatchContext, SourceToken, Converter, ListItem, ListResult, InlineTagDef } from './types';
|
import type { Tag, Converter, ListItem, ListResult, InlineTagDef } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Tag from a shorthand inline definition.
|
* Create a Tag from a shorthand inline definition.
|
||||||
|
|
@ -35,6 +35,13 @@ export function inlineTag(def: InlineTagDef): Tag {
|
||||||
pattern: globalPattern,
|
pattern: globalPattern,
|
||||||
openPattern,
|
openPattern,
|
||||||
delimiter: def.delimiter,
|
delimiter: def.delimiter,
|
||||||
|
template: `${def.delimiter}text${def.delimiter}`,
|
||||||
|
replaceSelection: true,
|
||||||
|
button: def.button === false ? undefined : {
|
||||||
|
show: true,
|
||||||
|
label: def.name.charAt(0).toUpperCase() + def.name.slice(1),
|
||||||
|
...(typeof def.button === 'object' ? def.button : {}),
|
||||||
|
},
|
||||||
match: (context) => {
|
match: (context) => {
|
||||||
const matched = context.text.slice(context.offset).match(matchPattern);
|
const matched = context.text.slice(context.offset).match(matchPattern);
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
|
|
@ -180,6 +187,9 @@ export const defaultBlockTags: Record<string, Tag> = {
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
name: 'fencedCode',
|
name: 'fencedCode',
|
||||||
|
button: { show: true, label: 'Code Block', shortcut: 'Ctrl+Shift+E' },
|
||||||
|
template: '```\ncode\n```',
|
||||||
|
replaceSelection: true,
|
||||||
match: (context) => {
|
match: (context) => {
|
||||||
const matched = context.lines[context.index].match(/^(`{3,})(.*)/);
|
const matched = context.lines[context.index].match(/^(`{3,})(.*)/);
|
||||||
if (!matched) return null;
|
if (!matched) return null;
|
||||||
|
|
@ -215,6 +225,9 @@ export const defaultBlockTags: Record<string, Tag> = {
|
||||||
* ___
|
* ___
|
||||||
*/
|
*/
|
||||||
name: 'hr',
|
name: 'hr',
|
||||||
|
button: { show: true, label: 'Divider', shortcut: 'Ctrl+Shift+-' },
|
||||||
|
template: '---',
|
||||||
|
replaceSelection: false,
|
||||||
match: (context) => {
|
match: (context) => {
|
||||||
if (!/^(\*{3,}|-{3,}|_{3,})\s*$/.test(context.lines[context.index])) {
|
if (!/^(\*{3,}|-{3,}|_{3,})\s*$/.test(context.lines[context.index])) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -237,6 +250,7 @@ export const defaultBlockTags: Record<string, Tag> = {
|
||||||
* ### Heading 3
|
* ### Heading 3
|
||||||
*/
|
*/
|
||||||
name: 'heading',
|
name: 'heading',
|
||||||
|
button: { show: false, label: 'Heading' },
|
||||||
match: (context) => {
|
match: (context) => {
|
||||||
const matched = context.lines[context.index].match(/^(#{1,6})\s+(.*)/);
|
const matched = context.lines[context.index].match(/^(#{1,6})\s+(.*)/);
|
||||||
if (!matched) return null;
|
if (!matched) return null;
|
||||||
|
|
@ -262,6 +276,9 @@ export const defaultBlockTags: Record<string, Tag> = {
|
||||||
* > more quoted text
|
* > more quoted text
|
||||||
*/
|
*/
|
||||||
name: 'blockquote',
|
name: 'blockquote',
|
||||||
|
button: { show: true, label: 'Quote', shortcut: 'Ctrl+Shift+.' },
|
||||||
|
template: '> Quote\n> continues here',
|
||||||
|
replaceSelection: true,
|
||||||
match: (context) => {
|
match: (context) => {
|
||||||
if (!/^>\s?/.test(context.lines[context.index])) return null;
|
if (!/^>\s?/.test(context.lines[context.index])) return null;
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
@ -290,6 +307,7 @@ export const defaultBlockTags: Record<string, Tag> = {
|
||||||
* 2. ordered item
|
* 2. ordered item
|
||||||
*/
|
*/
|
||||||
name: 'list',
|
name: 'list',
|
||||||
|
button: { show: false, label: 'List' },
|
||||||
match: (context) => {
|
match: (context) => {
|
||||||
const line = context.lines[context.index];
|
const line = context.lines[context.index];
|
||||||
if (!/^[*\-]\s/.test(line) && !/^\d+\.\s/.test(line)) return null;
|
if (!/^[*\-]\s/.test(line) && !/^\d+\.\s/.test(line)) return null;
|
||||||
|
|
@ -312,6 +330,9 @@ export const defaultBlockTags: Record<string, Tag> = {
|
||||||
* | cell 1 | cell 2 |
|
* | cell 1 | cell 2 |
|
||||||
*/
|
*/
|
||||||
name: 'table',
|
name: 'table',
|
||||||
|
button: { show: true, label: 'Table', shortcut: 'Ctrl+Shift+T' },
|
||||||
|
template: '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |',
|
||||||
|
replaceSelection: false,
|
||||||
match: (context) => {
|
match: (context) => {
|
||||||
const { lines, index } = context;
|
const { lines, index } = context;
|
||||||
if (lines[index].indexOf('|') === -1 || index + 1 >= lines.length) return null;
|
if (lines[index].indexOf('|') === -1 || index + 1 >= lines.length) return null;
|
||||||
|
|
@ -405,6 +426,7 @@ export const defaultInlineTags: Record<string, Tag> = {
|
||||||
htmlTag: 'code',
|
htmlTag: 'code',
|
||||||
precedence: 10,
|
precedence: 10,
|
||||||
recursive: false,
|
recursive: false,
|
||||||
|
button: { show: true, label: 'Code', shortcut: 'Ctrl+E' },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
'A': {
|
'A': {
|
||||||
|
|
@ -412,6 +434,9 @@ export const defaultInlineTags: Record<string, Tag> = {
|
||||||
* [link text](http://example.com)
|
* [link text](http://example.com)
|
||||||
*/
|
*/
|
||||||
name: 'link',
|
name: 'link',
|
||||||
|
button: { show: true, label: 'Link', shortcut: 'Ctrl+K' },
|
||||||
|
template: '[text](url)',
|
||||||
|
replaceSelection: true,
|
||||||
match: (context) => {
|
match: (context) => {
|
||||||
const matched = context.text.slice(context.offset).match(/^\[([^\]]+)\]\(([^)]+)\)/);
|
const matched = context.text.slice(context.offset).match(/^\[([^\]]+)\]\(([^)]+)\)/);
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
|
|
@ -458,6 +483,7 @@ export const defaultInlineTags: Record<string, Tag> = {
|
||||||
htmlTag: 'strong',
|
htmlTag: 'strong',
|
||||||
aliases: 'B',
|
aliases: 'B',
|
||||||
precedence: 40,
|
precedence: 40,
|
||||||
|
button: { show: true, label: 'Bold', shortcut: 'Ctrl+B' },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
'EM,I': inlineTag({
|
'EM,I': inlineTag({
|
||||||
|
|
@ -469,6 +495,7 @@ export const defaultInlineTags: Record<string, Tag> = {
|
||||||
htmlTag: 'em',
|
htmlTag: 'em',
|
||||||
aliases: 'I',
|
aliases: 'I',
|
||||||
precedence: 50,
|
precedence: 50,
|
||||||
|
button: { show: true, label: 'Italic', shortcut: 'Ctrl+I' },
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
376
src/ts/toolbar.ts
Normal file
376
src/ts/toolbar.ts
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
/*
|
||||||
|
* toolbar.ts — toolbar manager for the ribbit editor.
|
||||||
|
*
|
||||||
|
* Resolves tags and macros into toolbar buttons. Renders the toolbar
|
||||||
|
* DOM. Manages button state (active/disabled/visible).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const toolbar = editor.toolbar;
|
||||||
|
* toolbar.buttons.get('bold').click();
|
||||||
|
* toolbar.buttons.get('table').hide();
|
||||||
|
* document.body.prepend(toolbar.render());
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Tag, ToolbarSlot, Button } from './types';
|
||||||
|
import type { MacroDef } from './macros';
|
||||||
|
|
||||||
|
class ButtonImpl implements Button {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
shortcut?: string;
|
||||||
|
action: 'wrap' | 'prefix' | 'insert' | 'custom';
|
||||||
|
delimiter?: string;
|
||||||
|
template?: string;
|
||||||
|
replaceSelection: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
element?: HTMLElement;
|
||||||
|
handler?: () => void;
|
||||||
|
|
||||||
|
constructor(def: Partial<Button> & { id: string }) {
|
||||||
|
this.id = def.id;
|
||||||
|
this.label = def.label || def.id;
|
||||||
|
this.icon = def.icon;
|
||||||
|
this.shortcut = def.shortcut;
|
||||||
|
this.action = def.action || 'insert';
|
||||||
|
this.delimiter = def.delimiter;
|
||||||
|
this.template = def.template;
|
||||||
|
this.replaceSelection = def.replaceSelection ?? true;
|
||||||
|
this.visible = def.visible ?? true;
|
||||||
|
this.handler = def.handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
click(): void {
|
||||||
|
this.element?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
hide(): void {
|
||||||
|
this.visible = false;
|
||||||
|
if (this.element) {
|
||||||
|
this.element.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
show(): void {
|
||||||
|
this.visible = true;
|
||||||
|
if (this.element) {
|
||||||
|
this.element.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ToolbarManager {
|
||||||
|
buttons: Map<string, Button>;
|
||||||
|
private layout: ToolbarSlot[];
|
||||||
|
private editor: any;
|
||||||
|
|
||||||
|
constructor(editor: any, tags: Record<string, Tag>, macros: MacroDef[], layout?: ToolbarSlot[]) {
|
||||||
|
this.editor = editor;
|
||||||
|
this.buttons = new Map();
|
||||||
|
|
||||||
|
for (const tag of Object.values(tags)) {
|
||||||
|
if (!tag.button || !tag.button.show) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.register(tag.name, {
|
||||||
|
label: tag.button.label,
|
||||||
|
icon: tag.button.icon,
|
||||||
|
shortcut: tag.button.shortcut,
|
||||||
|
action: tag.delimiter ? 'wrap' : 'insert',
|
||||||
|
delimiter: tag.delimiter,
|
||||||
|
template: tag.template,
|
||||||
|
replaceSelection: tag.replaceSelection,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading and list variants (derived from their parent tags)
|
||||||
|
for (let i = 1; i <= 6; i++) {
|
||||||
|
this.register(`h${i}`, {
|
||||||
|
label: `H${i}`,
|
||||||
|
shortcut: `Ctrl+${i}`,
|
||||||
|
action: 'prefix',
|
||||||
|
delimiter: '#'.repeat(i) + ' ',
|
||||||
|
replaceSelection: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.register('ul', {
|
||||||
|
label: 'Bullet List',
|
||||||
|
shortcut: 'Ctrl+Shift+8',
|
||||||
|
action: 'insert',
|
||||||
|
template: '- Item 1\n- Item 2\n- Item 3',
|
||||||
|
replaceSelection: false,
|
||||||
|
});
|
||||||
|
this.register('ol', {
|
||||||
|
label: 'Numbered List',
|
||||||
|
shortcut: 'Ctrl+Shift+7',
|
||||||
|
action: 'insert',
|
||||||
|
template: '1. Item 1\n2. Item 2\n3. Item 3',
|
||||||
|
replaceSelection: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const macro of macros) {
|
||||||
|
if (macro.button === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const btn = typeof macro.button === 'object' ? macro.button : null;
|
||||||
|
this.register(`macro:${macro.name}`, {
|
||||||
|
label: btn?.label || macro.name.charAt(0).toUpperCase() + macro.name.slice(1),
|
||||||
|
icon: btn?.icon,
|
||||||
|
action: 'insert',
|
||||||
|
template: `@${macro.name}`,
|
||||||
|
replaceSelection: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.register('save', {
|
||||||
|
label: 'Save', shortcut: 'Ctrl+S', action: 'custom',
|
||||||
|
handler: () => this.editor.save(),
|
||||||
|
});
|
||||||
|
this.register('toggle', {
|
||||||
|
label: 'Edit', shortcut: 'Ctrl+Shift+V', action: 'custom',
|
||||||
|
handler: () => {
|
||||||
|
this.editor.getState() === 'view'
|
||||||
|
? this.editor.wysiwyg()
|
||||||
|
: this.editor.view();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.register('markdown', {
|
||||||
|
label: 'Source', shortcut: 'Ctrl+/', action: 'custom',
|
||||||
|
handler: () => {
|
||||||
|
this.editor.getState() === 'edit'
|
||||||
|
? this.editor.wysiwyg()
|
||||||
|
: this.editor.edit();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.layout = layout || this.defaultLayout();
|
||||||
|
this.bindShortcuts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for keyboard shortcuts on the document and dispatch
|
||||||
|
* to the matching toolbar button.
|
||||||
|
*/
|
||||||
|
private bindShortcuts(): void {
|
||||||
|
const shortcutMap = new Map<string, Button>();
|
||||||
|
for (const button of this.buttons.values()) {
|
||||||
|
if (button.shortcut) {
|
||||||
|
shortcutMap.set(button.shortcut.toLowerCase(), button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (event.ctrlKey || event.metaKey) parts.push('ctrl');
|
||||||
|
if (event.shiftKey) parts.push('shift');
|
||||||
|
if (event.altKey) parts.push('alt');
|
||||||
|
|
||||||
|
let key = event.key;
|
||||||
|
if (key === '/') key = '/';
|
||||||
|
else if (key === '.') key = '.';
|
||||||
|
else if (key === '-') key = '-';
|
||||||
|
else key = key.toLowerCase();
|
||||||
|
|
||||||
|
parts.push(key);
|
||||||
|
const combo = parts.join('+');
|
||||||
|
|
||||||
|
const button = shortcutMap.get(combo);
|
||||||
|
if (button) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.executeAction(button);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private register(id: string, def: Partial<Button>): void {
|
||||||
|
if (this.buttons.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.buttons.set(id, new ButtonImpl({ id, ...def }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private defaultLayout(): ToolbarSlot[] {
|
||||||
|
const tagIds: string[] = [];
|
||||||
|
const macroIds: string[] = [];
|
||||||
|
for (const id of this.buttons.keys()) {
|
||||||
|
if (['save', 'toggle', 'markdown'].includes(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (id.startsWith('macro:')) {
|
||||||
|
macroIds.push(id);
|
||||||
|
} else {
|
||||||
|
tagIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const slots: ToolbarSlot[] = [...tagIds];
|
||||||
|
if (macroIds.length > 0) {
|
||||||
|
slots.push('');
|
||||||
|
slots.push({ group: 'Macros', items: macroIds });
|
||||||
|
}
|
||||||
|
slots.push('', 'markdown', 'save', 'toggle');
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update .active class on buttons matching the cursor's formatting context.
|
||||||
|
*/
|
||||||
|
updateActiveState(activeTagNames: string[]): void {
|
||||||
|
for (const [id, button] of this.buttons) {
|
||||||
|
button.element?.classList.toggle('active', activeTagNames.includes(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable all toolbar buttons.
|
||||||
|
*/
|
||||||
|
enable(): void {
|
||||||
|
for (const button of this.buttons.values()) {
|
||||||
|
button.element?.classList.remove('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable all toolbar buttons.
|
||||||
|
*/
|
||||||
|
disable(): void {
|
||||||
|
for (const button of this.buttons.values()) {
|
||||||
|
button.element?.classList.add('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the toolbar DOM and return it. Caller inserts it.
|
||||||
|
*/
|
||||||
|
render(): HTMLElement {
|
||||||
|
const nav = document.createElement('nav');
|
||||||
|
nav.className = 'ribbit-toolbar';
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
|
||||||
|
for (const slot of this.layout) {
|
||||||
|
if (slot === '') {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'spacer';
|
||||||
|
ul.appendChild(li);
|
||||||
|
} else if (typeof slot === 'string') {
|
||||||
|
if (slot === 'macros') {
|
||||||
|
const items = [...this.buttons.values()].filter(b => b.id.startsWith('macro:'));
|
||||||
|
if (items.length > 0) {
|
||||||
|
ul.appendChild(this.renderGroup({ label: 'Macros', items }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const button = this.buttons.get(slot);
|
||||||
|
if (button) {
|
||||||
|
ul.appendChild(this.renderButton(button));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const items = slot.items
|
||||||
|
.map(id => this.buttons.get(id))
|
||||||
|
.filter((b): b is Button => b !== undefined);
|
||||||
|
if (items.length > 0) {
|
||||||
|
ul.appendChild(this.renderGroup({ label: slot.group, items }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.appendChild(ul);
|
||||||
|
return nav;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderButton(button: Button): HTMLElement {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = `ribbit-btn-${button.id}`;
|
||||||
|
btn.setAttribute('aria-label', button.label);
|
||||||
|
btn.title = button.shortcut
|
||||||
|
? `${button.label} (${button.shortcut})`
|
||||||
|
: button.label;
|
||||||
|
if (!button.visible) {
|
||||||
|
li.style.display = 'none';
|
||||||
|
}
|
||||||
|
btn.addEventListener('click', () => this.executeAction(button));
|
||||||
|
button.element = btn;
|
||||||
|
li.appendChild(btn);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderGroup(group: { label: string; items: Button[] }): HTMLElement {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const toggle = document.createElement('button');
|
||||||
|
toggle.className = 'ribbit-btn-group';
|
||||||
|
toggle.setAttribute('aria-label', group.label);
|
||||||
|
toggle.title = group.label;
|
||||||
|
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'ribbit-dropdown';
|
||||||
|
menu.style.display = 'none';
|
||||||
|
|
||||||
|
for (const button of group.items) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = `ribbit-btn-${button.id}`;
|
||||||
|
btn.setAttribute('aria-label', button.label);
|
||||||
|
btn.title = button.label;
|
||||||
|
btn.textContent = button.label;
|
||||||
|
if (!button.visible) {
|
||||||
|
btn.style.display = 'none';
|
||||||
|
}
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this.executeAction(button);
|
||||||
|
menu.style.display = 'none';
|
||||||
|
});
|
||||||
|
button.element = btn;
|
||||||
|
menu.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
menu.style.display = menu.style.display === 'none' ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
li.appendChild(toggle);
|
||||||
|
li.appendChild(menu);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeAction(button: Button): void {
|
||||||
|
if (!button.visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button.handler) {
|
||||||
|
button.handler();
|
||||||
|
this.editor.element.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button.action === 'wrap' && button.delimiter) {
|
||||||
|
this.wrapSelection(button.delimiter);
|
||||||
|
} else if (button.action === 'insert' && button.template) {
|
||||||
|
this.insertText(button.template, button.replaceSelection);
|
||||||
|
}
|
||||||
|
this.editor.invalidateCache();
|
||||||
|
this.editor.element.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private wrapSelection(delimiter: string): void {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || sel.rangeCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
const text = range.toString();
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(document.createTextNode(delimiter + text + delimiter));
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertText(text: string, replaceSelection: boolean): void {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || sel.rangeCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
if (replaceSelection) {
|
||||||
|
range.deleteContents();
|
||||||
|
} else {
|
||||||
|
range.collapse(false);
|
||||||
|
}
|
||||||
|
range.insertNode(document.createTextNode(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* types.ts — shared types for the hopdown converter.
|
* types.ts — shared types for the ribbit editor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface SourceToken {
|
export interface SourceToken {
|
||||||
|
|
@ -23,28 +23,27 @@ export interface MatchContext {
|
||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolbarButton {
|
||||||
|
show: boolean;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
shortcut?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
name: string;
|
name: string;
|
||||||
match: (context: MatchContext) => SourceToken | null;
|
match: (context: MatchContext) => SourceToken | null;
|
||||||
toHTML: (token: SourceToken, convert: Converter) => string;
|
toHTML: (token: SourceToken, convert: Converter) => string;
|
||||||
selector: string | ((element: HTMLElement) => boolean);
|
selector: string | ((element: HTMLElement) => boolean);
|
||||||
toMarkdown: (element: HTMLElement, convert: Converter) => string;
|
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;
|
openPattern?: RegExp;
|
||||||
/**
|
|
||||||
* The markdown delimiter string. Auto-generated by inlineTag().
|
|
||||||
*/
|
|
||||||
delimiter?: string;
|
delimiter?: string;
|
||||||
/** Lower runs first in inline processing. Default 50. Auto-generated by inlineTag(). */
|
|
||||||
precedence?: number;
|
precedence?: number;
|
||||||
/** Whether inner content is processed for nested markdown. Auto-generated by inlineTag(). */
|
|
||||||
recursive?: boolean;
|
recursive?: boolean;
|
||||||
/** Global regex for matching this tag's delimiter pair. Auto-generated by inlineTag(). */
|
|
||||||
pattern?: RegExp;
|
pattern?: RegExp;
|
||||||
|
template?: string;
|
||||||
|
replaceSelection?: boolean;
|
||||||
|
button?: ToolbarButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListItem {
|
export interface ListItem {
|
||||||
|
|
@ -59,20 +58,49 @@ export interface ListResult {
|
||||||
|
|
||||||
export interface InlineTagDef {
|
export interface InlineTagDef {
|
||||||
name: string;
|
name: string;
|
||||||
/** The markdown delimiter, e.g. '**' or '`' or '~~' */
|
|
||||||
delimiter: string;
|
delimiter: string;
|
||||||
/** The HTML tag to wrap with, e.g. 'strong' or 'code' */
|
|
||||||
htmlTag: string;
|
htmlTag: string;
|
||||||
/** Additional HTML selectors for reverse matching, e.g. 'B' for bold */
|
|
||||||
aliases?: string;
|
aliases?: string;
|
||||||
/** Lower runs first. Default 50. */
|
|
||||||
precedence?: number;
|
precedence?: number;
|
||||||
/** Process inner content for nested markdown? Default true. False for code spans. */
|
|
||||||
recursive?: boolean;
|
recursive?: boolean;
|
||||||
|
button?: ToolbarButton | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RibbitThemeFeatures {
|
export interface RibbitThemeFeatures {
|
||||||
sourceMode?: boolean;
|
sourceMode?: boolean;
|
||||||
|
vim?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A slot in the toolbar layout.
|
||||||
|
*
|
||||||
|
* 'bold' — single button
|
||||||
|
* '' — spacer
|
||||||
|
* 'macros' — auto-populated macro dropdown
|
||||||
|
* { group: 'Heading', items: ['h1', ...] } — dropdown group
|
||||||
|
*/
|
||||||
|
export type ToolbarSlot =
|
||||||
|
| string
|
||||||
|
| { group: string; items: string[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A resolved toolbar button with methods for interaction.
|
||||||
|
*/
|
||||||
|
export interface Button {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
shortcut?: string;
|
||||||
|
action: 'wrap' | 'prefix' | 'insert' | 'custom';
|
||||||
|
delimiter?: string;
|
||||||
|
template?: string;
|
||||||
|
replaceSelection: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
element?: HTMLElement;
|
||||||
|
handler?: () => void;
|
||||||
|
click(): void;
|
||||||
|
hide(): void;
|
||||||
|
show(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RibbitTheme {
|
export interface RibbitTheme {
|
||||||
|
|
|
||||||
248
src/ts/vim.ts
Normal file
248
src/ts/vim.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
/*
|
||||||
|
* vim.ts — vim keybinding handler for ribbit source edit mode.
|
||||||
|
*
|
||||||
|
* Two modes: normal and insert. Activated in source (edit) mode only.
|
||||||
|
* Esc enters normal mode, i/a/o/O enter insert mode.
|
||||||
|
*
|
||||||
|
* Normal mode commands:
|
||||||
|
* h/j/k/l — cursor movement
|
||||||
|
* w/b — word forward/back
|
||||||
|
* 0/$ — line start/end
|
||||||
|
* gg/G — document start/end
|
||||||
|
* i — insert before cursor
|
||||||
|
* a — insert after cursor
|
||||||
|
* o — new line below, insert
|
||||||
|
* O — new line above, insert
|
||||||
|
* x — delete char under cursor
|
||||||
|
* dd — delete line
|
||||||
|
* u — undo
|
||||||
|
* Ctrl+r — redo
|
||||||
|
*/
|
||||||
|
|
||||||
|
type VimMode = 'normal' | 'insert';
|
||||||
|
|
||||||
|
export class VimHandler {
|
||||||
|
mode: VimMode;
|
||||||
|
private element: HTMLElement | null;
|
||||||
|
private listener: ((e: KeyboardEvent) => void) | null;
|
||||||
|
private pending: string;
|
||||||
|
private count: string;
|
||||||
|
private onModeChange: (mode: VimMode) => void;
|
||||||
|
|
||||||
|
constructor(onModeChange: (mode: VimMode) => void) {
|
||||||
|
this.mode = 'insert';
|
||||||
|
this.element = null;
|
||||||
|
this.listener = null;
|
||||||
|
this.pending = '';
|
||||||
|
this.count = '';
|
||||||
|
this.onModeChange = onModeChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
attach(element: HTMLElement): void {
|
||||||
|
this.detach();
|
||||||
|
this.element = element;
|
||||||
|
this.pending = '';
|
||||||
|
this.listener = (e: KeyboardEvent) => this.handleKey(e);
|
||||||
|
this.element.addEventListener('keydown', this.listener);
|
||||||
|
this.setMode('insert');
|
||||||
|
}
|
||||||
|
|
||||||
|
detach(): void {
|
||||||
|
if (this.element && this.listener) {
|
||||||
|
this.element.removeEventListener('keydown', this.listener);
|
||||||
|
this.element.classList.remove('vim-normal', 'vim-insert');
|
||||||
|
}
|
||||||
|
this.element = null;
|
||||||
|
this.listener = null;
|
||||||
|
this.mode = 'insert';
|
||||||
|
this.pending = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private setMode(mode: VimMode): void {
|
||||||
|
this.mode = mode;
|
||||||
|
this.pending = '';
|
||||||
|
this.count = '';
|
||||||
|
this.onModeChange(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKey(e: KeyboardEvent): void {
|
||||||
|
if (this.mode === 'insert') {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.setMode('normal');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal mode — prevent all default text input
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Undo/redo with Ctrl
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
if (e.key === 'r') {
|
||||||
|
document.execCommand('redo');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = e.key;
|
||||||
|
|
||||||
|
// Accumulate count prefix (digits, but not 0 as first char — that's line start)
|
||||||
|
if (/^[0-9]$/.test(key) && (this.count || key !== '0')) {
|
||||||
|
this.count += key;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repeat = parseInt(this.count || '1', 10);
|
||||||
|
this.count = '';
|
||||||
|
|
||||||
|
// Two-char commands
|
||||||
|
if (this.pending) {
|
||||||
|
const combo = this.pending + key;
|
||||||
|
this.pending = '';
|
||||||
|
for (let n = 0; n < repeat; n++) {
|
||||||
|
this.handlePending(combo);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
// Mode switching — no repeat
|
||||||
|
case 'i':
|
||||||
|
this.setMode('insert');
|
||||||
|
break;
|
||||||
|
case 'a':
|
||||||
|
this.moveCursor('right');
|
||||||
|
this.setMode('insert');
|
||||||
|
break;
|
||||||
|
case 'o':
|
||||||
|
this.endOfLine();
|
||||||
|
this.insertNewline();
|
||||||
|
this.setMode('insert');
|
||||||
|
break;
|
||||||
|
case 'O':
|
||||||
|
this.startOfLine();
|
||||||
|
this.insertNewline();
|
||||||
|
this.moveCursor('up');
|
||||||
|
this.setMode('insert');
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Movement — repeatable
|
||||||
|
case 'h':
|
||||||
|
for (let n = 0; n < repeat; n++) this.moveCursor('left');
|
||||||
|
break;
|
||||||
|
case 'j':
|
||||||
|
for (let n = 0; n < repeat; n++) this.moveCursor('down');
|
||||||
|
break;
|
||||||
|
case 'k':
|
||||||
|
for (let n = 0; n < repeat; n++) this.moveCursor('up');
|
||||||
|
break;
|
||||||
|
case 'l':
|
||||||
|
for (let n = 0; n < repeat; n++) this.moveCursor('right');
|
||||||
|
break;
|
||||||
|
case 'w':
|
||||||
|
for (let n = 0; n < repeat; n++) this.wordForward();
|
||||||
|
break;
|
||||||
|
case 'b':
|
||||||
|
for (let n = 0; n < repeat; n++) this.wordBack();
|
||||||
|
break;
|
||||||
|
case '0':
|
||||||
|
this.startOfLine();
|
||||||
|
break;
|
||||||
|
case '$':
|
||||||
|
this.endOfLine();
|
||||||
|
break;
|
||||||
|
case 'G':
|
||||||
|
this.endOfDocument();
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Editing — repeatable
|
||||||
|
case 'x':
|
||||||
|
for (let n = 0; n < repeat; n++) this.deleteChar();
|
||||||
|
break;
|
||||||
|
case 'u':
|
||||||
|
for (let n = 0; n < repeat; n++) document.execCommand('undo');
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Pending commands — count preserved for the second key
|
||||||
|
case 'd':
|
||||||
|
case 'g':
|
||||||
|
this.pending = key;
|
||||||
|
// Restore count so it's available for the pending handler
|
||||||
|
if (repeat > 1) {
|
||||||
|
this.count = String(repeat);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePending(combo: string): void {
|
||||||
|
switch (combo) {
|
||||||
|
case 'dd':
|
||||||
|
this.deleteLine();
|
||||||
|
break;
|
||||||
|
case 'gg':
|
||||||
|
this.startOfDocument();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveCursor(direction: 'left' | 'right' | 'up' | 'down'): void {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel) return;
|
||||||
|
sel.modify('move', direction === 'left' || direction === 'up' ? 'backward' : 'forward',
|
||||||
|
direction === 'up' || direction === 'down' ? 'line' : 'character');
|
||||||
|
}
|
||||||
|
|
||||||
|
private wordForward(): void {
|
||||||
|
window.getSelection()?.modify('move', 'forward', 'word');
|
||||||
|
}
|
||||||
|
|
||||||
|
private wordBack(): void {
|
||||||
|
window.getSelection()?.modify('move', 'backward', 'word');
|
||||||
|
}
|
||||||
|
|
||||||
|
private startOfLine(): void {
|
||||||
|
window.getSelection()?.modify('move', 'backward', 'lineboundary');
|
||||||
|
}
|
||||||
|
|
||||||
|
private endOfLine(): void {
|
||||||
|
window.getSelection()?.modify('move', 'forward', 'lineboundary');
|
||||||
|
}
|
||||||
|
|
||||||
|
private startOfDocument(): void {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || !this.element) return;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(this.element, 0);
|
||||||
|
range.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
private endOfDocument(): void {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || !this.element) return;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(this.element);
|
||||||
|
range.collapse(false);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteChar(): void {
|
||||||
|
document.execCommand('forwardDelete');
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteLine(): void {
|
||||||
|
this.startOfLine();
|
||||||
|
window.getSelection()?.modify('extend', 'forward', 'lineboundary');
|
||||||
|
document.execCommand('delete');
|
||||||
|
// Delete the newline too
|
||||||
|
document.execCommand('forwardDelete');
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertNewline(): void {
|
||||||
|
document.execCommand('insertLineBreak');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -199,17 +199,6 @@ describe('ThemeManager', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('RibbitPlugin', () => {
|
|
||||||
it('has defaults', () => {
|
|
||||||
resetDOM();
|
|
||||||
const viewer = new r.Viewer({});
|
|
||||||
const plugin = new r.Plugin({ name: 'test', wiki: viewer });
|
|
||||||
expect(plugin.name).toBe('test');
|
|
||||||
expect(plugin.precedence).toBe(50);
|
|
||||||
expect(plugin.toMarkdown('<b>x</b>')).toBe('<b>x</b>');
|
|
||||||
expect(plugin.toHTML('**x**')).toBe('**x**');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('defaultTheme', () => {
|
describe('defaultTheme', () => {
|
||||||
it('has correct shape', () => {
|
it('has correct shape', () => {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ const macros = [
|
||||||
{
|
{
|
||||||
name: 'user',
|
name: 'user',
|
||||||
toHTML: () => '<a href="/user">TestUser</a>',
|
toHTML: () => '<a href="/user">TestUser</a>',
|
||||||
selector: 'A[href="/user"]',
|
|
||||||
toMarkdown: () => '@user',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'npc',
|
name: 'npc',
|
||||||
|
|
@ -15,14 +13,10 @@ const macros = [
|
||||||
const name = keywords.join(' ');
|
const name = keywords.join(' ');
|
||||||
return '<a href="/NPC/' + name.replace(/ /g, '') + '">' + name + '</a>';
|
return '<a href="/NPC/' + name.replace(/ /g, '') + '">' + name + '</a>';
|
||||||
},
|
},
|
||||||
selector: 'A[href^="/NPC/"]',
|
|
||||||
toMarkdown: (el: any) => '@npc(' + el.textContent + ')',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'style',
|
name: 'style',
|
||||||
toHTML: ({ keywords, content }: any) => '<div class="' + keywords.join(' ') + '">' + (content || '') + '</div>',
|
toHTML: ({ keywords, content }: any) => '<div class="' + keywords.join(' ') + '">' + (content || '') + '</div>',
|
||||||
selector: 'DIV[class]',
|
|
||||||
toMarkdown: (el: any, convert: any) => '\n\n@style(' + el.className + '\n' + convert.children(el) + '\n)\n\n',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'toc',
|
name: 'toc',
|
||||||
|
|
@ -36,10 +30,12 @@ const M = (html: string) => h.toMarkdown(html);
|
||||||
|
|
||||||
describe('Macros', () => {
|
describe('Macros', () => {
|
||||||
describe('self-closing', () => {
|
describe('self-closing', () => {
|
||||||
it('bare name', () => expect(H('hello @user world')).toBe('<p>hello <a href="/user">TestUser</a> world</p>'));
|
it('bare name renders', () => expect(H('hello @user world')).toContain('<a href="/user">TestUser</a>'));
|
||||||
it('empty parens', () => expect(H('hello @user() world')).toBe('<p>hello <a href="/user">TestUser</a> world</p>'));
|
it('bare name wrapped', () => expect(H('hello @user world')).toContain('data-macro="user"'));
|
||||||
it('keywords', () => expect(H('@npc(Goblin King)')).toBe('<p><a href="/NPC/GoblinKing">Goblin King</a></p>'));
|
it('empty parens', () => expect(H('hello @user() world')).toContain('data-macro="user"'));
|
||||||
it('params', () => expect(H('@toc(depth="2")')).toContain('data-depth="2"'));
|
it('keywords', () => expect(H('@npc(Goblin King)')).toContain('Goblin King'));
|
||||||
|
it('keywords in data attr', () => expect(H('@npc(Goblin King)')).toContain('data-keywords="Goblin King"'));
|
||||||
|
it('params', () => expect(H('@toc(depth="2")')).toContain('data-param-depth="2"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('unknown macros', () => {
|
describe('unknown macros', () => {
|
||||||
|
|
@ -52,8 +48,8 @@ describe('Macros', () => {
|
||||||
|
|
||||||
describe('block macros', () => {
|
describe('block macros', () => {
|
||||||
it('content processed', () => expect(H('@style(box\n**bold**\n)')).toContain('<strong>bold</strong>'));
|
it('content processed', () => expect(H('@style(box\n**bold**\n)')).toContain('<strong>bold</strong>'));
|
||||||
it('wraps in div', () => expect(H('@style(box\ncontent\n)')).toContain('<div class="box">'));
|
it('wrapped with data-macro', () => expect(H('@style(box\ncontent\n)')).toContain('data-macro="style"'));
|
||||||
it('multiple keywords', () => expect(H('@style(box center\ncontent\n)')).toContain('class="box center"'));
|
it('keywords in data attr', () => expect(H('@style(box center\ncontent\n)')).toContain('data-keywords="box center"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('verbatim', () => {
|
describe('verbatim', () => {
|
||||||
|
|
@ -61,23 +57,41 @@ describe('Macros', () => {
|
||||||
it('no strong tag', () => expect(H('@style(box verbatim\n**bold**\n)')).not.toContain('<strong>'));
|
it('no strong tag', () => expect(H('@style(box verbatim\n**bold**\n)')).not.toContain('<strong>'));
|
||||||
it('escapes html', () => expect(H('@style(box verbatim\n<b>tag</b>\n)')).toContain('<b>'));
|
it('escapes html', () => expect(H('@style(box verbatim\n<b>tag</b>\n)')).toContain('<b>'));
|
||||||
it('preserves newlines', () => expect(H('@style(box verbatim\nline1\nline2\n)')).toContain('line1<br>'));
|
it('preserves newlines', () => expect(H('@style(box verbatim\nline1\nline2\n)')).toContain('line1<br>'));
|
||||||
it('strips keyword', () => expect(H('@style(box verbatim\ncontent\n)')).not.toContain('verbatim'));
|
it('data-verbatim set', () => expect(H('@style(box verbatim\ncontent\n)')).toContain('data-verbatim="true"'));
|
||||||
|
it('keyword stripped from data-keywords', () => {
|
||||||
|
const html = H('@style(box verbatim\ncontent\n)');
|
||||||
|
expect(html).toContain('data-keywords="box"');
|
||||||
|
expect(html).not.toMatch(/data-keywords="[^"]*verbatim/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('nesting', () => {
|
describe('nesting', () => {
|
||||||
it('inline inside bold', () => expect(H('**@npc(Goblin King)**')).toContain('<strong><a href="/NPC/GoblinKing">'));
|
it('inline inside bold', () => expect(H('**@npc(Goblin King)**')).toContain('<strong>'));
|
||||||
it('block contains list', () => expect(H('@style(box\n- item 1\n- item 2\n)')).toContain('<ul>'));
|
it('block contains list', () => expect(H('@style(box\n- item 1\n- item 2\n)')).toContain('<ul>'));
|
||||||
it('inline inside block', () => expect(H('@style(box\nhello @user world\n)')).toContain('<a href="/user">TestUser</a>'));
|
it('inline inside block', () => expect(H('@style(box\nhello @user world\n)')).toContain('data-macro="user"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fenced code protection', () => {
|
describe('fenced code protection', () => {
|
||||||
it('not in code block', () => expect(H('```\n@user\n```')).not.toContain('<a href="/user">'));
|
it('not in code block', () => expect(H('```\n@user\n```')).not.toContain('data-macro'));
|
||||||
it('literal in code block', () => expect(H('```\n@user\n```')).toContain('@user'));
|
it('literal in code block', () => expect(H('```\n@user\n```')).toContain('@user'));
|
||||||
it('not in inline code', () => expect(H('`@user`')).not.toContain('<a href="/user">'));
|
it('not in inline code', () => expect(H('`@user`')).not.toContain('data-macro'));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('round-trips', () => {
|
describe('generic round-trip via data- attributes', () => {
|
||||||
it('npc', () => expect(M(H('@npc(Goblin King)'))).toBe('@npc(Goblin King)'));
|
it('inline macro', () => expect(M(H('hello @user world'))).toBe('hello @user world'));
|
||||||
it('user', () => expect(M(H('hello @user world'))).toBe('hello @user world'));
|
it('inline with keywords', () => expect(M(H('@npc(Goblin King)'))).toBe('@npc(Goblin King)'));
|
||||||
|
it('inline with params', () => expect(M(H('@toc(depth="2")'))).toBe('@toc(depth="2")'));
|
||||||
|
it('block macro', () => {
|
||||||
|
const md = '@style(box\n**bold** content\n)';
|
||||||
|
const result = M(H(md)).trim();
|
||||||
|
expect(result).toContain('@style(box');
|
||||||
|
expect(result).toContain('**bold** content');
|
||||||
|
expect(result).toContain(')');
|
||||||
|
});
|
||||||
|
it('verbatim round-trip preserves keyword', () => {
|
||||||
|
const md = '@style(box verbatim\n<b>literal</b>\n)';
|
||||||
|
const result = M(H(md)).trim();
|
||||||
|
expect(result).toContain('@style(box verbatim');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,10 @@ export function getWindow(): any {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ribbit(): any {
|
export function ribbit(): any {
|
||||||
return getWindow().ribbit;
|
const w = getWindow();
|
||||||
|
const r = w.ribbit;
|
||||||
|
r.window = w;
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetDOM(content = 'test'): void {
|
export function resetDOM(content = 'test'): void {
|
||||||
|
|
|
||||||
326
test/toolbar.test.ts
Normal file
326
test/toolbar.test.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
|
const r = ribbit();
|
||||||
|
|
||||||
|
describe('ToolbarManager', () => {
|
||||||
|
beforeEach(() => resetDOM('**bold** text'));
|
||||||
|
|
||||||
|
describe('button registration', () => {
|
||||||
|
it('registers tag buttons', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('bold')).toBeDefined();
|
||||||
|
expect(editor.toolbar.buttons.get('italic')).toBeDefined();
|
||||||
|
expect(editor.toolbar.buttons.get('code')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers editor actions', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('save')).toBeDefined();
|
||||||
|
expect(editor.toolbar.buttons.get('toggle')).toBeDefined();
|
||||||
|
expect(editor.toolbar.buttons.get('markdown')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers macro buttons', () => {
|
||||||
|
const editor = new r.Editor({
|
||||||
|
macros: [{ name: 'user', toHTML: () => 'u' }],
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('macro:user')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips macros with button: false', () => {
|
||||||
|
const editor = new r.Editor({
|
||||||
|
macros: [{ name: 'hidden', toHTML: () => '', button: false }],
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('macro:hidden')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips tags without button', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('paragraph')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('button properties', () => {
|
||||||
|
it('bold has correct label and shortcut', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
const bold = editor.toolbar.buttons.get('bold')!;
|
||||||
|
expect(bold.label).toBe('Bold');
|
||||||
|
expect(bold.shortcut).toBe('Ctrl+B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bold action is wrap', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('bold')!.action).toBe('wrap');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save action is custom', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('save')!.action).toBe('custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('table has template', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
const table = editor.toolbar.buttons.get('table')!;
|
||||||
|
expect(table.template).toContain('Header');
|
||||||
|
expect(table.replaceSelection).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('macro button has insert action', () => {
|
||||||
|
const editor = new r.Editor({
|
||||||
|
macros: [{ name: 'toc', toHTML: () => '' }],
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
const btn = editor.toolbar.buttons.get('macro:toc')!;
|
||||||
|
expect(btn.action).toBe('insert');
|
||||||
|
expect(btn.template).toBe('@toc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('button.hide() and button.show()', () => {
|
||||||
|
it('hide sets visible false', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
const bold = editor.toolbar.buttons.get('bold')!;
|
||||||
|
expect(bold.visible).toBe(true);
|
||||||
|
bold.hide();
|
||||||
|
expect(bold.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show restores visible', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
const bold = editor.toolbar.buttons.get('bold')!;
|
||||||
|
bold.hide();
|
||||||
|
bold.show();
|
||||||
|
expect(bold.visible).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('render()', () => {
|
||||||
|
it('returns an HTMLElement', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const el = editor.toolbar.render();
|
||||||
|
expect(el.tagName).toBe('NAV');
|
||||||
|
expect(el.className).toBe('ribbit-toolbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains buttons', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const el = editor.toolbar.render();
|
||||||
|
expect(el.querySelector('.ribbit-btn-bold')).not.toBeNull();
|
||||||
|
expect(el.querySelector('.ribbit-btn-save')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buttons have aria-label', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const el = editor.toolbar.render();
|
||||||
|
const bold = el.querySelector('.ribbit-btn-bold');
|
||||||
|
expect(bold?.getAttribute('aria-label')).toBe('Bold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buttons have title with shortcut', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const el = editor.toolbar.render();
|
||||||
|
const bold = el.querySelector('.ribbit-btn-bold');
|
||||||
|
expect(bold?.getAttribute('title')).toBe('Bold (Ctrl+B)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders spacers', () => {
|
||||||
|
const editor = new r.Editor({
|
||||||
|
autoToolbar: false,
|
||||||
|
toolbar: ['bold', '', 'save'],
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
const el = editor.toolbar.render();
|
||||||
|
expect(el.querySelector('.spacer')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders dropdown groups', () => {
|
||||||
|
const editor = new r.Editor({
|
||||||
|
autoToolbar: false,
|
||||||
|
toolbar: [{ group: 'Test', items: ['bold', 'italic'] }],
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
const el = editor.toolbar.render();
|
||||||
|
expect(el.querySelector('.ribbit-dropdown')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('auto-render', () => {
|
||||||
|
it('inserts toolbar before editor by default', () => {
|
||||||
|
resetDOM();
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
const toolbar = editor.element.previousElementSibling;
|
||||||
|
expect(toolbar?.className).toBe('ribbit-toolbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not insert when autoToolbar is false', () => {
|
||||||
|
resetDOM();
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const toolbar = editor.element.previousElementSibling;
|
||||||
|
expect(toolbar?.className || '').not.toBe('ribbit-toolbar');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('custom layout', () => {
|
||||||
|
it('respects custom toolbar order', () => {
|
||||||
|
const editor = new r.Editor({
|
||||||
|
autoToolbar: false,
|
||||||
|
toolbar: ['save', 'bold'],
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
const el = editor.toolbar.render();
|
||||||
|
const buttons = el.querySelectorAll('button');
|
||||||
|
expect(buttons[0]?.className).toBe('ribbit-btn-save');
|
||||||
|
expect(buttons[1]?.className).toBe('ribbit-btn-bold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-generates layout when not specified', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const el = editor.toolbar.render();
|
||||||
|
expect(el.querySelectorAll('button').length).toBeGreaterThan(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enable/disable', () => {
|
||||||
|
it('disable adds disabled class', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const el = editor.toolbar.render();
|
||||||
|
editor.toolbar.disable();
|
||||||
|
const bold = el.querySelector('.ribbit-btn-bold');
|
||||||
|
expect(bold?.classList.contains('disabled')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enable removes disabled class', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const el = editor.toolbar.render();
|
||||||
|
editor.toolbar.disable();
|
||||||
|
editor.toolbar.enable();
|
||||||
|
const bold = el.querySelector('.ribbit-btn-bold');
|
||||||
|
expect(bold?.classList.contains('disabled')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateActiveState', () => {
|
||||||
|
it('sets active class on matching buttons', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
editor.toolbar.render();
|
||||||
|
editor.toolbar.updateActiveState(['bold']);
|
||||||
|
expect(editor.toolbar.buttons.get('bold')!.element?.classList.contains('active')).toBe(true);
|
||||||
|
expect(editor.toolbar.buttons.get('italic')!.element?.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears active when not in list', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
editor.toolbar.render();
|
||||||
|
editor.toolbar.updateActiveState(['bold']);
|
||||||
|
editor.toolbar.updateActiveState([]);
|
||||||
|
expect(editor.toolbar.buttons.get('bold')!.element?.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('heading and list buttons', () => {
|
||||||
|
it('registers h1-h6', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
for (let i = 1; i <= 6; i++) {
|
||||||
|
const btn = editor.toolbar.buttons.get(`h${i}`);
|
||||||
|
expect(btn).toBeDefined();
|
||||||
|
expect(btn!.label).toBe(`H${i}`);
|
||||||
|
expect(btn!.shortcut).toBe(`Ctrl+${i}`);
|
||||||
|
expect(btn!.action).toBe('prefix');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers ul and ol', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('ul')!.shortcut).toBe('Ctrl+Shift+8');
|
||||||
|
expect(editor.toolbar.buttons.get('ol')!.shortcut).toBe('Ctrl+Shift+7');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keyboard shortcuts', () => {
|
||||||
|
it('all formatting buttons have shortcuts', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const expected = ['bold', 'italic', 'code', 'link', 'save'];
|
||||||
|
for (const id of expected) {
|
||||||
|
expect(editor.toolbar.buttons.get(id)!.shortcut).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('block buttons have shortcuts', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('fencedCode')!.shortcut).toBe('Ctrl+Shift+E');
|
||||||
|
expect(editor.toolbar.buttons.get('blockquote')!.shortcut).toBe('Ctrl+Shift+.');
|
||||||
|
expect(editor.toolbar.buttons.get('table')!.shortcut).toBe('Ctrl+Shift+T');
|
||||||
|
expect(editor.toolbar.buttons.get('hr')!.shortcut).toBe('Ctrl+Shift+-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('editor actions have shortcuts', () => {
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('toggle')!.shortcut).toBe('Ctrl+Shift+V');
|
||||||
|
expect(editor.toolbar.buttons.get('markdown')!.shortcut).toBe('Ctrl+/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('save button', () => {
|
||||||
|
it('triggers editor.save()', () => {
|
||||||
|
resetDOM();
|
||||||
|
let saved = false;
|
||||||
|
const editor = new r.Editor({
|
||||||
|
autoToolbar: false,
|
||||||
|
on: { save: () => { saved = true; } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.toolbar.render();
|
||||||
|
editor.toolbar.buttons.get('save')!.click();
|
||||||
|
expect(saved).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggle button', () => {
|
||||||
|
it('switches from view to wysiwyg', () => {
|
||||||
|
resetDOM();
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
editor.toolbar.render();
|
||||||
|
expect(editor.getState()).toBe('view');
|
||||||
|
editor.toolbar.buttons.get('toggle')!.click();
|
||||||
|
expect(editor.getState()).toBe('wysiwyg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches from wysiwyg to view', () => {
|
||||||
|
resetDOM();
|
||||||
|
const editor = new r.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
editor.toolbar.render();
|
||||||
|
editor.toolbar.buttons.get('toggle')!.click();
|
||||||
|
expect(editor.getState()).toBe('view');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
78
test/vim.test.ts
Normal file
78
test/vim.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
|
const r = ribbit();
|
||||||
|
|
||||||
|
describe('VimHandler', () => {
|
||||||
|
beforeEach(() => resetDOM('hello world'));
|
||||||
|
|
||||||
|
it('starts in insert mode', () => {
|
||||||
|
const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||||
|
editor.run();
|
||||||
|
editor.edit();
|
||||||
|
expect(editor.element.classList.contains('vim-insert')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Esc enters normal mode', () => {
|
||||||
|
const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||||
|
editor.run();
|
||||||
|
editor.edit();
|
||||||
|
editor.element.dispatchEvent(new r.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 r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||||
|
editor.run();
|
||||||
|
editor.edit();
|
||||||
|
// Enter normal mode
|
||||||
|
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
// Back to insert
|
||||||
|
editor.element.dispatchEvent(new r.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 r.Editor({ autoToolbar: false, currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||||
|
editor.run();
|
||||||
|
editor.toolbar.render();
|
||||||
|
editor.edit();
|
||||||
|
editor.toolbar.enable();
|
||||||
|
editor.element.dispatchEvent(new r.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 r.Editor({ autoToolbar: false, currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||||
|
editor.run();
|
||||||
|
editor.toolbar.render();
|
||||||
|
editor.edit();
|
||||||
|
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
editor.element.dispatchEvent(new r.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 r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||||
|
editor.run();
|
||||||
|
editor.edit();
|
||||||
|
editor.element.dispatchEvent(new r.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 r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
// Esc in wysiwyg should not add vim classes
|
||||||
|
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
expect(editor.element.classList.contains('vim-normal')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user