Reimplement as a tokenizer with GFM parity
This commit is contained in:
parent
005db2f431
commit
d41716c8b2
118
TOKENIZER_DESIGN.md
Normal file
118
TOKENIZER_DESIGN.md
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
# HopDown Tokenizer Design
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The regex-based inline parser and serializer can't reliably distinguish
|
||||||
|
structural delimiters from literal text characters. This causes:
|
||||||
|
- `toMarkdown` escaping bugs (over-escaping inside inline tags, under-escaping
|
||||||
|
in text nodes)
|
||||||
|
- Round-trip failures (`toHTML(toMarkdown(html)) !== html`)
|
||||||
|
- Fragile interactions between features (underscore normalization + strikethrough,
|
||||||
|
HTML passthrough + escaping)
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
1. `toHTML` satisfies GFM spec rules 1-15
|
||||||
|
2. `toMarkdown` always emits the canonical form
|
||||||
|
3. `toHTML(toMarkdown(html)) === html` (single-pass round-trip)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Token types
|
||||||
|
|
||||||
|
```
|
||||||
|
text — literal characters, will be escaped during serialization
|
||||||
|
delimiter — structural marker (**, *, ~~, `, etc.)
|
||||||
|
html — raw HTML tag passthrough
|
||||||
|
break — hard line break (<br>)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline tokenizer (markdown → tokens)
|
||||||
|
|
||||||
|
Scans left-to-right, character by character. Maintains a stack of open
|
||||||
|
delimiters. Produces a flat token stream:
|
||||||
|
|
||||||
|
```
|
||||||
|
Input: "hello **bold *nested*** end"
|
||||||
|
Tokens: [text "hello "] [open **] [text "bold "] [open *] [text "nested"] [close *] [close **] [text " end"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The tokenizer handles:
|
||||||
|
- Backslash escapes: `\*` → text token containing `*`
|
||||||
|
- Entity resolution: `&` → text token containing `&`
|
||||||
|
- Flanking rules: only emit delimiter tokens when flanking conditions are met
|
||||||
|
- Code spans: `` ` `` opens a code span that consumes everything until the matching `` ` ``
|
||||||
|
- Links: `[text](url)` parsed as a unit
|
||||||
|
- Autolinks: `<url>` and bare URLs
|
||||||
|
- Hard line breaks: trailing spaces or `\` before newline
|
||||||
|
- HTML tags: `<span>` etc. passed through as html tokens
|
||||||
|
|
||||||
|
### Inline parser (tokens → HTML)
|
||||||
|
|
||||||
|
Walks the token stream and matches open/close delimiter pairs using a
|
||||||
|
stack. Produces HTML string. Handles:
|
||||||
|
- Delimiter pairing with precedence (*** before ** before *)
|
||||||
|
- Multiple-of-3 rule
|
||||||
|
- Nesting validation (no em inside em, no links inside links)
|
||||||
|
|
||||||
|
### Serializer (DOM → tokens → markdown)
|
||||||
|
|
||||||
|
Walks the DOM tree. For each node:
|
||||||
|
- Text nodes → text tokens (the serializer knows these need escaping)
|
||||||
|
- Element nodes → look up the tag, emit delimiter tokens + recurse into children
|
||||||
|
- Unknown elements → recurse into children
|
||||||
|
|
||||||
|
Then the token stream is serialized to a string:
|
||||||
|
- Delimiter tokens → emitted verbatim (they're structural)
|
||||||
|
- Text tokens → characters that would be misinterpreted as delimiters are
|
||||||
|
backslash-escaped. The serializer knows exactly which characters are
|
||||||
|
dangerous because it knows what delimiters exist.
|
||||||
|
- HTML tokens → emitted verbatim
|
||||||
|
|
||||||
|
### Why this solves the round-trip problem
|
||||||
|
|
||||||
|
The key insight: delimiter tokens and text tokens are different types.
|
||||||
|
When serializing `<strong>hello *world*</strong>`, the output is:
|
||||||
|
|
||||||
|
```
|
||||||
|
[delim **] [text "hello "] [delim *] [text "world"] [delim *] [delim **]
|
||||||
|
```
|
||||||
|
|
||||||
|
The `*` around "world" are delimiter tokens (from the nested `<em>`).
|
||||||
|
If instead the text contained a literal `*`:
|
||||||
|
|
||||||
|
```
|
||||||
|
<strong>hello * world</strong>
|
||||||
|
```
|
||||||
|
|
||||||
|
The output would be:
|
||||||
|
|
||||||
|
```
|
||||||
|
[delim **] [text "hello * world"] [delim **]
|
||||||
|
```
|
||||||
|
|
||||||
|
The `*` is a text token. During serialization, the text token scanner
|
||||||
|
sees `*` and escapes it to `\*` because `*` is a known delimiter character.
|
||||||
|
The delimiter tokens are never escaped. No ambiguity.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `types.ts` — Token type, updated Tag interface
|
||||||
|
- `tokenizer.ts` — Inline tokenizer (markdown → tokens)
|
||||||
|
- `serializer.ts` — DOM → tokens → markdown string
|
||||||
|
- `hopdown.ts` — Orchestrator (block parsing, delegates inline to tokenizer)
|
||||||
|
- `tags.ts` — Tag definitions (simplified: no more regex patterns)
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
The Tag interface changes:
|
||||||
|
- `pattern` field removed (tokenizer handles delimiter matching)
|
||||||
|
- `toMarkdown` returns Token[] instead of string
|
||||||
|
- `match` stays the same (block-level matching is already clean)
|
||||||
|
- `toHTML` stays the same
|
||||||
|
|
||||||
|
The HopDown public API stays the same:
|
||||||
|
- `toHTML(markdown)` — unchanged
|
||||||
|
- `toMarkdown(html)` — unchanged
|
||||||
|
- `findCompletePair`, `findUnmatchedOpener` — reimplemented on tokenizer
|
||||||
|
- `getTagForElement`, `getEditableSelector` — unchanged
|
||||||
|
|
@ -11,7 +11,7 @@ module.exports = {
|
||||||
'^.+\\.tsx?$': ['ts-jest', {
|
'^.+\\.tsx?$': ['ts-jest', {
|
||||||
tsconfig: {
|
tsconfig: {
|
||||||
strict: true,
|
strict: true,
|
||||||
target: 'ES2017',
|
target: 'ES2018',
|
||||||
module: 'CommonJS',
|
module: 'CommonJS',
|
||||||
moduleResolution: 'node',
|
moduleResolution: 'node',
|
||||||
esModuleInterop: true,
|
esModuleInterop: true,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,38 @@ import type {
|
||||||
CollaborationSettings, RevisionProvider, Revision, RevisionMetadata,
|
CollaborationSettings, RevisionProvider, Revision, RevisionMetadata,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
/** Milliseconds to buffer rapid remote updates before applying the latest. */
|
||||||
|
const THROTTLE_DELAY_MS = 150;
|
||||||
|
|
||||||
|
/** Default milliseconds before a peer is considered idle. */
|
||||||
|
const DEFAULT_IDLE_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
/** Peer status values used in presence tracking. */
|
||||||
|
const PEER_STATUS = {
|
||||||
|
ACTIVE: 'active' as const,
|
||||||
|
EDITING: 'editing' as const,
|
||||||
|
IDLE: 'idle' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Auto-revision metadata when saving remote state before source mode merge. */
|
||||||
|
const AUTO_REVISION_AUTHOR = 'auto';
|
||||||
|
const AUTO_REVISION_SUMMARY = 'Auto-saved before source mode merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages real-time collaboration for a ribbit editor instance.
|
||||||
|
*
|
||||||
|
* Handles document sync, peer presence, document locking, and
|
||||||
|
* revision management through consumer-provided transport interfaces.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const collab = new CollaborationManager(settings, {
|
||||||
|
* onRemoteUpdate: (content) => editor.setContent(content),
|
||||||
|
* onPeersChange: (peers) => updateUserList(peers),
|
||||||
|
* onLockChange: (holder) => updateLockUI(holder),
|
||||||
|
* onRemoteActivity: (count) => showBadge(count),
|
||||||
|
* });
|
||||||
|
* collab.connect();
|
||||||
|
*/
|
||||||
export class CollaborationManager {
|
export class CollaborationManager {
|
||||||
private transport: DocumentTransport;
|
private transport: DocumentTransport;
|
||||||
private presence?: PresenceChannel;
|
private presence?: PresenceChannel;
|
||||||
|
|
@ -21,9 +53,7 @@ export class CollaborationManager {
|
||||||
private paused: boolean;
|
private paused: boolean;
|
||||||
private remoteChangeCount: number;
|
private remoteChangeCount: number;
|
||||||
private latestRemoteContent: string | null;
|
private latestRemoteContent: string | null;
|
||||||
private baseContent: string | null;
|
|
||||||
private idleTimeout: number;
|
private idleTimeout: number;
|
||||||
private idleTimer?: number;
|
|
||||||
private lockHolder: PeerInfo | null;
|
private lockHolder: PeerInfo | null;
|
||||||
private onRemoteUpdate: (content: string) => void;
|
private onRemoteUpdate: (content: string) => void;
|
||||||
private onPeersChange: (peers: PeerInfo[]) => void;
|
private onPeersChange: (peers: PeerInfo[]) => void;
|
||||||
|
|
@ -50,8 +80,7 @@ export class CollaborationManager {
|
||||||
this.paused = false;
|
this.paused = false;
|
||||||
this.remoteChangeCount = 0;
|
this.remoteChangeCount = 0;
|
||||||
this.latestRemoteContent = null;
|
this.latestRemoteContent = null;
|
||||||
this.baseContent = null;
|
this.idleTimeout = settings.idleTimeout ?? DEFAULT_IDLE_TIMEOUT_MS;
|
||||||
this.idleTimeout = settings.idleTimeout ?? 30000;
|
|
||||||
this.lockHolder = null;
|
this.lockHolder = null;
|
||||||
this.onRemoteUpdate = callbacks.onRemoteUpdate;
|
this.onRemoteUpdate = callbacks.onRemoteUpdate;
|
||||||
this.onPeersChange = callbacks.onPeersChange;
|
this.onPeersChange = callbacks.onPeersChange;
|
||||||
|
|
@ -78,16 +107,32 @@ export class CollaborationManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the transport connection and begin receiving updates.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* collab.connect();
|
||||||
|
*/
|
||||||
connect(): void {
|
connect(): void {
|
||||||
if (this.connected) return;
|
if (this.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.transport.connect();
|
this.transport.connect();
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.remoteChangeCount = 0;
|
this.remoteChangeCount = 0;
|
||||||
this.latestRemoteContent = null;
|
this.latestRemoteContent = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the transport connection and clear peer state.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* collab.disconnect();
|
||||||
|
*/
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
if (!this.connected) return;
|
if (!this.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.transport.disconnect();
|
this.transport.disconnect();
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.peers = [];
|
this.peers = [];
|
||||||
|
|
@ -95,103 +140,200 @@ export class CollaborationManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pause applying remote updates (entering source mode).
|
* Pause applying remote updates (e.g. when entering source mode).
|
||||||
* Updates are still received and counted.
|
* Updates are still received and counted so the UI can show a badge.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* collab.pause(editor.getMarkdown());
|
||||||
*/
|
*/
|
||||||
pause(currentContent: string): void {
|
pause(currentContent: string): void {
|
||||||
this.paused = true;
|
this.paused = true;
|
||||||
this.baseContent = currentContent;
|
|
||||||
this.remoteChangeCount = 0;
|
this.remoteChangeCount = 0;
|
||||||
this.latestRemoteContent = null;
|
this.latestRemoteContent = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resume applying remote updates (leaving source mode).
|
* Resume applying remote updates (e.g. when leaving source mode).
|
||||||
* If there were remote changes, creates a revision of the remote
|
* If remote changes arrived while paused, creates a revision of
|
||||||
* version before applying the local version (last-write-wins).
|
* the remote version before applying local content (last-write-wins).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await collab.resume(editor.getMarkdown());
|
||||||
*/
|
*/
|
||||||
async resume(localContent: string): Promise<void> {
|
async resume(localContent: string): Promise<void> {
|
||||||
if (this.paused && this.latestRemoteContent && this.revisions) {
|
if (this.paused && this.latestRemoteContent && this.revisions) {
|
||||||
await this.revisions.create(this.latestRemoteContent, {
|
await this.revisions.create(this.latestRemoteContent, {
|
||||||
author: 'auto',
|
author: AUTO_REVISION_AUTHOR,
|
||||||
summary: 'Auto-saved before source mode merge',
|
summary: AUTO_REVISION_SUMMARY,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.paused = false;
|
this.paused = false;
|
||||||
this.baseContent = null;
|
|
||||||
this.remoteChangeCount = 0;
|
this.remoteChangeCount = 0;
|
||||||
this.latestRemoteContent = null;
|
this.latestRemoteContent = null;
|
||||||
this.sendUpdate(localContent);
|
this.sendUpdate(localContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast local content to connected peers.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* collab.sendUpdate(editor.getMarkdown());
|
||||||
|
*/
|
||||||
sendUpdate(markdown: string): void {
|
sendUpdate(markdown: string): void {
|
||||||
if (!this.connected || this.paused) return;
|
if (!this.connected || this.paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const encoded = new TextEncoder().encode(markdown);
|
const encoded = new TextEncoder().encode(markdown);
|
||||||
this.transport.send(encoded);
|
this.transport.send(encoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast cursor position to connected peers.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* collab.sendCursor(selection.anchorOffset);
|
||||||
|
*/
|
||||||
sendCursor(position: number): void {
|
sendCursor(position: number): void {
|
||||||
if (!this.connected || !this.presence) return;
|
if (!this.connected || !this.presence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.presence.send({
|
this.presence.send({
|
||||||
...this.user,
|
...this.user,
|
||||||
status: this.paused ? 'editing' : 'active',
|
status: this.paused ? PEER_STATUS.EDITING : PEER_STATUS.ACTIVE,
|
||||||
lastActive: Date.now(),
|
lastActive: Date.now(),
|
||||||
cursor: position,
|
cursor: position,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request an exclusive document lock.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const acquired = await collab.lock();
|
||||||
|
*/
|
||||||
async lock(): Promise<boolean> {
|
async lock(): Promise<boolean> {
|
||||||
if (!this.transport.lock) return false;
|
if (!this.transport.lock) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return this.transport.lock();
|
return this.transport.lock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the document lock.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* collab.unlock();
|
||||||
|
*/
|
||||||
unlock(): void {
|
unlock(): void {
|
||||||
this.transport.unlock?.();
|
this.transport.unlock?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-acquire the lock, overriding any existing holder.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const acquired = await collab.forceLock();
|
||||||
|
*/
|
||||||
async forceLock(): Promise<boolean> {
|
async forceLock(): Promise<boolean> {
|
||||||
if (!this.transport.forceLock) return false;
|
if (!this.transport.forceLock) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return this.transport.forceLock();
|
return this.transport.forceLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the peer currently holding the document lock, or null.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const holder = collab.getLockHolder();
|
||||||
|
*/
|
||||||
getLockHolder(): PeerInfo | null {
|
getLockHolder(): PeerInfo | null {
|
||||||
return this.lockHolder;
|
return this.lockHolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the list of currently connected peers.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const peers = collab.getPeers();
|
||||||
|
*/
|
||||||
getPeers(): PeerInfo[] {
|
getPeers(): PeerInfo[] {
|
||||||
return this.peers;
|
return this.peers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the number of remote changes received while paused.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const count = collab.getRemoteChangeCount();
|
||||||
|
*/
|
||||||
getRemoteChangeCount(): number {
|
getRemoteChangeCount(): number {
|
||||||
return this.remoteChangeCount;
|
return this.remoteChangeCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the transport connection is open.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* if (collab.isConnected()) { ... }
|
||||||
|
*/
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return this.connected;
|
return this.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether remote updates are currently paused.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* if (collab.isPaused()) { ... }
|
||||||
|
*/
|
||||||
isPaused(): boolean {
|
isPaused(): boolean {
|
||||||
return this.paused;
|
return this.paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revision access — delegates to the consumer's RevisionProvider.
|
* List all stored revisions via the consumer's RevisionProvider.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const revisions = await collab.listRevisions();
|
||||||
*/
|
*/
|
||||||
async listRevisions(): Promise<Revision[]> {
|
async listRevisions(): Promise<Revision[]> {
|
||||||
if (!this.revisions) return [];
|
if (!this.revisions) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return this.revisions.list();
|
return this.revisions.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a specific revision by ID.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const revision = await collab.getRevision('abc123');
|
||||||
|
*/
|
||||||
async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
|
async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
|
||||||
if (!this.revisions) return null;
|
if (!this.revisions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return this.revisions.get(id);
|
return this.revisions.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new revision with the given content and metadata.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await collab.createRevision(markdown, { author: 'user1', summary: 'Draft' });
|
||||||
|
*/
|
||||||
async createRevision(content: string, metadata?: RevisionMetadata): Promise<Revision | null> {
|
async createRevision(content: string, metadata?: RevisionMetadata): Promise<Revision | null> {
|
||||||
if (!this.revisions) return null;
|
if (!this.revisions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return this.revisions.create(content, metadata);
|
return this.revisions.create(content, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buffers rapid remote updates and applies only the latest after
|
||||||
|
* a throttle delay. When paused, counts changes without applying.
|
||||||
|
*/
|
||||||
private handleRemoteUpdate(update: Uint8Array): void {
|
private handleRemoteUpdate(update: Uint8Array): void {
|
||||||
const content = new TextDecoder().decode(update);
|
const content = new TextDecoder().decode(update);
|
||||||
|
|
||||||
|
|
@ -203,23 +345,29 @@ export class CollaborationManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.receiveBuffer.push(update);
|
this.receiveBuffer.push(update);
|
||||||
if (this.throttleTimer !== undefined) return;
|
if (this.throttleTimer !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.throttleTimer = window.setTimeout(() => {
|
this.throttleTimer = window.setTimeout(() => {
|
||||||
this.throttleTimer = undefined;
|
this.throttleTimer = undefined;
|
||||||
if (this.receiveBuffer.length === 0) return;
|
if (this.receiveBuffer.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const latest = this.receiveBuffer[this.receiveBuffer.length - 1];
|
const latest = this.receiveBuffer[this.receiveBuffer.length - 1];
|
||||||
this.receiveBuffer = [];
|
this.receiveBuffer = [];
|
||||||
this.onRemoteUpdate(new TextDecoder().decode(latest));
|
this.onRemoteUpdate(new TextDecoder().decode(latest));
|
||||||
}, 150);
|
}, THROTTLE_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Marks peers as idle when their lastActive exceeds the timeout. */
|
||||||
private applyIdleStatus(peers: PeerInfo[]): PeerInfo[] {
|
private applyIdleStatus(peers: PeerInfo[]): PeerInfo[] {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return peers.map(peer => ({
|
return peers.map(peer => ({
|
||||||
...peer,
|
...peer,
|
||||||
status: peer.status === 'editing' ? 'editing'
|
status: peer.status === PEER_STATUS.EDITING
|
||||||
: (now - peer.lastActive > this.idleTimeout ? 'idle' : 'active'),
|
? PEER_STATUS.EDITING
|
||||||
|
: (now - peer.lastActive > this.idleTimeout ? PEER_STATUS.IDLE : PEER_STATUS.ACTIVE),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,18 @@
|
||||||
import type { RibbitTheme } from './types';
|
import type { RibbitTheme } from './types';
|
||||||
import { defaultTags } from './tags';
|
import { defaultTags } from './tags';
|
||||||
|
|
||||||
|
/** Theme name used as the built-in default across ribbit. */
|
||||||
|
const DEFAULT_THEME_NAME = 'ribbit-default';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The built-in ribbit theme. Enables all default tags and source mode.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import { defaultTheme } from './default-theme';
|
||||||
|
* const editor = new RibbitEditor({ theme: defaultTheme });
|
||||||
|
*/
|
||||||
export const defaultTheme: RibbitTheme = {
|
export const defaultTheme: RibbitTheme = {
|
||||||
name: 'ribbit-default',
|
name: DEFAULT_THEME_NAME,
|
||||||
tags: defaultTags,
|
tags: defaultTags,
|
||||||
features: {
|
features: {
|
||||||
sourceMode: true,
|
sourceMode: true,
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,14 @@ export interface RibbitEventMap {
|
||||||
|
|
||||||
type EventName = keyof RibbitEventMap;
|
type EventName = keyof RibbitEventMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed event emitter for ribbit editor lifecycle and collaboration events.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const emitter = new RibbitEmitter();
|
||||||
|
* emitter.on('change', ({ markdown }) => console.log(markdown));
|
||||||
|
* emitter.emit('change', { markdown: '# Hello', html: '<h1>Hello</h1>' });
|
||||||
|
*/
|
||||||
export class RibbitEmitter {
|
export class RibbitEmitter {
|
||||||
private listeners: Map<string, Set<Function>>;
|
private listeners: Map<string, Set<Function>>;
|
||||||
|
|
||||||
|
|
@ -122,6 +130,9 @@ export class RibbitEmitter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a callback for an event.
|
* Register a callback for an event.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* emitter.on('save', ({ markdown }) => saveDraft(markdown));
|
||||||
*/
|
*/
|
||||||
on<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
|
on<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
|
||||||
if (!this.listeners.has(event)) {
|
if (!this.listeners.has(event)) {
|
||||||
|
|
@ -132,6 +143,9 @@ export class RibbitEmitter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a previously registered callback.
|
* Remove a previously registered callback.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* emitter.off('save', savedCallback);
|
||||||
*/
|
*/
|
||||||
off<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
|
off<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
|
||||||
this.listeners.get(event)?.delete(callback);
|
this.listeners.get(event)?.delete(callback);
|
||||||
|
|
@ -139,6 +153,9 @@ export class RibbitEmitter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emit an event, calling all registered callbacks with the payload.
|
* Emit an event, calling all registered callbacks with the payload.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* emitter.emit('change', { markdown: '# Title', html: '<h1>Title</h1>' });
|
||||||
*/
|
*/
|
||||||
emit<K extends EventName>(event: K, ...args: Parameters<RibbitEventMap[K]>): void {
|
emit<K extends EventName>(event: K, ...args: Parameters<RibbitEventMap[K]>): void {
|
||||||
for (const callback of this.listeners.get(event) || []) {
|
for (const callback of this.listeners.get(event) || []) {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
/*
|
/*
|
||||||
* hopdown.ts — configurable markdown↔HTML converter.
|
* hopdown.ts — configurable markdown↔HTML converter.
|
||||||
*
|
*
|
||||||
* Usage:
|
* HopDown orchestrates markdown↔HTML conversion using a tokenizer for
|
||||||
* const converter = new HopDown();
|
* inline parsing and a serializer for HTML→markdown. Block-level parsing
|
||||||
* const converter = new HopDown({ exclude: ['table'] });
|
* uses Tag definitions directly. The tokenizer/serializer architecture
|
||||||
* const converter = new HopDown({ tags: { ...defaultTags, 'DEL,S,STRIKE': strikethrough } });
|
* ensures correct round-trips by separating structural delimiters from
|
||||||
*
|
* literal text at the type level.
|
||||||
* converter.toHTML('**bold**');
|
|
||||||
* converter.toMarkdown('<strong>bold</strong>');
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Converter, MatchContext, Tag } from './types';
|
import type { Converter, MatchContext, Tag, DelimiterMatch } from './types';
|
||||||
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } from './tags';
|
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml } from './tags';
|
||||||
import { buildMacroTags, processInlineMacros, type MacroDef } from './macros';
|
import { buildMacroTags, processInlineMacros, type MacroDef } from './macros';
|
||||||
|
import { InlineTokenizer, type InlineToken, type DelimiterDef } from './tokenizer';
|
||||||
|
import { MarkdownSerializer, type SerializerTagDef } from './serializer';
|
||||||
|
|
||||||
export type TagMap = Record<string, Tag>;
|
export type TagMap = Record<string, Tag>;
|
||||||
|
|
||||||
|
|
@ -23,17 +23,25 @@ export interface HopDownOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A configurable markdown↔HTML converter.
|
* Configurable markdown↔HTML converter. Uses a tokenizer for inline
|
||||||
|
* parsing (markdown→HTML) and a serializer for HTML→markdown. Block
|
||||||
|
* parsing delegates to Tag definitions.
|
||||||
*
|
*
|
||||||
* By default includes all standard tags. Pass options to customize:
|
* const converter = new HopDown();
|
||||||
* - tags: a mapping of HTML selectors to Tag definitions
|
* converter.toHTML('**bold**');
|
||||||
* - exclude: remove specific tags by name from the defaults
|
* converter.toMarkdown('<strong>bold</strong>');
|
||||||
*/
|
*/
|
||||||
export class HopDown {
|
export class HopDown {
|
||||||
private blockTags: Tag[];
|
private blockTags: Tag[];
|
||||||
private inlineTags: Tag[];
|
private inlineTags: Tag[];
|
||||||
private tags: Map<string, Tag>;
|
private tags: Map<string, Tag>;
|
||||||
private macroMap: Map<string, MacroDef>;
|
private macroMap: Map<string, MacroDef>;
|
||||||
|
private referenceLinks: Map<string, { url: string; title?: string }>;
|
||||||
|
private tokenizer: InlineTokenizer;
|
||||||
|
private serializer: MarkdownSerializer;
|
||||||
|
private cachedConverter: Converter;
|
||||||
|
private delimiterRegexes: { tag: Tag; htmlTag: string; complete: RegExp; open: RegExp }[];
|
||||||
|
private editableSelectorCache: string;
|
||||||
|
|
||||||
constructor(options: HopDownOptions = {}) {
|
constructor(options: HopDownOptions = {}) {
|
||||||
let tagMap: TagMap;
|
let tagMap: TagMap;
|
||||||
|
|
@ -49,8 +57,8 @@ export class HopDown {
|
||||||
tagMap = defaultTags;
|
tagMap = defaultTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build macro tags if macros are provided
|
|
||||||
this.macroMap = new Map();
|
this.macroMap = new Map();
|
||||||
|
this.referenceLinks = new Map();
|
||||||
if (options.macros && options.macros.length > 0) {
|
if (options.macros && options.macros.length > 0) {
|
||||||
const { blockTag, selectorTag, macroMap } = buildMacroTags(options.macros);
|
const { blockTag, selectorTag, macroMap } = buildMacroTags(options.macros);
|
||||||
this.macroMap = macroMap;
|
this.macroMap = macroMap;
|
||||||
|
|
@ -59,20 +67,27 @@ export class HopDown {
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTags = Object.values(tagMap);
|
const allTags = Object.values(tagMap);
|
||||||
const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(t => t.name));
|
const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(tag => tag.name));
|
||||||
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(t => t.name));
|
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(tag => tag.name));
|
||||||
|
|
||||||
this.blockTags = allTags.filter(tag =>
|
this.blockTags = allTags.filter(tag =>
|
||||||
defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
|
defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
|
||||||
(!defaultInlineNames.has(tag.name) && !tag.pattern)
|
(!defaultInlineNames.has(tag.name) && !tag.pattern)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure macro block tag runs after fencedCode but before everything else
|
// Macro block tag must run after fencedCode (so code blocks aren't
|
||||||
|
// parsed as macros) but before paragraph (the catch-all)
|
||||||
this.blockTags.sort((a, b) => {
|
this.blockTags.sort((a, b) => {
|
||||||
const order = (t: Tag) => {
|
const order = (tag: Tag) => {
|
||||||
if (t.name === 'fencedCode') return 0;
|
if (tag.name === 'fencedCode') {
|
||||||
if (t.name === 'macro') return 1;
|
return 0;
|
||||||
if (t.name === 'paragraph') return 99;
|
}
|
||||||
|
if (tag.name === 'macro') {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (tag.name === 'paragraph') {
|
||||||
|
return 99;
|
||||||
|
}
|
||||||
return 50;
|
return 50;
|
||||||
};
|
};
|
||||||
return order(a) - order(b);
|
return order(a) - order(b);
|
||||||
|
|
@ -83,30 +98,35 @@ export class HopDown {
|
||||||
);
|
);
|
||||||
|
|
||||||
this.tags = new Map();
|
this.tags = new Map();
|
||||||
|
this.registerSelectors(tagMap);
|
||||||
|
this.validateInlineTags();
|
||||||
|
|
||||||
|
this.tokenizer = this.buildTokenizer();
|
||||||
|
this.serializer = this.buildSerializer();
|
||||||
|
this.cachedConverter = this.makeConverter();
|
||||||
|
this.delimiterRegexes = this.buildDelimiterRegexes();
|
||||||
|
this.editableSelectorCache = this.buildEditableSelector();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerSelectors(tagMap: TagMap): void {
|
||||||
for (const [selector, tag] of Object.entries(tagMap)) {
|
for (const [selector, tag] of Object.entries(tagMap)) {
|
||||||
for (const sel of selector.split(',').map(s => s.trim()).filter(Boolean)) {
|
const parts = selector.split(',').map(part => part.trim()).filter(Boolean);
|
||||||
if (sel.startsWith('_')) {
|
for (const part of parts) {
|
||||||
|
if (part.startsWith('_')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const existing = this.tags.get(sel);
|
const existing = this.tags.get(part);
|
||||||
if (existing && existing !== tag) {
|
if (existing && existing !== tag) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`HTML tag "${sel}" is claimed by both "${existing.name}" and "${tag.name}". ` +
|
`HTML tag "${part}" is claimed by both "${existing.name}" and "${tag.name}". ` +
|
||||||
`Use the exclude option to remove one before adding the other.`
|
`Use the exclude option to remove one before adding the other.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.tags.set(sel, tag);
|
this.tags.set(part, tag);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.validateInlineTags();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify that no two inline tags have colliding delimiters without
|
|
||||||
* correct precedence ordering. If delimiter A is a prefix of delimiter B,
|
|
||||||
* B must have lower (earlier) precedence so the longer match wins.
|
|
||||||
*/
|
|
||||||
private validateInlineTags(): void {
|
private validateInlineTags(): void {
|
||||||
const withDelimiters = this.inlineTags
|
const withDelimiters = this.inlineTags
|
||||||
.filter(tag => tag.delimiter)
|
.filter(tag => tag.delimiter)
|
||||||
|
|
@ -116,17 +136,17 @@ export class HopDown {
|
||||||
precedence: tag.precedence as number ?? 50,
|
precedence: tag.precedence as number ?? 50,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
for (let i = 0; i < withDelimiters.length; i++) {
|
for (let outer = 0; outer < withDelimiters.length; outer++) {
|
||||||
for (let j = i + 1; j < withDelimiters.length; j++) {
|
for (let inner = outer + 1; inner < withDelimiters.length; inner++) {
|
||||||
const a = withDelimiters[i];
|
const first = withDelimiters[outer];
|
||||||
const b = withDelimiters[j];
|
const second = withDelimiters[inner];
|
||||||
const aPrefix = b.delimiter.startsWith(a.delimiter);
|
const firstIsPrefix = second.delimiter.startsWith(first.delimiter);
|
||||||
const bPrefix = a.delimiter.startsWith(b.delimiter);
|
const secondIsPrefix = first.delimiter.startsWith(second.delimiter);
|
||||||
if (!aPrefix && !bPrefix) {
|
if (!firstIsPrefix && !secondIsPrefix) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const longer = a.delimiter.length > b.delimiter.length ? a : b;
|
const longer = first.delimiter.length > second.delimiter.length ? first : second;
|
||||||
const shorter = a.delimiter.length > b.delimiter.length ? b : a;
|
const shorter = first.delimiter.length > second.delimiter.length ? second : first;
|
||||||
if (longer.precedence >= shorter.precedence) {
|
if (longer.precedence >= shorter.precedence) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Inline tag "${longer.name}" (delimiter "${longer.delimiter}") must have ` +
|
`Inline tag "${longer.name}" (delimiter "${longer.delimiter}") must have ` +
|
||||||
|
|
@ -141,42 +161,145 @@ export class HopDown {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a markdown string to HTML.
|
* Convert a markdown string to HTML.
|
||||||
|
*
|
||||||
|
* converter.toHTML('# Hello\n\n**bold** text')
|
||||||
*/
|
*/
|
||||||
toHTML(md: string): string {
|
toHTML(markdown: string): string {
|
||||||
return this.processBlocks(md);
|
return this.processBlocks(markdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert an HTML string back to markdown.
|
* Convert an HTML string back to markdown. Uses the serializer
|
||||||
|
* which produces correctly-escaped output via typed tokens.
|
||||||
|
*
|
||||||
|
* converter.toMarkdown('<h1>Hello</h1><p><strong>bold</strong> text</p>')
|
||||||
*/
|
*/
|
||||||
toMarkdown(html: string): string {
|
toMarkdown(html: string): string {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim();
|
return this.serializeNode(container).replace(/\n{3,}/g, '\n\n').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the block tags for external iteration (e.g. speculative rendering).
|
* The registered block-level tags. Used by the WYSIWYG editor
|
||||||
|
* to detect block syntax patterns during live editing.
|
||||||
|
*
|
||||||
|
* converter.getBlockTags().forEach(tag => console.log(tag.name))
|
||||||
*/
|
*/
|
||||||
getBlockTags(): Tag[] {
|
getBlockTags(): Tag[] {
|
||||||
return this.blockTags;
|
return this.blockTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the inline tags for external iteration (e.g. speculative rendering).
|
* The registered inline tags. Used by the WYSIWYG editor to
|
||||||
|
* build delimiter regexes for speculative rendering.
|
||||||
|
*
|
||||||
|
* converter.getInlineTags().filter(tag => tag.delimiter)
|
||||||
*/
|
*/
|
||||||
getInlineTags(): Tag[] {
|
getInlineTags(): Tag[] {
|
||||||
return this.inlineTags;
|
return this.inlineTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
private processBlocks(md: string): string {
|
/**
|
||||||
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
* Find the first complete delimiter pair in the text.
|
||||||
const output: string[] = [];
|
*
|
||||||
let index = 0;
|
* converter.findCompletePair('hello **world** end')
|
||||||
|
*/
|
||||||
|
findCompletePair(text: string): DelimiterMatch | null {
|
||||||
|
for (const entry of this.delimiterRegexes) {
|
||||||
|
const match = text.match(entry.complete);
|
||||||
|
if (match && match.index !== undefined) {
|
||||||
|
return {
|
||||||
|
tag: entry.tag,
|
||||||
|
htmlTag: entry.htmlTag,
|
||||||
|
content: match[1],
|
||||||
|
index: match.index,
|
||||||
|
length: match[0].length,
|
||||||
|
delimiter: entry.tag.delimiter!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
while (index < lines.length) {
|
/**
|
||||||
if (/^\s*$/.test(lines[index])) {
|
* Find the first unclosed delimiter opener in the text.
|
||||||
index++;
|
*
|
||||||
|
* converter.findUnmatchedOpener('hello **world')
|
||||||
|
*/
|
||||||
|
findUnmatchedOpener(text: string): DelimiterMatch | null {
|
||||||
|
for (const entry of this.delimiterRegexes) {
|
||||||
|
const match = text.match(entry.open);
|
||||||
|
if (match && match.index !== undefined) {
|
||||||
|
const before = text.slice(0, match.index);
|
||||||
|
if (before.endsWith('<') || before.endsWith('/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tag: entry.tag,
|
||||||
|
htmlTag: entry.htmlTag,
|
||||||
|
content: match[1],
|
||||||
|
index: match.index,
|
||||||
|
length: match[0].length,
|
||||||
|
delimiter: entry.tag.delimiter!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the Tag definition for an HTML element by its tag name.
|
||||||
|
*
|
||||||
|
* converter.getTagForElement(strongElement)
|
||||||
|
*/
|
||||||
|
getTagForElement(element: HTMLElement): Tag | null {
|
||||||
|
const tag = this.tags.get(element.tagName);
|
||||||
|
if (tag && tag.delimiter) {
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS selector string matching all elements that should show
|
||||||
|
* editing context.
|
||||||
|
*
|
||||||
|
* element.matches(converter.getEditableSelector())
|
||||||
|
*/
|
||||||
|
getEditableSelector(): string {
|
||||||
|
return this.editableSelectorCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split markdown into lines, match each against block tags in
|
||||||
|
* priority order, and concatenate the resulting HTML.
|
||||||
|
*/
|
||||||
|
private processBlocks(markdown: string): string {
|
||||||
|
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||||
|
const output: string[] = [];
|
||||||
|
const blankLine = /^\s*$/;
|
||||||
|
const refDefinition = /^\[(?<label>[^\]]+)\]:\s+(?<url>\S+)(?:\s+"(?<title>[^"]*)")?$/;
|
||||||
|
let lineIndex = 0;
|
||||||
|
|
||||||
|
// Collect reference link definitions
|
||||||
|
this.referenceLinks = new Map();
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(refDefinition);
|
||||||
|
if (match?.groups) {
|
||||||
|
this.referenceLinks.set(
|
||||||
|
match.groups.label.toLowerCase(),
|
||||||
|
{
|
||||||
|
url: match.groups.url,
|
||||||
|
title: match.groups.title,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (lineIndex < lines.length) {
|
||||||
|
if (blankLine.test(lines[lineIndex]) || refDefinition.test(lines[lineIndex])) {
|
||||||
|
lineIndex++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,166 +307,435 @@ export class HopDown {
|
||||||
for (const tag of this.blockTags) {
|
for (const tag of this.blockTags) {
|
||||||
const context: MatchContext = {
|
const context: MatchContext = {
|
||||||
lines,
|
lines,
|
||||||
index,
|
index: lineIndex,
|
||||||
text: '',
|
text: '',
|
||||||
offset: 0,
|
offset: 0,
|
||||||
};
|
};
|
||||||
const token = tag.match(context);
|
const token = tag.match(context);
|
||||||
if (!token) continue;
|
if (!token) {
|
||||||
|
continue;
|
||||||
if (tag.name === 'list') {
|
|
||||||
const result = parseListBlock(lines, index, 0, (source) => this.processInline(source));
|
|
||||||
output.push(result.html);
|
|
||||||
index = result.end;
|
|
||||||
} else {
|
|
||||||
|
|
||||||
output.push(tag.toHTML(token, this.makeConverter()));
|
|
||||||
index += token.consumed;
|
|
||||||
}
|
}
|
||||||
|
output.push(tag.toHTML(token, this.cachedConverter));
|
||||||
|
lineIndex += token.consumed;
|
||||||
matched = true;
|
matched = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
index++;
|
lineIndex++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return output.join('\n');
|
return output.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert inline markdown to HTML using the tokenizer.
|
||||||
|
* Tokenizes the source, then walks the token stream to build HTML.
|
||||||
|
* Open/close delimiter pairs are matched using a stack.
|
||||||
|
*/
|
||||||
private processInline(source: string): string {
|
private processInline(source: string): string {
|
||||||
const sorted = [...this.inlineTags].sort((a, b) =>
|
|
||||||
((a as any).precedence ?? 50) - ((b as any).precedence ?? 50)
|
|
||||||
);
|
|
||||||
|
|
||||||
const placeholders: string[] = [];
|
|
||||||
let text = source;
|
let text = source;
|
||||||
|
|
||||||
// Extract inline macros before other processing
|
// Process inline macros before tokenizing — they produce HTML
|
||||||
|
// that should pass through without further parsing
|
||||||
if (this.macroMap.size > 0) {
|
if (this.macroMap.size > 0) {
|
||||||
text = processInlineMacros(text, this.macroMap, this.makeConverter(), placeholders);
|
const placeholders: string[] = [];
|
||||||
|
text = processInlineMacros(text, this.macroMap, this.cachedConverter, placeholders);
|
||||||
|
// Restore placeholders to their HTML content
|
||||||
|
const placeholderPattern = /\x00P(?<index>\d+)\x00/g;
|
||||||
|
text = text.replace(placeholderPattern, (_, index: string) =>
|
||||||
|
placeholders[parseInt(index)]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 1: extract links and non-recursive tags into placeholders before escaping
|
// Resolve reference links before tokenizing
|
||||||
for (const tag of sorted) {
|
text = this.resolveReferenceLinks(text);
|
||||||
const recursive = tag.recursive ?? true;
|
// Normalize _ emphasis to *
|
||||||
|
text = this.normalizeUnderscores(text);
|
||||||
if (tag.name === 'link') {
|
const tokens = this.tokenizer.tokenize(text);
|
||||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => {
|
return this.tokensToHTML(tokens);
|
||||||
let inner = linkText;
|
|
||||||
const hasPlaceholders = /\x00P\d+\x00/.test(inner);
|
|
||||||
if (hasPlaceholders) {
|
|
||||||
inner = inner.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
|
|
||||||
} else {
|
|
||||||
inner = this.processInline(inner);
|
|
||||||
}
|
|
||||||
placeholders.push('<a href="' + escapeHtml(href) + '">' + inner + '</a>');
|
|
||||||
return '\x00P' + (placeholders.length - 1) + '\x00';
|
|
||||||
});
|
|
||||||
} else if (!recursive && tag.pattern) {
|
|
||||||
const globalPattern = tag.pattern as RegExp;
|
|
||||||
globalPattern.lastIndex = 0;
|
|
||||||
|
|
||||||
text = text.replace(globalPattern, (_, content: string) => {
|
|
||||||
placeholders.push(tag.toHTML(
|
|
||||||
{ content, raw: '', consumed: 0 },
|
|
||||||
this.makeConverter(),
|
|
||||||
));
|
|
||||||
return '\x00P' + (placeholders.length - 1) + '\x00';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
text = escapeHtml(text);
|
/**
|
||||||
|
* Replace [text][ref] and [text][] with [text](url) using the
|
||||||
// Pass 2: apply recursive tags in precedence order.
|
* reference definitions collected during block parsing.
|
||||||
// Content is already HTML-escaped from pass 1, so we wrap directly
|
*/
|
||||||
// without re-processing through convert.inline().
|
private resolveReferenceLinks(text: string): string {
|
||||||
for (const tag of sorted) {
|
if (this.referenceLinks.size === 0) {
|
||||||
const recursive = tag.recursive ?? true;
|
|
||||||
if (tag.name === 'link' || !recursive) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const globalPattern = tag.pattern as RegExp | undefined;
|
|
||||||
if (globalPattern) {
|
|
||||||
globalPattern.lastIndex = 0;
|
|
||||||
text = text.replace(globalPattern, (_, content: string) => {
|
|
||||||
const restored = content.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
|
|
||||||
const htmlTag = tag.name === 'boldItalic'
|
|
||||||
? null
|
|
||||||
: ((tag.selector as string) || '').split(',')[0].toLowerCase();
|
|
||||||
if (tag.name === 'boldItalic') {
|
|
||||||
return '<em><strong>' + restored + '</strong></em>';
|
|
||||||
}
|
|
||||||
return `<${htmlTag}>${restored}</${htmlTag}>`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
text = text.replace(/\x00P(\d+)\x00/g, (_, index: string) => placeholders[parseInt(index)]);
|
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
const refLink = /\[(?<text>[^\[\]]+)\]\[(?<label>[^\]]*)\]/g;
|
||||||
|
return text.replace(refLink, (...args) => {
|
||||||
|
const groups = args[args.length - 1] as Record<string, string>;
|
||||||
|
const label = (groups.label || groups.text).toLowerCase();
|
||||||
|
const ref = this.referenceLinks.get(label);
|
||||||
|
if (!ref) {
|
||||||
|
return args[0];
|
||||||
|
}
|
||||||
|
const titlePart = ref.title ? ` "${ref.title}"` : '';
|
||||||
|
return `[${groups.text}](${ref.url}${titlePart})`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private nodeToMd(node: Node): string {
|
/**
|
||||||
|
* Normalize flanking underscore runs to asterisks so the tokenizer
|
||||||
|
* only needs to handle * delimiters for emphasis.
|
||||||
|
*/
|
||||||
|
private normalizeUnderscores(text: string): string {
|
||||||
|
// Protect backslash-escaped underscores from normalization
|
||||||
|
const escapePlaceholder = '\x00U\x00';
|
||||||
|
const safeText = text.replace(/\\_/g, escapePlaceholder);
|
||||||
|
|
||||||
|
const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|]`;
|
||||||
|
const openRun = new RegExp(
|
||||||
|
`(?<=^|${punctuation})` + // preceded by start, space, or punctuation
|
||||||
|
`(_+)` + // one or more underscores
|
||||||
|
`(?=\\S)`, // followed by non-whitespace
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
const closeRun = new RegExp(
|
||||||
|
`(?<=\\S)` + // preceded by non-whitespace
|
||||||
|
`(_+)` + // one or more underscores
|
||||||
|
`(?=$|${punctuation})`, // followed by end, space, or punctuation
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
const toAsterisks = (_: string, run: string) => '*'.repeat(run.length);
|
||||||
|
const normalized = safeText
|
||||||
|
.replace(openRun, toAsterisks)
|
||||||
|
.replace(closeRun, toAsterisks);
|
||||||
|
|
||||||
|
return normalized.replace(/\x00U\x00/g, '\\_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a token stream to HTML. Matches open/close delimiter
|
||||||
|
* pairs and wraps their content in the appropriate HTML tags.
|
||||||
|
* Unmatched delimiters are emitted as literal text.
|
||||||
|
*/
|
||||||
|
private tokensToHTML(tokens: InlineToken[]): string {
|
||||||
|
// Build a map from delimiter string to tag info
|
||||||
|
const delimiterToTag = new Map<string, { htmlTag: string; name: string }>();
|
||||||
|
for (const tag of this.inlineTags) {
|
||||||
|
if (tag.delimiter) {
|
||||||
|
const htmlTag = tag.name === 'boldItalic'
|
||||||
|
? 'em'
|
||||||
|
: (tag.selector as string).split(',')[0].toLowerCase();
|
||||||
|
delimiterToTag.set(tag.delimiter, {
|
||||||
|
htmlTag,
|
||||||
|
name: tag.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pass: match open/close pairs using a stack
|
||||||
|
const paired = this.pairDelimiters(tokens);
|
||||||
|
|
||||||
|
// Second pass: build HTML from paired tokens
|
||||||
|
let html = '';
|
||||||
|
for (const token of paired) {
|
||||||
|
switch (token.role) {
|
||||||
|
case 'text':
|
||||||
|
html += escapeHtml(token.value);
|
||||||
|
break;
|
||||||
|
case 'open': {
|
||||||
|
const info = delimiterToTag.get(token.delimiter!);
|
||||||
|
if (info) {
|
||||||
|
if (info.name === 'boldItalic') {
|
||||||
|
html += '<em><strong>';
|
||||||
|
} else {
|
||||||
|
html += `<${info.htmlTag}>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html += escapeHtml(token.value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'close': {
|
||||||
|
const info = delimiterToTag.get(token.delimiter!);
|
||||||
|
if (info) {
|
||||||
|
if (info.name === 'boldItalic') {
|
||||||
|
html += '</strong></em>';
|
||||||
|
} else {
|
||||||
|
html += `</${info.htmlTag}>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html += escapeHtml(token.value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'code':
|
||||||
|
html += `<code>${escapeHtml(token.content || '')}</code>`;
|
||||||
|
break;
|
||||||
|
case 'link': {
|
||||||
|
const titleAttr = token.title
|
||||||
|
? ` title="${escapeHtml(token.title)}"`
|
||||||
|
: '';
|
||||||
|
// Process link text for nested inline formatting
|
||||||
|
const innerTokens = this.tokenizer.tokenize(token.value);
|
||||||
|
const innerHtml = this.tokensToHTML(innerTokens);
|
||||||
|
// Strip any nested <a> tags (links can't contain links)
|
||||||
|
const nestedLink = /<a[^>]*>|<\/a>/g;
|
||||||
|
const cleanInner = innerHtml.replace(nestedLink, '');
|
||||||
|
html += `<a href="${escapeHtml(token.href!)}"${titleAttr}>${cleanInner}</a>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'autolink':
|
||||||
|
html += `<a href="${escapeHtml(token.href!)}">${escapeHtml(token.value)}</a>`;
|
||||||
|
break;
|
||||||
|
case 'html':
|
||||||
|
html += token.value;
|
||||||
|
break;
|
||||||
|
case 'break':
|
||||||
|
html += '<br>';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
html += escapeHtml(token.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match open/close delimiter pairs in a token stream. Unmatched
|
||||||
|
* openers/closers are converted to text tokens so they render
|
||||||
|
* as literal characters.
|
||||||
|
*/
|
||||||
|
private pairDelimiters(tokens: InlineToken[]): InlineToken[] {
|
||||||
|
const openStack: number[] = [];
|
||||||
|
const result = [...tokens];
|
||||||
|
|
||||||
|
// Track which delimiter types are currently open to prevent
|
||||||
|
// forbidden nesting (e.g. <del> inside <del>, <em> inside <em>)
|
||||||
|
const openDelimiters = new Set<string>();
|
||||||
|
|
||||||
|
for (let index = 0; index < result.length; index++) {
|
||||||
|
const token = result[index];
|
||||||
|
if (token.role === 'open') {
|
||||||
|
// Don't open a delimiter that's already open (prevents nesting)
|
||||||
|
if (openDelimiters.has(token.delimiter!)) {
|
||||||
|
result[index] = {
|
||||||
|
role: 'text',
|
||||||
|
value: token.value,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
openStack.push(index);
|
||||||
|
openDelimiters.add(token.delimiter!);
|
||||||
|
} else if (token.role === 'close') {
|
||||||
|
let matched = false;
|
||||||
|
for (let stackIndex = openStack.length - 1; stackIndex >= 0; stackIndex--) {
|
||||||
|
const openerIndex = openStack[stackIndex];
|
||||||
|
if (result[openerIndex].delimiter === token.delimiter) {
|
||||||
|
openStack.splice(stackIndex, 1);
|
||||||
|
openDelimiters.delete(token.delimiter!);
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
result[index] = {
|
||||||
|
role: 'text',
|
||||||
|
value: token.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any remaining unmatched openers become literal text
|
||||||
|
for (const openerIndex of openStack) {
|
||||||
|
result[openerIndex] = {
|
||||||
|
role: 'text',
|
||||||
|
value: result[openerIndex].value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a DOM node to markdown using the serializer for inline
|
||||||
|
* content and custom logic for block-level elements.
|
||||||
|
*/
|
||||||
|
private serializeNode(node: Node): string {
|
||||||
if (node.nodeType === 3) {
|
if (node.nodeType === 3) {
|
||||||
return node.textContent || '';
|
return this.serializer.serialize(node);
|
||||||
}
|
}
|
||||||
if (node.nodeType !== 1) {
|
if (node.nodeType !== 1) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const element = node as HTMLElement;
|
const element = node as HTMLElement;
|
||||||
|
|
||||||
// Check CSS selectors first (macro selectors are more specific)
|
// CSS selectors (e.g. [data-macro]) are more specific
|
||||||
for (const [selector, selectorTag] of this.tags.entries()) {
|
const cssSelectorMatch = this.matchCssSelector(element);
|
||||||
if (selector.includes('[') || selector.includes('.') || selector.includes('#')) {
|
if (cssSelectorMatch) {
|
||||||
// Lowercase only the tag name portion for case-insensitive matching
|
return cssSelectorMatch.toMarkdown(element, this.cachedConverter);
|
||||||
const normalized = selector.replace(/^[A-Z]+/, s => s.toLowerCase());
|
|
||||||
try {
|
|
||||||
if (element.matches(normalized)) {
|
|
||||||
return selectorTag.toMarkdown(element, this.makeConverter());
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// invalid selector, skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then check by element name
|
// Inline elements: use the serializer which handles escaping
|
||||||
|
// via typed tokens (text vs delimiter separation)
|
||||||
|
const inlineTag = this.tags.get(element.nodeName);
|
||||||
|
if (inlineTag && (inlineTag.delimiter || inlineTag.name === 'link'
|
||||||
|
|| inlineTag.name === 'code' || inlineTag.name === 'hardBreak')) {
|
||||||
|
return this.serializer.serialize(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block elements: use the tag's toMarkdown
|
||||||
const tag = this.tags.get(element.nodeName);
|
const tag = this.tags.get(element.nodeName);
|
||||||
if (tag) {
|
if (tag) {
|
||||||
return tag.toMarkdown(element, this.makeConverter());
|
return tag.toMarkdown(element, this.cachedConverter);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.childrenToMd(node);
|
return this.serializeChildren(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
private childrenToMd(node: Node): string {
|
private matchCssSelector(element: HTMLElement): Tag | null {
|
||||||
return Array.from(node.childNodes).map(child => this.nodeToMd(child)).join('');
|
for (const [selector, tag] of this.tags.entries()) {
|
||||||
|
if (!selector.includes('[') && !selector.includes('.') && !selector.includes('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uppercaseTagName = /^[A-Z]+/;
|
||||||
|
const normalized = selector.replace(uppercaseTagName, part => part.toLowerCase());
|
||||||
|
try {
|
||||||
|
if (element.matches(normalized)) {
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid selector — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeChildren(node: Node): string {
|
||||||
|
return Array.from(node.childNodes)
|
||||||
|
.map(child => this.serializeNode(child))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the inline tokenizer from registered delimiter-based tags.
|
||||||
|
*/
|
||||||
|
private buildTokenizer(): InlineTokenizer {
|
||||||
|
const hasCodeTag = this.inlineTags.some(tag => tag.name === 'code');
|
||||||
|
const delimiterDefs: DelimiterDef[] = this.inlineTags
|
||||||
|
.filter(tag => tag.delimiter && tag.name !== 'code')
|
||||||
|
.map(tag => ({
|
||||||
|
delimiter: tag.delimiter!,
|
||||||
|
htmlTag: tag.name === 'boldItalic'
|
||||||
|
? 'em'
|
||||||
|
: (tag.selector as string).split(',')[0].toLowerCase(),
|
||||||
|
recursive: tag.recursive !== false,
|
||||||
|
precedence: tag.precedence ?? 50,
|
||||||
|
}));
|
||||||
|
return new InlineTokenizer(delimiterDefs, { codeSpans: hasCodeTag });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the markdown serializer from registered tags. Maps HTML
|
||||||
|
* element names to their serialization strategy (delimiter wrap
|
||||||
|
* or custom function).
|
||||||
|
*/
|
||||||
|
private buildSerializer(): MarkdownSerializer {
|
||||||
|
const tagMap = new Map<string, SerializerTagDef>();
|
||||||
|
const delimiterChars = new Set<string>();
|
||||||
|
|
||||||
|
for (const [selector, tag] of this.tags.entries()) {
|
||||||
|
if (tag.delimiter) {
|
||||||
|
delimiterChars.add(tag.delimiter[0]);
|
||||||
|
// Delimiter-based tags: emit delimiter + children + delimiter
|
||||||
|
for (const part of selector.split(',').map(part => part.trim())) {
|
||||||
|
tagMap.set(part, { delimiter: tag.delimiter });
|
||||||
|
}
|
||||||
|
} else if (tag.name === 'link') {
|
||||||
|
tagMap.set('A', {
|
||||||
|
serialize: (element, children) => {
|
||||||
|
const href = element.getAttribute('href') || '';
|
||||||
|
const title = element.getAttribute('title');
|
||||||
|
const titlePart = title ? ` "${title}"` : '';
|
||||||
|
return '[' + children() + '](' + href + titlePart + ')';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (tag.name === 'hardBreak') {
|
||||||
|
tagMap.set('BR', {
|
||||||
|
serialize: () => ' \n',
|
||||||
|
});
|
||||||
|
} else if (tag.name === 'fencedCode') {
|
||||||
|
tagMap.set('PRE', {
|
||||||
|
serialize: (element) => {
|
||||||
|
const code = element.querySelector('code');
|
||||||
|
const langMatch = (code?.getAttribute('class') || '').match(/language-(\S+)/);
|
||||||
|
const lang = langMatch ? langMatch[1] : '';
|
||||||
|
const content = code?.textContent || element.textContent || '';
|
||||||
|
return '\n\n```' + lang + '\n' + content + '\n```\n\n';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CODE gets a custom serializer because its content is literal
|
||||||
|
tagMap.set('CODE', {
|
||||||
|
serialize: (element) => {
|
||||||
|
// Code inside <pre> is handled by the PRE serializer
|
||||||
|
if (element.parentNode?.nodeName === 'PRE') {
|
||||||
|
return element.textContent || '';
|
||||||
|
}
|
||||||
|
return '`' + (element.textContent || '') + '`';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new MarkdownSerializer(tagMap, delimiterChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDelimiterRegexes(): { tag: Tag; htmlTag: string; complete: RegExp; open: RegExp }[] {
|
||||||
|
const escapeRegex = /[.*+?^${}()|[\]\\]/g;
|
||||||
|
const sorted = this.inlineTags
|
||||||
|
.filter(tag => tag.delimiter)
|
||||||
|
.sort((first, second) => (first.precedence ?? 50) - (second.precedence ?? 50));
|
||||||
|
|
||||||
|
return sorted.map(tag => {
|
||||||
|
const delimiter = tag.delimiter!;
|
||||||
|
const escaped = delimiter.replace(escapeRegex, '\\$&');
|
||||||
|
const escapedChar = delimiter[0].replace(escapeRegex, '\\$&');
|
||||||
|
const htmlTag = tag.name === 'boldItalic'
|
||||||
|
? 'em'
|
||||||
|
: (tag.selector as string).split(',')[0].toLowerCase();
|
||||||
|
return {
|
||||||
|
tag,
|
||||||
|
htmlTag,
|
||||||
|
complete: new RegExp(
|
||||||
|
`(?<!${escapedChar})` +
|
||||||
|
`${escaped}` +
|
||||||
|
`(?!${escapedChar})` +
|
||||||
|
`([^\\x01\\x02]+?)` +
|
||||||
|
`(?<!${escapedChar})` +
|
||||||
|
`${escaped}`
|
||||||
|
),
|
||||||
|
open: new RegExp(
|
||||||
|
`(?<!${escapedChar})` +
|
||||||
|
`${escaped}` +
|
||||||
|
`(?!${escapedChar})` +
|
||||||
|
`([^\\x01\\x02]+)$`
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEditableSelector(): string {
|
||||||
|
return [
|
||||||
|
...this.inlineTags,
|
||||||
|
...this.blockTags,
|
||||||
|
].filter(tag => typeof tag.selector === 'string')
|
||||||
|
.map(tag => (tag.selector as string).toLowerCase())
|
||||||
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
private makeConverter(): Converter {
|
private makeConverter(): Converter {
|
||||||
return {
|
return {
|
||||||
inline: (source) => this.processInline(source),
|
inline: (source) => this.processInline(source),
|
||||||
block: (md) => this.processBlocks(md),
|
block: (markdown) => this.processBlocks(markdown),
|
||||||
children: (node) => this.childrenToMd(node),
|
children: (node) => this.serializeChildren(node),
|
||||||
node: (node) => this.nodeToMd(node),
|
node: (node) => this.serializeNode(node),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A default HopDown instance with all standard tags enabled.
|
|
||||||
* Use this for simple cases where no configuration is needed.
|
|
||||||
*/
|
|
||||||
const hopdown = new HopDown();
|
|
||||||
|
|
||||||
export function toHTML(md: string): string {
|
|
||||||
return hopdown.toHTML(md);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toMarkdown(html: string): string {
|
|
||||||
return hopdown.toMarkdown(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default hopdown;
|
|
||||||
|
|
|
||||||
230
src/ts/macros.ts
230
src/ts/macros.ts
|
|
@ -21,6 +21,63 @@
|
||||||
import type { Tag, Converter, ToolbarButton } from './types';
|
import type { Tag, Converter, ToolbarButton } from './types';
|
||||||
import { escapeHtml } from './tags';
|
import { escapeHtml } from './tags';
|
||||||
|
|
||||||
|
/* ── Constants ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const VERBATIM_KEYWORD = 'verbatim';
|
||||||
|
const VERBATIM_DATA_VALUE = 'true';
|
||||||
|
const DATASET_PARAM_PREFIX = 'param';
|
||||||
|
const DATASET_PARAM_PREFIX_LENGTH = 5;
|
||||||
|
const PLACEHOLDER_SENTINEL = '\x00P';
|
||||||
|
const PLACEHOLDER_TERMINATOR = '\x00';
|
||||||
|
|
||||||
|
/* Named regex for key="value" pairs inside macro argument strings */
|
||||||
|
const PARAM_PATTERN = /(?<paramKey>\w+)="(?<paramValue>[^"]*)"/g;
|
||||||
|
|
||||||
|
/* Matches the opening line of a block macro: @name(args with no closing paren */
|
||||||
|
const BLOCK_MACRO_OPEN = /^@(?<macroName>\w+)\((?<macroArgs>[^)]*)\s*$/;
|
||||||
|
|
||||||
|
/* Matches a line that closes a block macro body */
|
||||||
|
const BLOCK_CLOSE_LINE = /^\)\s*$/;
|
||||||
|
|
||||||
|
/* Matches a nested block macro opening inside a body */
|
||||||
|
const NESTED_BLOCK_OPEN = /^@\w+\([^)]*\s*$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches inline macros: `@name` or `@name(args)`.
|
||||||
|
* The lookbehind ensures macros only start after whitespace or
|
||||||
|
* markdown punctuation, preventing false matches mid-word.
|
||||||
|
*
|
||||||
|
* Named groups:
|
||||||
|
* inlineName — the macro name after @
|
||||||
|
* inlineArgs — optional parenthesized arguments
|
||||||
|
*/
|
||||||
|
const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(?<inlineName>\w+)(?:\((?<inlineArgs>[^)]*)\))?/g;
|
||||||
|
|
||||||
|
/* ── Public interfaces ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Definition for a macro that can be registered with ribbit.
|
||||||
|
*
|
||||||
|
* Each macro provides a name and a `toHTML` renderer. Ribbit handles
|
||||||
|
* wrapping, round-tripping, and toolbar integration automatically.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const userMacro: MacroDef = {
|
||||||
|
* name: 'user',
|
||||||
|
* toHTML: () => '<a href="/User/gsb">gsb</a>',
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const styleMacro: MacroDef = {
|
||||||
|
* name: 'style',
|
||||||
|
* toHTML: ({ keywords, content }) =>
|
||||||
|
* `<div class="${keywords.join(' ')}">${content}</div>`,
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
export interface MacroDef {
|
export interface MacroDef {
|
||||||
name: string;
|
name: string;
|
||||||
/**
|
/**
|
||||||
|
|
@ -44,34 +101,58 @@ export interface MacroDef {
|
||||||
button?: ToolbarButton | false;
|
button?: ToolbarButton | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Internal representation of a fully parsed macro invocation. */
|
||||||
interface ParsedMacro {
|
interface ParsedMacro {
|
||||||
name: string;
|
name: string;
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
params: Record<string, string>;
|
params: Record<string, string>;
|
||||||
verbatim: boolean;
|
verbatim: boolean;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
/** Number of source lines consumed by this macro (for block advancement). */
|
||||||
consumed: number;
|
consumed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PARAM_PATTERN = /(\w+)="([^"]*)"/g;
|
/* ── Module-level helpers ──────────────────────────────────────── */
|
||||||
|
|
||||||
function parseArgs(argsStr: string | undefined): {
|
/**
|
||||||
|
* Parse the argument string from a macro invocation into keywords,
|
||||||
|
* key="value" params, and a verbatim flag.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* parseArgs('box center depth="3"')
|
||||||
|
* // { keywords: ['box', 'center'], params: { depth: '3' }, verbatim: false }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function parseArgs(argumentString: string | undefined): {
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
params: Record<string, string>;
|
params: Record<string, string>;
|
||||||
verbatim: boolean;
|
verbatim: boolean;
|
||||||
} {
|
} {
|
||||||
if (!argsStr || !argsStr.trim()) {
|
if (!argumentString || !argumentString.trim()) {
|
||||||
return { keywords: [], params: {}, verbatim: false };
|
return {
|
||||||
|
keywords: [],
|
||||||
|
params: {},
|
||||||
|
verbatim: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
const withoutParams = argsStr.replace(new RegExp(PARAM_PATTERN.source, 'g'), (_, key, val) => {
|
/* Strip key="value" pairs, collecting them into params */
|
||||||
params[key] = val;
|
const withoutParams = argumentString.replace(
|
||||||
|
new RegExp(PARAM_PATTERN.source, 'g'),
|
||||||
|
(_match, paramKey, paramValue) => {
|
||||||
|
params[paramKey] = paramValue;
|
||||||
return '';
|
return '';
|
||||||
});
|
},
|
||||||
|
);
|
||||||
const allKeywords = withoutParams.trim().split(/\s+/).filter(Boolean);
|
const allKeywords = withoutParams.trim().split(/\s+/).filter(Boolean);
|
||||||
const verbatim = allKeywords.includes('verbatim');
|
const verbatim = allKeywords.includes(VERBATIM_KEYWORD);
|
||||||
const keywords = allKeywords.filter(k => k !== 'verbatim');
|
const keywords = allKeywords.filter(keyword => keyword !== VERBATIM_KEYWORD);
|
||||||
return { keywords, params, verbatim };
|
return {
|
||||||
|
keywords,
|
||||||
|
params,
|
||||||
|
verbatim,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function macroError(name: string): string {
|
function macroError(name: string): string {
|
||||||
|
|
@ -80,7 +161,7 @@ function macroError(name: string): string {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap a macro's rendered HTML with data- attributes for round-tripping.
|
* Wrap a macro's rendered HTML with data- attributes for round-tripping.
|
||||||
* Block macros (with content) use <div>, inline macros use <span>.
|
* Block macros (with content) use `<div>`, inline macros use `<span>`.
|
||||||
*/
|
*/
|
||||||
function wrapMacro(
|
function wrapMacro(
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -95,34 +176,36 @@ function wrapMacro(
|
||||||
if (keywords.length) {
|
if (keywords.length) {
|
||||||
attrs += ` data-keywords="${escapeHtml(keywords.join(' '))}"`;
|
attrs += ` data-keywords="${escapeHtml(keywords.join(' '))}"`;
|
||||||
}
|
}
|
||||||
for (const [key, val] of Object.entries(params)) {
|
for (const [paramKey, paramValue] of Object.entries(params)) {
|
||||||
attrs += ` data-param-${escapeHtml(key)}="${escapeHtml(val)}"`;
|
attrs += ` data-param-${escapeHtml(paramKey)}="${escapeHtml(paramValue)}"`;
|
||||||
}
|
}
|
||||||
if (verbatim) {
|
if (verbatim) {
|
||||||
attrs += ` data-verbatim="true"`;
|
attrs += ` data-verbatim="${VERBATIM_DATA_VALUE}"`;
|
||||||
}
|
}
|
||||||
return `<${tag}${attrs}>${innerHtml}</${tag}>`;
|
return `<${tag}${attrs}>${innerHtml}</${tag}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reconstruct macro source from a DOM element's data- attributes.
|
* Reconstruct macro source from a DOM element's data- attributes.
|
||||||
* This is the generic toMarkdown for all macros.
|
* This is the generic toMarkdown for all macros — it reads the
|
||||||
|
* data- attributes that wrapMacro wrote and rebuilds the @name(...)
|
||||||
|
* syntax so the document can round-trip without per-macro logic.
|
||||||
*/
|
*/
|
||||||
function macroToMarkdown(element: HTMLElement, convert: Converter): string {
|
function macroToMarkdown(element: HTMLElement, convert: Converter): string {
|
||||||
const name = element.dataset.macro || '';
|
const name = element.dataset.macro || '';
|
||||||
const keywords = element.dataset.keywords || '';
|
const keywords = element.dataset.keywords || '';
|
||||||
const verbatim = element.dataset.verbatim === 'true';
|
const verbatim = element.dataset.verbatim === VERBATIM_DATA_VALUE;
|
||||||
|
|
||||||
const paramParts: string[] = [];
|
const paramParts: string[] = [];
|
||||||
for (const [key, val] of Object.entries(element.dataset)) {
|
for (const [datasetKey, datasetValue] of Object.entries(element.dataset)) {
|
||||||
if (key.startsWith('param') && key.length > 5) {
|
if (datasetKey.startsWith(DATASET_PARAM_PREFIX) && datasetKey.length > DATASET_PARAM_PREFIX_LENGTH) {
|
||||||
const paramName = key.slice(5).toLowerCase();
|
const paramName = datasetKey.slice(DATASET_PARAM_PREFIX_LENGTH).toLowerCase();
|
||||||
paramParts.push(`${paramName}="${val}"`);
|
paramParts.push(`${paramName}="${datasetValue}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allKeywords = verbatim
|
const allKeywords = verbatim
|
||||||
? [keywords, 'verbatim'].filter(Boolean).join(' ')
|
? [keywords, VERBATIM_KEYWORD].filter(Boolean).join(' ')
|
||||||
: keywords;
|
: keywords;
|
||||||
const args = [allKeywords, paramParts.join(' ')].filter(Boolean).join(' ');
|
const args = [allKeywords, paramParts.join(' ')].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
|
@ -136,32 +219,36 @@ function macroToMarkdown(element: HTMLElement, convert: Converter): string {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to parse a block macro starting at the given line index.
|
* Try to parse a block macro starting at the given line index.
|
||||||
|
* Returns null if the line doesn't start a block macro or the
|
||||||
|
* closing paren is never found (unclosed macro).
|
||||||
*/
|
*/
|
||||||
function parseBlockMacro(lines: string[], index: number): ParsedMacro | null {
|
function parseBlockMacro(lines: string[], lineIndex: number): ParsedMacro | null {
|
||||||
const line = lines[index];
|
const line = lines[lineIndex];
|
||||||
const m = line.match(/^@(\w+)\(([^)]*)\s*$/);
|
const openMatch = BLOCK_MACRO_OPEN.exec(line);
|
||||||
if (!m) {
|
if (!openMatch || !openMatch.groups) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const name = m[1];
|
const name = openMatch.groups.macroName;
|
||||||
const { keywords, params, verbatim } = parseArgs(m[2]);
|
const { keywords, params, verbatim } = parseArgs(openMatch.groups.macroArgs);
|
||||||
|
|
||||||
const contentLines: string[] = [];
|
const contentLines: string[] = [];
|
||||||
let i = index + 1;
|
let scanIndex = lineIndex + 1;
|
||||||
let depth = 1;
|
let nestingDepth = 1;
|
||||||
while (i < lines.length && depth > 0) {
|
while (scanIndex < lines.length && nestingDepth > 0) {
|
||||||
if (/^\)\s*$/.test(lines[i])) {
|
if (BLOCK_CLOSE_LINE.test(lines[scanIndex])) {
|
||||||
depth--;
|
nestingDepth--;
|
||||||
if (depth === 0) {
|
if (nestingDepth === 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (/^@\w+\([^)]*\s*$/.test(lines[i])) {
|
if (NESTED_BLOCK_OPEN.test(lines[scanIndex])) {
|
||||||
depth++;
|
nestingDepth++;
|
||||||
}
|
}
|
||||||
contentLines.push(lines[i]);
|
contentLines.push(lines[scanIndex]);
|
||||||
i++;
|
scanIndex++;
|
||||||
}
|
}
|
||||||
if (depth !== 0) {
|
/* Unclosed macro — treat as plain text */
|
||||||
|
if (nestingDepth !== 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -170,14 +257,25 @@ function parseBlockMacro(lines: string[], index: number): ParsedMacro | null {
|
||||||
params,
|
params,
|
||||||
verbatim,
|
verbatim,
|
||||||
content: contentLines.join('\n'),
|
content: contentLines.join('\n'),
|
||||||
consumed: i + 1 - index,
|
consumed: scanIndex + 1 - lineIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(\w+)(?:\(([^)]*)\))?/g;
|
/* ── Public API ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build Tags from an array of macro definitions.
|
* Build Tags from an array of macro definitions.
|
||||||
|
*
|
||||||
|
* Returns a block-level Tag for parsing `@name(args\ncontent\n)` syntax,
|
||||||
|
* a selector Tag for HTML→markdown round-tripping, and a lookup map
|
||||||
|
* for inline macro processing.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const { blockTag, selectorTag, macroMap } = buildMacroTags([
|
||||||
|
* { name: 'user', toHTML: () => '<a href="/User/gsb">gsb</a>' },
|
||||||
|
* ]);
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function buildMacroTags(
|
export function buildMacroTags(
|
||||||
macros: MacroDef[],
|
macros: MacroDef[],
|
||||||
|
|
@ -188,11 +286,6 @@ export function buildMacroTags(
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockTag: Tag = {
|
const blockTag: Tag = {
|
||||||
/*
|
|
||||||
* @name(args
|
|
||||||
* content
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
name: 'macro',
|
name: 'macro',
|
||||||
match: (context) => {
|
match: (context) => {
|
||||||
const parsed = parseBlockMacro(context.lines, context.index);
|
const parsed = parseBlockMacro(context.lines, context.index);
|
||||||
|
|
@ -235,8 +328,10 @@ export function buildMacroTags(
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic selector tag that matches any element with data-macro
|
* Generic selector tag — matches any element with data-macro
|
||||||
* and reconstructs the macro source from data- attributes.
|
* and reconstructs the macro source from data- attributes.
|
||||||
|
* Separate from blockTag so the selector-based HTML→markdown
|
||||||
|
* path can find macro elements independently.
|
||||||
*/
|
*/
|
||||||
const selectorTag: Tag = {
|
const selectorTag: Tag = {
|
||||||
name: 'macro:generic',
|
name: 'macro:generic',
|
||||||
|
|
@ -246,11 +341,30 @@ export function buildMacroTags(
|
||||||
toMarkdown: macroToMarkdown,
|
toMarkdown: macroToMarkdown,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { blockTag, selectorTag, 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.
|
||||||
|
*
|
||||||
|
* Inline macros are replaced with placeholder tokens so that subsequent
|
||||||
|
* inline parsing (bold, italic, etc.) doesn't mangle the HTML output.
|
||||||
|
* The caller restores placeholders after all inline processing is done.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const placeholders: string[] = [];
|
||||||
|
* const result = processInlineMacros(
|
||||||
|
* 'Hello @user!',
|
||||||
|
* macroMap,
|
||||||
|
* convert,
|
||||||
|
* placeholders,
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function processInlineMacros(
|
export function processInlineMacros(
|
||||||
text: string,
|
text: string,
|
||||||
|
|
@ -258,20 +372,26 @@ export function processInlineMacros(
|
||||||
convert: Converter,
|
convert: Converter,
|
||||||
placeholders: string[],
|
placeholders: string[],
|
||||||
): string {
|
): string {
|
||||||
return text.replace(INLINE_MACRO_GLOBAL, (match, nameStr: string, argsStr: string | undefined) => {
|
return text.replace(
|
||||||
const macro = macroMap.get(nameStr);
|
INLINE_MACRO_GLOBAL,
|
||||||
|
(match, ...args) => {
|
||||||
|
/* Named groups are the last non-offset argument from replace() */
|
||||||
|
const groups = args[args.length - 1] as { inlineName: string; inlineArgs?: string };
|
||||||
|
const macroName = groups.inlineName;
|
||||||
|
const macro = macroMap.get(macroName);
|
||||||
if (!macro) {
|
if (!macro) {
|
||||||
placeholders.push(macroError(nameStr));
|
placeholders.push(macroError(macroName));
|
||||||
return '\x00P' + (placeholders.length - 1) + '\x00';
|
return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR;
|
||||||
}
|
}
|
||||||
const { keywords, params } = parseArgs(argsStr);
|
const { keywords, params } = parseArgs(groups.inlineArgs);
|
||||||
const innerHtml = macro.toHTML({
|
const innerHtml = macro.toHTML({
|
||||||
keywords,
|
keywords,
|
||||||
params,
|
params,
|
||||||
convert,
|
convert,
|
||||||
});
|
});
|
||||||
const wrapped = wrapMacro(nameStr, keywords, params, false, false, innerHtml);
|
const wrapped = wrapMacro(macroName, keywords, params, false, false, innerHtml);
|
||||||
placeholders.push(wrapped);
|
placeholders.push(wrapped);
|
||||||
return '\x00P' + (placeholders.length - 1) + '\x00';
|
return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,24 +7,38 @@ import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './t
|
||||||
import { defaultTheme } from './default-theme';
|
import { defaultTheme } from './default-theme';
|
||||||
import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
||||||
import { VimHandler } from './vim';
|
import { VimHandler } from './vim';
|
||||||
|
import type { DelimiterMatch } from './types';
|
||||||
import { type MacroDef } from './macros';
|
import { type MacroDef } from './macros';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
|
* WYSIWYG markdown editor. Extends Ribbit's read-only viewer with
|
||||||
|
* contentEditable support, live inline transforms (typing `**bold**`
|
||||||
|
* immediately wraps in `<strong>`), and source editing mode.
|
||||||
*
|
*
|
||||||
* Extends Ribbit with contentEditable support and bidirectional
|
|
||||||
* markdown↔HTML conversion on mode switches.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* const editor = new RibbitEditor({ editorId: 'my-element' });
|
* const editor = new RibbitEditor({ editorId: 'my-element' });
|
||||||
* editor.run();
|
* editor.run();
|
||||||
* editor.wysiwyg(); // switch to WYSIWYG mode
|
* editor.wysiwyg();
|
||||||
* editor.edit(); // switch to source editing mode
|
|
||||||
* editor.view(); // switch to read-only view
|
|
||||||
*/
|
*/
|
||||||
export class RibbitEditor extends Ribbit {
|
export class RibbitEditor extends Ribbit {
|
||||||
private vim?: VimHandler;
|
private vim?: VimHandler;
|
||||||
|
|
||||||
|
// Elements that must not be nested inside each other.
|
||||||
|
// Used by transformInline and rebuildBlock to prevent
|
||||||
|
// invalid structures like <em> inside <em>.
|
||||||
|
private static readonly forbiddenNesting: Record<string, string[]> = {
|
||||||
|
'strong': ['strong', 'b'],
|
||||||
|
'em': ['em', 'i'],
|
||||||
|
'del': ['del', 's', 'strike'],
|
||||||
|
'code': ['code', 'strong', 'b', 'em', 'i', 'a', 'del'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the editor with all three modes (view/edit/wysiwyg),
|
||||||
|
* bind DOM events, and optionally attach vim keybindings.
|
||||||
|
*
|
||||||
|
* const editor = new RibbitEditor({ editorId: 'content' });
|
||||||
|
* editor.run();
|
||||||
|
*/
|
||||||
run(): void {
|
run(): void {
|
||||||
this.states = {
|
this.states = {
|
||||||
VIEW: 'view',
|
VIEW: 'view',
|
||||||
|
|
@ -72,20 +86,20 @@ export class RibbitEditor extends Ribbit {
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.element.addEventListener('keydown', (e: KeyboardEvent) => {
|
this.element.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||||
if (this.state !== this.states.WYSIWYG) {
|
if (this.state !== this.states.WYSIWYG) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
this.handleEnter(e);
|
this.handleEnter(event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.element.addEventListener('keyup', (e: KeyboardEvent) => {
|
this.element.addEventListener('keyup', (event: KeyboardEvent) => {
|
||||||
if (this.state !== this.states.WYSIWYG) {
|
if (this.state !== this.states.WYSIWYG) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key.startsWith('Arrow')) {
|
if (event.key.startsWith('Arrow')) {
|
||||||
this.closeOrphanedSpeculative();
|
this.closeOrphanedSpeculative();
|
||||||
this.updateEditingContext();
|
this.updateEditingContext();
|
||||||
}
|
}
|
||||||
|
|
@ -105,11 +119,11 @@ export class RibbitEditor extends Ribbit {
|
||||||
this.closeOrphanedSpeculative();
|
this.closeOrphanedSpeculative();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('click', (e: MouseEvent) => {
|
document.addEventListener('click', (event: MouseEvent) => {
|
||||||
if (this.state !== this.states.WYSIWYG) {
|
if (this.state !== this.states.WYSIWYG) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.element.contains(e.target as Node)) {
|
if (!this.element.contains(event.target as Node)) {
|
||||||
this.closeAllSpeculative();
|
this.closeAllSpeculative();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -124,11 +138,9 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the block-level element containing the cursor.
|
* Browsers create bare <div> and <br> elements in contentEditable
|
||||||
*/
|
* that aren't valid markdown block containers. Convert them to <p>
|
||||||
/**
|
* so every editor child is a recognized block element.
|
||||||
* Ensure the editor contains valid block structure.
|
|
||||||
* Wraps bare <br> and <div> elements in <p> tags.
|
|
||||||
*/
|
*/
|
||||||
private ensureBlockStructure(): void {
|
private ensureBlockStructure(): void {
|
||||||
for (const child of Array.from(this.element.childNodes)) {
|
for (const child of Array.from(this.element.childNodes)) {
|
||||||
|
|
@ -147,9 +159,10 @@ export class RibbitEditor extends Ribbit {
|
||||||
p.innerHTML = '<br>';
|
p.innerHTML = '<br>';
|
||||||
}
|
}
|
||||||
element.replaceWith(p);
|
element.replaceWith(p);
|
||||||
// Restore cursor inside the new <p>
|
// Cursor must follow the content into the new <p>,
|
||||||
const sel = window.getSelection();
|
// otherwise the next keystroke creates another <div>
|
||||||
if (sel && sel.rangeCount > 0) {
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.rangeCount > 0) {
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
const target = p.lastChild || p;
|
const target = p.lastChild || p;
|
||||||
if (target.nodeType === 3) {
|
if (target.nodeType === 3) {
|
||||||
|
|
@ -158,8 +171,8 @@ export class RibbitEditor extends Ribbit {
|
||||||
range.selectNodeContents(target);
|
range.selectNodeContents(target);
|
||||||
range.collapse(false);
|
range.collapse(false);
|
||||||
}
|
}
|
||||||
sel.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
sel.addRange(range);
|
selection.addRange(range);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,25 +182,30 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk up from the cursor to find the nearest block-level ancestor.
|
||||||
|
* Returns <li> for list items (not the <ul>/<ol>) because list items
|
||||||
|
* are the editable unit inside a list.
|
||||||
|
*/
|
||||||
private findCurrentBlock(): HTMLElement | null {
|
private findCurrentBlock(): HTMLElement | null {
|
||||||
const sel = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!sel || sel.rangeCount === 0) {
|
if (!selection || selection.rangeCount === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
let node: Node | null = sel.anchorNode;
|
let node: Node | null = selection.anchorNode;
|
||||||
|
|
||||||
// If cursor is in a text node directly inside the editor,
|
// Bare text nodes in contentEditable cause cursor issues;
|
||||||
// wrap it in a <p> first (browsers don't always do this).
|
// wrap in <p> before the browser can create a <div> around it
|
||||||
if (node && node.nodeType === 3 && node.parentNode === this.element) {
|
if (node && node.nodeType === 3 && node.parentNode === this.element) {
|
||||||
const p = document.createElement('p');
|
const p = document.createElement('p');
|
||||||
node.parentNode.insertBefore(p, node);
|
node.parentNode.insertBefore(p, node);
|
||||||
p.appendChild(node);
|
p.appendChild(node);
|
||||||
// Restore cursor inside the new <p>
|
// Restore cursor inside the new <p> so typing continues there
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.setStart(node, sel.anchorOffset);
|
range.setStart(node, selection.anchorOffset);
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
sel.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
sel.addRange(range);
|
selection.addRange(range);
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,17 +222,19 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check the current block's text for markdown patterns and
|
* Detect markdown block syntax at the start of the current line
|
||||||
* transform the DOM element in-place if a pattern matches.
|
* and transform the DOM element in-place. Runs on every input event.
|
||||||
|
* Non-breaking spaces are normalized because browsers insert
|
||||||
|
* in contentEditable instead of regular spaces.
|
||||||
*/
|
*/
|
||||||
private transformCurrentBlock(): void {
|
private transformCurrentBlock(): void {
|
||||||
const block = this.findCurrentBlock();
|
const block = this.findCurrentBlock();
|
||||||
if (!block) {
|
if (!block) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Normalize → space so patterns like "- " and "> " match
|
||||||
const text = (block.textContent || '').replace(/\u00A0/g, ' ');
|
const text = (block.textContent || '').replace(/\u00A0/g, ' ');
|
||||||
|
|
||||||
// Heading: # through ######
|
|
||||||
const headingMatch = text.match(/^(#{1,6})\s/);
|
const headingMatch = text.match(/^(#{1,6})\s/);
|
||||||
if (headingMatch) {
|
if (headingMatch) {
|
||||||
const level = headingMatch[1].length;
|
const level = headingMatch[1].length;
|
||||||
|
|
@ -225,13 +245,11 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blockquote: >
|
|
||||||
if (text.startsWith('> ') && block.tagName !== 'BLOCKQUOTE') {
|
if (text.startsWith('> ') && block.tagName !== 'BLOCKQUOTE') {
|
||||||
this.replaceBlock(block, 'BLOCKQUOTE', 2);
|
this.replaceBlock(block, 'BLOCKQUOTE', 2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horizontal rule: --- or *** or ___
|
|
||||||
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(text)) {
|
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(text)) {
|
||||||
const hr = document.createElement('hr');
|
const hr = document.createElement('hr');
|
||||||
const p = document.createElement('p');
|
const p = document.createElement('p');
|
||||||
|
|
@ -240,26 +258,23 @@ export class RibbitEditor extends Ribbit {
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.setStart(p, 0);
|
range.setStart(p, 0);
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
const sel = window.getSelection()!;
|
const selection = window.getSelection()!;
|
||||||
sel.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
sel.addRange(range);
|
selection.addRange(range);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unordered list: - or *
|
if (/^[-*+]\s/.test(text) && block.tagName !== 'LI') {
|
||||||
if (/^[-*]\s/.test(text) && block.tagName !== 'LI') {
|
|
||||||
this.replaceBlockWithList(block, 'ul', text.indexOf(' ') + 1);
|
this.replaceBlockWithList(block, 'ul', text.indexOf(' ') + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ordered list: 1.
|
|
||||||
if (/^\d+\.\s/.test(text) && block.tagName !== 'LI') {
|
if (/^\d+\.\s/.test(text) && block.tagName !== 'LI') {
|
||||||
this.replaceBlockWithList(block, 'ol', text.indexOf(' ') + 1);
|
this.replaceBlockWithList(block, 'ol', text.indexOf(' ') + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fenced code: ```
|
if ((text.startsWith('```') || text.startsWith('~~~')) && block.tagName !== 'PRE') {
|
||||||
if (text.startsWith('```') && block.tagName !== 'PRE') {
|
|
||||||
const pre = document.createElement('pre');
|
const pre = document.createElement('pre');
|
||||||
const code = document.createElement('code');
|
const code = document.createElement('code');
|
||||||
code.textContent = '';
|
code.textContent = '';
|
||||||
|
|
@ -268,40 +283,41 @@ export class RibbitEditor extends Ribbit {
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.setStart(code, 0);
|
range.setStart(code, 0);
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
const sel = window.getSelection()!;
|
const selection = window.getSelection()!;
|
||||||
sel.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
sel.addRange(range);
|
selection.addRange(range);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline transforms: flatten to markdown, transform, rebuild DOM
|
|
||||||
this.transformInline(block);
|
this.transformInline(block);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a block's DOM children to a mixed string where completed
|
* Serialize a block's children into a mixed string of markdown text
|
||||||
* inline elements are preserved as HTML and only speculative/text
|
* and sentinel-wrapped HTML. Completed inline elements (e.g. a
|
||||||
* content is flattened to markdown. Completed elements are wrapped
|
* finished `<strong>`) are preserved as HTML between \x01...\x02
|
||||||
* in sentinel markers so the regex engine skips them.
|
* markers so the transform regex won't re-match their delimiters.
|
||||||
|
* Speculative elements restore only their opening delimiter.
|
||||||
*/
|
*/
|
||||||
private blockToMarkdown(block: HTMLElement): string {
|
private blockToMarkdown(block: HTMLElement): string {
|
||||||
let md = '';
|
let markdown = '';
|
||||||
for (const child of Array.from(block.childNodes)) {
|
for (const child of Array.from(block.childNodes)) {
|
||||||
md += this.nodeToMarkdown(child);
|
markdown += this.nodeToMarkdown(child);
|
||||||
}
|
}
|
||||||
return md;
|
return markdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
private nodeToMarkdown(node: Node): string {
|
private nodeToMarkdown(node: Node): string {
|
||||||
if (node.nodeType === 3) {
|
if (node.nodeType === 3) {
|
||||||
return (node.textContent || '').replace(/\u200B/g, '');
|
return (node.textContent || '').replace(/\u200B/g, '');
|
||||||
}
|
}
|
||||||
if (node.nodeType !== 1) { return ''; }
|
if (node.nodeType !== 1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
const element = node as HTMLElement;
|
const element = node as HTMLElement;
|
||||||
const specDelim = element.getAttribute('data-speculative');
|
const specDelim = element.getAttribute('data-speculative');
|
||||||
|
|
||||||
if (specDelim) {
|
if (specDelim) {
|
||||||
// Speculative: restore opener delimiter + flatten children
|
|
||||||
let inner = '';
|
let inner = '';
|
||||||
for (const child of Array.from(element.childNodes)) {
|
for (const child of Array.from(element.childNodes)) {
|
||||||
inner += this.nodeToMarkdown(child);
|
inner += this.nodeToMarkdown(child);
|
||||||
|
|
@ -311,12 +327,9 @@ export class RibbitEditor extends Ribbit {
|
||||||
|
|
||||||
const tag = this.findTagForElement(element);
|
const tag = this.findTagForElement(element);
|
||||||
if (tag?.delimiter) {
|
if (tag?.delimiter) {
|
||||||
// Completed element: preserve as HTML, wrapped in sentinels
|
|
||||||
// so the complete-pair regex won't match across it
|
|
||||||
return '\x01' + element.outerHTML + '\x02';
|
return '\x01' + element.outerHTML + '\x02';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown element: flatten children
|
|
||||||
let inner = '';
|
let inner = '';
|
||||||
for (const child of Array.from(element.childNodes)) {
|
for (const child of Array.from(element.childNodes)) {
|
||||||
inner += this.nodeToMarkdown(child);
|
inner += this.nodeToMarkdown(child);
|
||||||
|
|
@ -325,120 +338,113 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the Tag definition that matches an HTML element.
|
* Look up the Tag definition for an HTML element by matching its
|
||||||
|
* tagName against registered inline tag selectors. Returns null
|
||||||
|
* for elements that aren't delimiter-based inline formatting.
|
||||||
*/
|
*/
|
||||||
private findTagForElement(el: HTMLElement): { delimiter?: string; name: string } | null {
|
private findTagForElement(element: HTMLElement): { delimiter?: string; name: string } | null {
|
||||||
const inlineTags = this.converter.getInlineTags();
|
return this.converter.getTagForElement(element);
|
||||||
for (const tag of inlineTags) {
|
|
||||||
if (!tag.delimiter) continue;
|
|
||||||
if (typeof tag.selector === 'string') {
|
|
||||||
const selectors = tag.selector.split(',');
|
|
||||||
if (selectors.some(s => el.tagName === s.trim())) {
|
|
||||||
return tag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatten the block to markdown, find and apply inline transforms,
|
* The core WYSIWYG pipeline: flatten → match → rebuild.
|
||||||
* then rebuild the DOM from the result.
|
*
|
||||||
|
* 1. Flatten the block's DOM to a markdown string (preserving
|
||||||
|
* completed elements as sentinel-wrapped HTML)
|
||||||
|
* 2. Match complete delimiter pairs and replace with HTML tags
|
||||||
|
* 3. Find one unclosed opener for speculative preview
|
||||||
|
* 4. Rebuild the block's DOM from the result string
|
||||||
|
*
|
||||||
|
* Sentinel markers (\x01...\x02) prevent the regex from matching
|
||||||
|
* delimiters that belong to already-transformed elements.
|
||||||
*/
|
*/
|
||||||
private transformInline(block: HTMLElement): void {
|
private transformInline(block: HTMLElement): void {
|
||||||
const sel = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!sel || sel.rangeCount === 0) return;
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let md = this.blockToMarkdown(block);
|
let markdown = this.blockToMarkdown(block);
|
||||||
if (md.replace(/\s/g, '').length < 2) return;
|
if (markdown.replace(/\s/g, '').length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const inlineTags = this.converter.getInlineTags();
|
// Nesting rules: which elements must not appear inside which
|
||||||
const sorted = [...inlineTags]
|
const forbiddenChildren = RibbitEditor.forbiddenNesting;
|
||||||
.filter(tag => tag.delimiter)
|
|
||||||
.sort((a, b) => (a.precedence ?? 50) - (b.precedence ?? 50));
|
|
||||||
|
|
||||||
// Build regex for each tag with exact-delimiter matching.
|
|
||||||
// [^\x01\x02] prevents matching across preserved HTML elements.
|
|
||||||
const tagRegexes = sorted.map(tag => {
|
|
||||||
const delim = tag.delimiter!;
|
|
||||||
const escaped = delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
const ec = delim[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
return {
|
|
||||||
tag,
|
|
||||||
complete: new RegExp(`(?<!${ec})${escaped}(?!${ec})([^\\x01\\x02]+?)(?<!${ec})${escaped}`),
|
|
||||||
open: new RegExp(`(?<!${ec})${escaped}(?!${ec})([^\\x01\\x02]+)$`),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply complete pairs repeatedly until none match
|
|
||||||
const forbiddenChildren: Record<string, string[]> = {
|
|
||||||
'strong': ['strong', 'b'],
|
|
||||||
'em': ['em', 'i'],
|
|
||||||
'code': ['code', 'strong', 'b', 'em', 'i', 'a'],
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Apply complete pairs until stable (each match restarts
|
||||||
|
// because the replacement may enable new matches)
|
||||||
let changed = true;
|
let changed = true;
|
||||||
while (changed) {
|
while (changed) {
|
||||||
changed = false;
|
changed = false;
|
||||||
for (const { tag, complete } of tagRegexes) {
|
const pair = this.converter.findCompletePair(markdown);
|
||||||
const match = md.match(complete);
|
if (!pair) {
|
||||||
if (match && match.index !== undefined) {
|
break;
|
||||||
const tagName = tag.name === 'boldItalic' ? 'em' : (tag.selector as string).split(',')[0].toLowerCase();
|
|
||||||
|
|
||||||
// Skip if wrapping would create forbidden nesting
|
|
||||||
const banned = forbiddenChildren[tagName];
|
|
||||||
if (banned && banned.some(t => match[1].includes('<' + t))) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = tagName === 'code'
|
const banned = forbiddenChildren[pair.htmlTag];
|
||||||
? match[1].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
if (banned && banned.some(tag => pair.content.includes('<' + tag))) {
|
||||||
: match[1];
|
break;
|
||||||
const inner = tag.name === 'boldItalic'
|
}
|
||||||
? `\x01<${tagName}><strong>${content}</strong></${tagName}>\x02`
|
|
||||||
: `\x01<${tagName}>${content}</${tagName}>\x02`;
|
// HTML entities in code content would be parsed as
|
||||||
md = md.slice(0, match.index) + inner + md.slice(match.index + match[0].length);
|
// real elements by innerHTML (e.g. `<div>` → actual <div>)
|
||||||
|
const content = pair.htmlTag === 'code'
|
||||||
|
? pair.content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
: pair.content;
|
||||||
|
const inner = pair.tag.name === 'boldItalic'
|
||||||
|
? `\x01<${pair.htmlTag}><strong>${content}</strong></${pair.htmlTag}>\x02`
|
||||||
|
: `\x01<${pair.htmlTag}>${content}</${pair.htmlTag}>\x02`;
|
||||||
|
markdown = markdown.slice(0, pair.index) + inner + markdown.slice(pair.index + pair.length);
|
||||||
changed = true;
|
changed = true;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip sentinel markers now that complete-pair matching is done
|
// Strip sentinels now — the speculative check below needs to
|
||||||
md = md.replace(/[\x01\x02]/g, '');
|
// see the actual HTML tags to detect forbidden nesting
|
||||||
|
markdown = markdown.replace(/[\x01\x02]/g, '');
|
||||||
|
|
||||||
// Check for one unclosed opener (speculative)
|
const opener = this.converter.findUnmatchedOpener(markdown);
|
||||||
let speculativeTag: typeof sorted[0] | null = null;
|
|
||||||
let speculativeMatch: RegExpMatchArray | null = null;
|
this.rebuildBlock(block, markdown, opener, forbiddenChildren);
|
||||||
for (const { tag, open } of tagRegexes) {
|
|
||||||
const match = md.match(open);
|
|
||||||
if (match && match.index !== undefined) {
|
|
||||||
// Make sure this isn't inside an HTML tag we just created
|
|
||||||
const before = md.slice(0, match.index);
|
|
||||||
if (!before.endsWith('<') && !before.endsWith('/')) {
|
|
||||||
speculativeTag = tag;
|
|
||||||
speculativeMatch = match;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild the DOM
|
/**
|
||||||
if (speculativeMatch && speculativeTag) {
|
* Rebuild a block's DOM from the transformed markdown string.
|
||||||
const tagName = speculativeTag.name === 'boldItalic' ? 'em' : (speculativeTag.selector as string).split(',')[0].toLowerCase();
|
* If an unclosed opener was found, wrap the trailing content in
|
||||||
const inside = md.slice(speculativeMatch.index! + speculativeTag.delimiter!.length);
|
* a speculative element; otherwise set innerHTML directly.
|
||||||
|
*/
|
||||||
|
private rebuildBlock(
|
||||||
|
block: HTMLElement,
|
||||||
|
markdown: string,
|
||||||
|
opener: DelimiterMatch | null,
|
||||||
|
forbiddenChildren: Record<string, string[]>,
|
||||||
|
): void {
|
||||||
|
if (!opener) {
|
||||||
|
block.innerHTML = markdown;
|
||||||
|
this.sanitizeNesting(block);
|
||||||
|
this.appendZwsIfNeeded(block);
|
||||||
|
this.placeCursorAtEnd(block);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inside = markdown.slice(opener.index + opener.delimiter.length);
|
||||||
|
const banned = forbiddenChildren[opener.htmlTag];
|
||||||
|
|
||||||
// Check for forbidden nesting before wrapping
|
// Check for forbidden nesting before wrapping
|
||||||
const probe = document.createElement('div');
|
const probe = document.createElement('div');
|
||||||
probe.innerHTML = inside;
|
probe.innerHTML = inside;
|
||||||
const banned = forbiddenChildren[tagName];
|
if (banned && banned.some(tag => probe.querySelector(tag))) {
|
||||||
const wouldNest = banned && banned.some(tag => probe.querySelector(tag));
|
block.innerHTML = markdown;
|
||||||
|
this.sanitizeNesting(block);
|
||||||
|
this.appendZwsIfNeeded(block);
|
||||||
|
this.placeCursorAtEnd(block);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!wouldNest) {
|
const before = markdown.slice(0, opener.index);
|
||||||
const before = md.slice(0, speculativeMatch.index!);
|
const wrapper = document.createElement(opener.htmlTag);
|
||||||
const wrapper = document.createElement(tagName);
|
|
||||||
wrapper.classList.add('ribbit-editing');
|
wrapper.classList.add('ribbit-editing');
|
||||||
wrapper.setAttribute('data-speculative', speculativeTag.delimiter!);
|
wrapper.setAttribute('data-speculative', opener.delimiter);
|
||||||
wrapper.innerHTML = inside;
|
wrapper.innerHTML = inside;
|
||||||
this.sanitizeNesting(wrapper);
|
this.sanitizeNesting(wrapper);
|
||||||
|
|
||||||
|
|
@ -449,39 +455,31 @@ export class RibbitEditor extends Ribbit {
|
||||||
block.appendChild(wrapper);
|
block.appendChild(wrapper);
|
||||||
// ZWS after wrapper so arrow-right can escape the element
|
// ZWS after wrapper so arrow-right can escape the element
|
||||||
block.appendChild(document.createTextNode('\u200B'));
|
block.appendChild(document.createTextNode('\u200B'));
|
||||||
|
|
||||||
// Cursor at end of speculative element
|
|
||||||
this.placeCursorAtEnd(wrapper);
|
this.placeCursorAtEnd(wrapper);
|
||||||
} else {
|
}
|
||||||
// Forbidden nesting — fall through to plain innerHTML
|
|
||||||
block.innerHTML = md;
|
/**
|
||||||
this.sanitizeNesting(block);
|
* Append a zero-width space after the last child if it's an element,
|
||||||
|
* so the cursor can land outside it instead of inside.
|
||||||
|
*/
|
||||||
|
private appendZwsIfNeeded(block: HTMLElement): void {
|
||||||
if (block.lastChild && block.lastChild.nodeType === 1) {
|
if (block.lastChild && block.lastChild.nodeType === 1) {
|
||||||
block.appendChild(document.createTextNode('\u200B'));
|
block.appendChild(document.createTextNode('\u200B'));
|
||||||
}
|
}
|
||||||
this.placeCursorAtEnd(block);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
block.innerHTML = md;
|
|
||||||
this.sanitizeNesting(block);
|
|
||||||
// If the block ends with an HTML element, append a ZWS text
|
|
||||||
// node so the cursor lands outside the element, not inside it.
|
|
||||||
if (block.lastChild && block.lastChild.nodeType === 1) {
|
|
||||||
block.appendChild(document.createTextNode('\u200B'));
|
|
||||||
}
|
|
||||||
this.placeCursorAtEnd(block);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Place the cursor at the end of an element's content.
|
* Place the cursor at the deepest last text node inside an element.
|
||||||
|
* Used after DOM rebuilds to restore the cursor to where the user
|
||||||
|
* was typing.
|
||||||
*/
|
*/
|
||||||
private placeCursorAtEnd(el: HTMLElement): void {
|
private placeCursorAtEnd(element: HTMLElement): void {
|
||||||
const sel = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!sel) return;
|
if (!selection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
// Find the deepest last text node
|
let target: Node = element;
|
||||||
let target: Node = el;
|
|
||||||
while (target.lastChild) {
|
while (target.lastChild) {
|
||||||
target = target.lastChild;
|
target = target.lastChild;
|
||||||
}
|
}
|
||||||
|
|
@ -492,15 +490,13 @@ export class RibbitEditor extends Ribbit {
|
||||||
range.collapse(false);
|
range.collapse(false);
|
||||||
}
|
}
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
sel.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
sel.addRange(range);
|
selection.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Replace a block element with a different tag (e.g. <p> → <h1>),
|
||||||
/**
|
* stripping the markdown prefix (e.g. "# ") from the content.
|
||||||
* Replace a block element with a new tag, stripping the prefix
|
|
||||||
* and preserving cursor position.
|
|
||||||
*/
|
*/
|
||||||
private replaceBlock(block: HTMLElement, newTag: string, prefixLength: number): void {
|
private replaceBlock(block: HTMLElement, newTag: string, prefixLength: number): void {
|
||||||
const newEl = document.createElement(newTag);
|
const newEl = document.createElement(newTag);
|
||||||
|
|
@ -513,7 +509,7 @@ export class RibbitEditor extends Ribbit {
|
||||||
block.replaceWith(newEl);
|
block.replaceWith(newEl);
|
||||||
newEl.classList.add('ribbit-editing');
|
newEl.classList.add('ribbit-editing');
|
||||||
|
|
||||||
// Place cursor at start of content
|
// Cursor at start so the user sees the content, not the prefix
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
if (newEl.firstChild && newEl.firstChild.nodeType === 3) {
|
if (newEl.firstChild && newEl.firstChild.nodeType === 3) {
|
||||||
range.setStart(newEl.firstChild, 0);
|
range.setStart(newEl.firstChild, 0);
|
||||||
|
|
@ -521,13 +517,14 @@ export class RibbitEditor extends Ribbit {
|
||||||
range.setStart(newEl, 0);
|
range.setStart(newEl, 0);
|
||||||
}
|
}
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
const sel = window.getSelection()!;
|
const selection = window.getSelection()!;
|
||||||
sel.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
sel.addRange(range);
|
selection.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace a block element with a list (ul/ol) containing one item.
|
* Replace a block element with a list containing one item.
|
||||||
|
* Triggered when the user types "- " or "1. " at the start of a line.
|
||||||
*/
|
*/
|
||||||
private replaceBlockWithList(block: HTMLElement, listTag: string, prefixLength: number): void {
|
private replaceBlockWithList(block: HTMLElement, listTag: string, prefixLength: number): void {
|
||||||
const list = document.createElement(listTag);
|
const list = document.createElement(listTag);
|
||||||
|
|
@ -548,16 +545,16 @@ export class RibbitEditor extends Ribbit {
|
||||||
range.setStart(li, 0);
|
range.setStart(li, 0);
|
||||||
}
|
}
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
const sel = window.getSelection()!;
|
const selection = window.getSelection()!;
|
||||||
sel.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
sel.addRange(range);
|
selection.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle Enter key: strip syntax decorations from the current
|
* On Enter, strip editing decorations from the current block so
|
||||||
* block before the browser creates a new line.
|
* the browser's default newline behavior creates a clean element.
|
||||||
*/
|
*/
|
||||||
private handleEnter(e: KeyboardEvent): void {
|
private handleEnter(_event: KeyboardEvent): void {
|
||||||
const prev = this.element.querySelector('.ribbit-editing');
|
const prev = this.element.querySelector('.ribbit-editing');
|
||||||
if (prev) {
|
if (prev) {
|
||||||
prev.classList.remove('ribbit-editing');
|
prev.classList.remove('ribbit-editing');
|
||||||
|
|
@ -566,25 +563,15 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close any speculative elements that the cursor is no longer inside.
|
* Replace an element with its children. Used to dissolve speculative
|
||||||
* Called on every selection change — handles arrow keys, clicks,
|
* wrappers and fix forbidden nesting — the formatting is removed
|
||||||
* tab switches, and any other cursor movement.
|
* but the text content is preserved.
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Unwrap a speculative element, replacing it with its children.
|
|
||||||
* An orphaned speculative element was never completed — it should
|
|
||||||
* not become permanent formatting.
|
|
||||||
*/
|
|
||||||
private unwrapSpeculative(element: HTMLElement): void {
|
|
||||||
this.unwrapElement(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace an element with its children, preserving content.
|
|
||||||
*/
|
*/
|
||||||
private unwrapElement(element: HTMLElement): void {
|
private unwrapElement(element: HTMLElement): void {
|
||||||
const parent = element.parentNode;
|
const parent = element.parentNode;
|
||||||
if (!parent) { return; }
|
if (!parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
while (element.firstChild) {
|
while (element.firstChild) {
|
||||||
parent.insertBefore(element.firstChild, element);
|
parent.insertBefore(element.firstChild, element);
|
||||||
}
|
}
|
||||||
|
|
@ -592,8 +579,9 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove forbidden nesting from a block element.
|
* Remove forbidden nesting (e.g. <em> inside <em>, <strong> inside
|
||||||
* For example, <em> inside <em>, <strong> inside <code>, etc.
|
* <code>) by unwrapping the inner element. Runs as a post-processing
|
||||||
|
* pass after innerHTML is set, catching cases the regex guards miss.
|
||||||
*/
|
*/
|
||||||
private sanitizeNesting(block: HTMLElement): void {
|
private sanitizeNesting(block: HTMLElement): void {
|
||||||
const rules: Record<string, string[]> = {
|
const rules: Record<string, string[]> = {
|
||||||
|
|
@ -601,7 +589,8 @@ export class RibbitEditor extends Ribbit {
|
||||||
'B': ['STRONG', 'B'],
|
'B': ['STRONG', 'B'],
|
||||||
'EM': ['EM', 'I'],
|
'EM': ['EM', 'I'],
|
||||||
'I': ['EM', 'I'],
|
'I': ['EM', 'I'],
|
||||||
'CODE': ['CODE', 'STRONG', 'B', 'EM', 'I', 'A'],
|
'DEL': ['DEL', 'S', 'STRIKE'],
|
||||||
|
'CODE': ['CODE', 'STRONG', 'B', 'EM', 'I', 'A', 'DEL'],
|
||||||
};
|
};
|
||||||
let found = true;
|
let found = true;
|
||||||
while (found) {
|
while (found) {
|
||||||
|
|
@ -621,55 +610,69 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwrap all speculative elements. Called when the user clicks
|
||||||
|
* outside the editor — nothing should remain speculative.
|
||||||
|
*/
|
||||||
private closeAllSpeculative(): void {
|
private closeAllSpeculative(): void {
|
||||||
for (const element of Array.from(this.element.querySelectorAll('[data-speculative]'))) {
|
for (const element of Array.from(this.element.querySelectorAll('[data-speculative]'))) {
|
||||||
this.unwrapSpeculative(element as HTMLElement);
|
this.unwrapElement(element as HTMLElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwrap speculative elements the cursor has left. An orphaned
|
||||||
|
* speculative element was never completed by the user, so it
|
||||||
|
* should not become permanent formatting.
|
||||||
|
*/
|
||||||
private closeOrphanedSpeculative(): void {
|
private closeOrphanedSpeculative(): void {
|
||||||
const speculative = this.element.querySelectorAll('[data-speculative]');
|
const speculative = this.element.querySelectorAll('[data-speculative]');
|
||||||
if (speculative.length === 0) { return; }
|
if (speculative.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const sel = window.getSelection();
|
const selection = window.getSelection();
|
||||||
const anchor = sel?.anchorNode;
|
const anchor = selection?.anchorNode;
|
||||||
|
|
||||||
for (const el of Array.from(speculative)) {
|
for (const element of Array.from(speculative)) {
|
||||||
const htmlEl = el as HTMLElement;
|
const htmlElement = element as HTMLElement;
|
||||||
let inside = false;
|
let inside = false;
|
||||||
let node: Node | null = anchor || null;
|
let node: Node | null = anchor || null;
|
||||||
while (node) {
|
while (node) {
|
||||||
if (node === htmlEl) {
|
if (node === htmlElement) {
|
||||||
inside = true;
|
inside = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
node = node.parentNode;
|
node = node.parentNode;
|
||||||
}
|
}
|
||||||
if (!inside) {
|
if (!inside) {
|
||||||
this.unwrapSpeculative(htmlEl);
|
this.unwrapElement(htmlElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track which formatting element contains the cursor and toggle
|
* Toggle .ribbit-editing on the formatting element containing the
|
||||||
* the .ribbit-editing class so CSS ::before/::after show delimiters.
|
* cursor. CSS uses this class to show delimiter pseudo-elements
|
||||||
|
* (::before/::after) so the user sees the markdown syntax.
|
||||||
*/
|
*/
|
||||||
private updateEditingContext(): void {
|
private updateEditingContext(): void {
|
||||||
const prev = this.element.querySelector('.ribbit-editing');
|
const prev = this.element.querySelector('.ribbit-editing');
|
||||||
if (prev) {
|
if (prev) {
|
||||||
prev.classList.remove('ribbit-editing');
|
prev.classList.remove('ribbit-editing');
|
||||||
}
|
}
|
||||||
const sel = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!sel || sel.rangeCount === 0) {
|
if (!selection || selection.rangeCount === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let node: Node | null = sel.anchorNode;
|
let node: Node | null = selection.anchorNode;
|
||||||
while (node && node !== this.element) {
|
while (node && node !== this.element) {
|
||||||
if (node.nodeType === 1) {
|
if (node.nodeType === 1) {
|
||||||
const el = node as HTMLElement;
|
const element = node as HTMLElement;
|
||||||
if (el.matches('strong, b, em, i, code, h1, h2, h3, h4, h5, h6, blockquote')) {
|
// Derive the selector list from registered tags so it
|
||||||
el.classList.add('ribbit-editing');
|
// stays in sync when tags are added or removed
|
||||||
|
if (element.matches(this.converter.getEditableSelector())) {
|
||||||
|
element.classList.add('ribbit-editing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -677,22 +680,43 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the editor's current HTML back to markdown.
|
||||||
|
*
|
||||||
|
* const md = editor.htmlToMarkdown();
|
||||||
|
* const md2 = editor.htmlToMarkdown('<p><strong>hi</strong></p>');
|
||||||
|
*/
|
||||||
htmlToMarkdown(html?: string): string {
|
htmlToMarkdown(html?: string): string {
|
||||||
return this.converter.toMarkdown(html || this.element.innerHTML);
|
return this.converter.toMarkdown(html || this.element.innerHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the markdown representation of the current content.
|
||||||
|
* Behavior depends on mode: edit mode decodes HTML entities from
|
||||||
|
* the raw source; wysiwyg mode converts the DOM back to markdown.
|
||||||
|
*
|
||||||
|
* const md = editor.getMarkdown();
|
||||||
|
*/
|
||||||
getMarkdown(): string {
|
getMarkdown(): string {
|
||||||
if (this.getState() === this.states.EDIT) {
|
if (this.getState() === this.states.EDIT) {
|
||||||
let html = this.element.innerHTML;
|
let html = this.element.innerHTML;
|
||||||
html = html.replace(/<(?:div|br)>/ig, '');
|
html = html.replace(/<(?:div|br)>/ig, '');
|
||||||
html = html.replace(/<\/div>/ig, '\n');
|
html = html.replace(/<\/div>/ig, '\n');
|
||||||
return decodeHtmlEntities(html);
|
return decodeHtmlEntities(html);
|
||||||
} else if (this.getState() === this.states.WYSIWYG) {
|
}
|
||||||
|
if (this.getState() === this.states.WYSIWYG || this.getState() === this.states.VIEW) {
|
||||||
return this.htmlToMarkdown(this.element.innerHTML);
|
return this.htmlToMarkdown(this.element.innerHTML);
|
||||||
}
|
}
|
||||||
|
// Before run() — element has raw markdown as text
|
||||||
return this.element.textContent || '';
|
return this.element.textContent || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to WYSIWYG mode with live inline transforms.
|
||||||
|
*
|
||||||
|
* editor.wysiwyg();
|
||||||
|
* // now typing **bold** immediately wraps in <strong>
|
||||||
|
*/
|
||||||
wysiwyg(): void {
|
wysiwyg(): void {
|
||||||
if (this.getState() === this.states.WYSIWYG) return;
|
if (this.getState() === this.states.WYSIWYG) return;
|
||||||
const wasEditing = this.getState() === this.states.EDIT;
|
const wasEditing = this.getState() === this.states.EDIT;
|
||||||
|
|
@ -703,20 +727,26 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
this.element.contentEditable = 'true';
|
this.element.contentEditable = 'true';
|
||||||
this.element.innerHTML = this.getHTML();
|
this.element.innerHTML = this.getHTML();
|
||||||
// Ensure there's at least one block element for the cursor
|
// Ensure there's a block element for the cursor to land in
|
||||||
if (!this.element.firstElementChild) {
|
if (!this.element.firstElementChild) {
|
||||||
this.element.innerHTML = '<p><br></p>';
|
this.element.innerHTML = '<p><br></p>';
|
||||||
}
|
}
|
||||||
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
Array.from(this.element.querySelectorAll('.macro')).forEach(macroElement => {
|
||||||
const macroEl = el as HTMLElement;
|
const htmlMacro = macroElement as HTMLElement;
|
||||||
if (macroEl.dataset.editable === 'false') {
|
if (htmlMacro.dataset.editable === 'false') {
|
||||||
macroEl.contentEditable = 'false';
|
htmlMacro.contentEditable = 'false';
|
||||||
macroEl.style.opacity = '0.5';
|
htmlMacro.style.opacity = '0.5';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.setState(this.states.WYSIWYG);
|
this.setState(this.states.WYSIWYG);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to source editing mode (raw markdown). Requires the theme
|
||||||
|
* to have sourceMode enabled. Attaches vim keybindings if configured.
|
||||||
|
*
|
||||||
|
* editor.edit();
|
||||||
|
*/
|
||||||
edit(): void {
|
edit(): void {
|
||||||
if (!this.theme.features?.sourceMode) {
|
if (!this.theme.features?.sourceMode) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -730,15 +760,23 @@ export class RibbitEditor extends Ribbit {
|
||||||
this.setState(this.states.EDIT);
|
this.setState(this.states.EDIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a DOM node at the current cursor position. Used by toolbar
|
||||||
|
* buttons and macros to inject content.
|
||||||
|
*
|
||||||
|
* const img = document.createElement('img');
|
||||||
|
* img.src = '/photo.jpg';
|
||||||
|
* editor.insertAtCursor(img);
|
||||||
|
*/
|
||||||
insertAtCursor(node: Node): void {
|
insertAtCursor(node: Node): void {
|
||||||
const sel = window.getSelection()!;
|
const selection = window.getSelection()!;
|
||||||
const range = sel.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
range.deleteContents();
|
range.deleteContents();
|
||||||
range.insertNode(node);
|
range.insertNode(node);
|
||||||
range.setStartAfter(node);
|
range.setStartAfter(node);
|
||||||
this.element.focus();
|
this.element.focus();
|
||||||
sel.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
sel.addRange(range);
|
selection.addRange(range);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
150
src/ts/ribbit.ts
150
src/ts/ribbit.ts
|
|
@ -27,7 +27,12 @@ export interface RibbitSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only markdown viewer. Renders markdown content into an HTML element.
|
* Base class providing read-only markdown rendering. RibbitEditor extends
|
||||||
|
* this with editing capabilities, so consumers who only need to display
|
||||||
|
* rendered markdown can use Ribbit directly and avoid loading editor code.
|
||||||
|
*
|
||||||
|
* const viewer = new Ribbit({ editorId: 'my-element' });
|
||||||
|
* viewer.run();
|
||||||
*/
|
*/
|
||||||
export class Ribbit {
|
export class Ribbit {
|
||||||
api: unknown;
|
api: unknown;
|
||||||
|
|
@ -36,7 +41,6 @@ export class Ribbit {
|
||||||
cachedHTML: string | null;
|
cachedHTML: string | null;
|
||||||
cachedMarkdown: string | null;
|
cachedMarkdown: string | null;
|
||||||
state: string | null;
|
state: string | null;
|
||||||
changed: boolean;
|
|
||||||
theme: RibbitTheme;
|
theme: RibbitTheme;
|
||||||
themes: ThemeManager;
|
themes: ThemeManager;
|
||||||
converter: HopDown;
|
converter: HopDown;
|
||||||
|
|
@ -59,7 +63,6 @@ export class Ribbit {
|
||||||
this.cachedHTML = null;
|
this.cachedHTML = null;
|
||||||
this.cachedMarkdown = null;
|
this.cachedMarkdown = null;
|
||||||
this.state = null;
|
this.state = null;
|
||||||
this.changed = false;
|
|
||||||
|
|
||||||
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
|
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
|
||||||
this.theme = theme;
|
this.theme = theme;
|
||||||
|
|
@ -138,10 +141,23 @@ export class Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to editor events. Callbacks persist across mode switches.
|
||||||
|
*
|
||||||
|
* editor.on('change', ({ markdown, html }) => console.log(markdown));
|
||||||
|
* editor.on('save', ({ markdown }) => fetch('/api', { 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe a previously registered event callback.
|
||||||
|
*
|
||||||
|
* const handler = (e) => console.log(e);
|
||||||
|
* editor.on('change', handler);
|
||||||
|
* editor.off('change', handler);
|
||||||
|
*/
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
@ -155,6 +171,13 @@ export class Ribbit {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the viewer: render toolbar, switch to view mode, and
|
||||||
|
* fire the ready event. Call once after construction.
|
||||||
|
*
|
||||||
|
* const viewer = new Ribbit({ editorId: 'content' });
|
||||||
|
* viewer.run();
|
||||||
|
*/
|
||||||
run(): void {
|
run(): void {
|
||||||
this.element.classList.add('loaded');
|
this.element.classList.add('loaded');
|
||||||
if (this.autoToolbar) {
|
if (this.autoToolbar) {
|
||||||
|
|
@ -164,10 +187,21 @@ export class Ribbit {
|
||||||
this.emitReady();
|
this.emitReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current mode name ('view', 'edit', or 'wysiwyg').
|
||||||
|
*
|
||||||
|
* if (editor.getState() === 'wysiwyg') { ... }
|
||||||
|
*/
|
||||||
getState(): string | null {
|
getState(): string | null {
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition to a new mode. Updates CSS classes on the editor element
|
||||||
|
* so themes can style each mode differently, and fires modeChange.
|
||||||
|
*
|
||||||
|
* editor.setState('edit');
|
||||||
|
*/
|
||||||
setState(newState: string): void {
|
setState(newState: string): void {
|
||||||
const previous = this.state;
|
const previous = this.state;
|
||||||
if (previous) {
|
if (previous) {
|
||||||
|
|
@ -181,10 +215,20 @@ export class Ribbit {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
markdownToHTML(md: string): string {
|
/**
|
||||||
return this.converter.toHTML(md);
|
* One-shot markdown→HTML conversion using the current theme's tags.
|
||||||
|
*
|
||||||
|
* const html = viewer.markdownToHTML('**hello**');
|
||||||
|
*/
|
||||||
|
markdownToHTML(markdown: string): string {
|
||||||
|
return this.converter.toHTML(markdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendered HTML of the current content, cached until invalidated.
|
||||||
|
*
|
||||||
|
* document.getElementById('preview').innerHTML = viewer.getHTML();
|
||||||
|
*/
|
||||||
getHTML(): string {
|
getHTML(): string {
|
||||||
if (this.cachedHTML === null) {
|
if (this.cachedHTML === null) {
|
||||||
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
|
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
|
||||||
|
|
@ -192,6 +236,12 @@ export class Ribbit {
|
||||||
return this.cachedHTML;
|
return this.cachedHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw markdown of the current content. In view mode this is the
|
||||||
|
* original text; in edit/wysiwyg mode it's derived from the DOM.
|
||||||
|
*
|
||||||
|
* fetch('/save', { body: editor.getMarkdown() });
|
||||||
|
*/
|
||||||
getMarkdown(): string {
|
getMarkdown(): string {
|
||||||
if (this.cachedMarkdown === null) {
|
if (this.cachedMarkdown === null) {
|
||||||
this.cachedMarkdown = this.element.textContent || '';
|
this.cachedMarkdown = this.element.textContent || '';
|
||||||
|
|
@ -199,6 +249,13 @@ export class Ribbit {
|
||||||
return this.cachedMarkdown;
|
return this.cachedMarkdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a save event with the current content. Ribbit never persists
|
||||||
|
* data itself — the consumer handles storage in the callback.
|
||||||
|
*
|
||||||
|
* editor.on('save', ({ markdown }) => localStorage.setItem('doc', markdown));
|
||||||
|
* editor.save();
|
||||||
|
*/
|
||||||
save(): void {
|
save(): void {
|
||||||
this.emitter.emit('save', {
|
this.emitter.emit('save', {
|
||||||
markdown: this.getMarkdown(),
|
markdown: this.getMarkdown(),
|
||||||
|
|
@ -206,6 +263,12 @@ export class Ribbit {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to read-only view mode. Renders markdown to HTML and
|
||||||
|
* disables contentEditable. Disconnects collaboration if active.
|
||||||
|
*
|
||||||
|
* editor.view();
|
||||||
|
*/
|
||||||
view(): void {
|
view(): void {
|
||||||
if (this.getState() === this.states.VIEW) return;
|
if (this.getState() === this.states.VIEW) return;
|
||||||
this.collaboration?.disconnect();
|
this.collaboration?.disconnect();
|
||||||
|
|
@ -214,36 +277,78 @@ export class Ribbit {
|
||||||
this.element.contentEditable = 'false';
|
this.element.contentEditable = 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force re-conversion on next getHTML()/getMarkdown() call.
|
||||||
|
* Call after programmatically changing element content.
|
||||||
|
*
|
||||||
|
* editor.element.innerHTML = newContent;
|
||||||
|
* editor.invalidateCache();
|
||||||
|
*/
|
||||||
invalidateCache(): void {
|
invalidateCache(): void {
|
||||||
this.changed = true;
|
|
||||||
this.cachedMarkdown = null;
|
this.cachedMarkdown = null;
|
||||||
this.cachedHTML = null;
|
this.cachedHTML = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request an advisory editing lock. Returns false if another user
|
||||||
|
* holds the lock. Requires a collaboration transport.
|
||||||
|
*
|
||||||
|
* if (await editor.lockForEditing()) { editor.wysiwyg(); }
|
||||||
|
*/
|
||||||
async lockForEditing(): Promise<boolean> {
|
async lockForEditing(): Promise<boolean> {
|
||||||
if (!this.collaboration) return false;
|
if (!this.collaboration) return false;
|
||||||
return this.collaboration.lock();
|
return this.collaboration.lock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the advisory editing lock.
|
||||||
|
*
|
||||||
|
* editor.unlockEditing();
|
||||||
|
* editor.view();
|
||||||
|
*/
|
||||||
unlockEditing(): void {
|
unlockEditing(): void {
|
||||||
this.collaboration?.unlock();
|
this.collaboration?.unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steal the lock from another user. Use when an admin needs to
|
||||||
|
* override a stale lock.
|
||||||
|
*
|
||||||
|
* await editor.forceLockEditing();
|
||||||
|
*/
|
||||||
async forceLockEditing(): Promise<boolean> {
|
async forceLockEditing(): Promise<boolean> {
|
||||||
if (!this.collaboration) return false;
|
if (!this.collaboration) return false;
|
||||||
return this.collaboration.forceLock();
|
return this.collaboration.forceLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all saved revisions from the revision provider.
|
||||||
|
*
|
||||||
|
* const revisions = await editor.listRevisions();
|
||||||
|
* revisions.forEach(r => console.log(r.id, r.timestamp));
|
||||||
|
*/
|
||||||
async listRevisions(): Promise<Revision[]> {
|
async listRevisions(): Promise<Revision[]> {
|
||||||
if (!this.collaboration) return [];
|
if (!this.collaboration) return [];
|
||||||
return this.collaboration.listRevisions();
|
return this.collaboration.listRevisions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single revision's content by ID.
|
||||||
|
*
|
||||||
|
* const rev = await editor.getRevision('abc-123');
|
||||||
|
* if (rev) { console.log(rev.content); }
|
||||||
|
*/
|
||||||
async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
|
async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
|
||||||
if (!this.collaboration) return null;
|
if (!this.collaboration) return null;
|
||||||
return this.collaboration.getRevision(id);
|
return this.collaboration.getRevision(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the editor content with a previous revision and broadcast
|
||||||
|
* the change to collaborators.
|
||||||
|
*
|
||||||
|
* await editor.restoreRevision('abc-123');
|
||||||
|
*/
|
||||||
async restoreRevision(id: string): Promise<void> {
|
async restoreRevision(id: string): Promise<void> {
|
||||||
if (!this.collaboration) return;
|
if (!this.collaboration) return;
|
||||||
const revision = await this.collaboration.getRevision(id);
|
const revision = await this.collaboration.getRevision(id);
|
||||||
|
|
@ -260,6 +365,12 @@ export class Ribbit {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot the current content as a named revision. The revision
|
||||||
|
* provider stores it; ribbit never persists data itself.
|
||||||
|
*
|
||||||
|
* const rev = await editor.createRevision({ label: 'v1.0' });
|
||||||
|
*/
|
||||||
async createRevision(metadata?: RevisionMetadata): Promise<Revision | null> {
|
async createRevision(metadata?: RevisionMetadata): Promise<Revision | null> {
|
||||||
if (!this.collaboration) return null;
|
if (!this.collaboration) return null;
|
||||||
const revision = await this.collaboration.createRevision(this.getMarkdown(), metadata);
|
const revision = await this.collaboration.createRevision(this.getMarkdown(), metadata);
|
||||||
|
|
@ -269,6 +380,14 @@ export class Ribbit {
|
||||||
return revision;
|
return revision;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast the current content to collaborators and fire the
|
||||||
|
* change event. Called automatically on input; call manually
|
||||||
|
* after programmatic content changes.
|
||||||
|
*
|
||||||
|
* editor.element.innerHTML = '<p>new content</p>';
|
||||||
|
* editor.notifyChange();
|
||||||
|
*/
|
||||||
notifyChange(): void {
|
notifyChange(): void {
|
||||||
const markdown = this.getMarkdown();
|
const markdown = this.getMarkdown();
|
||||||
this.collaboration?.sendUpdate(markdown);
|
this.collaboration?.sendUpdate(markdown);
|
||||||
|
|
@ -279,6 +398,12 @@ export class Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a string into words and capitalize each one.
|
||||||
|
* Used to generate camelCase IDs for heading anchors.
|
||||||
|
*
|
||||||
|
* camelCase('hello world') // ['Hello', 'World']
|
||||||
|
*/
|
||||||
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();
|
||||||
|
|
@ -286,12 +411,25 @@ export function camelCase(words: string): string[] {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode HTML entities back to characters. Uses a textarea element
|
||||||
|
* because the browser's HTML parser handles all entity forms.
|
||||||
|
*
|
||||||
|
* decodeHtmlEntities('<b>') // '<b>'
|
||||||
|
*/
|
||||||
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 characters that would be interpreted as HTML into numeric
|
||||||
|
* entities. Used when displaying raw markdown in contentEditable
|
||||||
|
* (edit mode) so the browser doesn't parse it as markup.
|
||||||
|
*
|
||||||
|
* encodeHtmlEntities('<b>hi</b>') // '<b>hi</b>'
|
||||||
|
*/
|
||||||
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) + ';');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
198
src/ts/serializer.ts
Normal file
198
src/ts/serializer.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
/*
|
||||||
|
* serializer.ts — DOM to markdown serializer.
|
||||||
|
*
|
||||||
|
* Converts an HTML DOM tree back to markdown by walking the tree and
|
||||||
|
* producing a typed token stream. Text tokens are escaped during final
|
||||||
|
* serialization; delimiter tokens pass through verbatim. This separation
|
||||||
|
* is what makes round-trip correctness possible — the serializer always
|
||||||
|
* knows which characters are structural and which are literal.
|
||||||
|
*
|
||||||
|
* const serializer = new MarkdownSerializer(tagMap, delimiterChars);
|
||||||
|
* serializer.serialize(document.getElementById('content'))
|
||||||
|
* // '**bold** and *italic*'
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { InlineToken } from './tokenizer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps HTML element names to their markdown serialization.
|
||||||
|
* Each entry defines how to convert an element back to markdown tokens.
|
||||||
|
*/
|
||||||
|
export interface SerializerTagDef {
|
||||||
|
/** The canonical delimiter (e.g. '**' for bold). */
|
||||||
|
delimiter?: string;
|
||||||
|
/** Custom serializer for elements that aren't simple delimiter wraps
|
||||||
|
* (e.g. links, code blocks, headings). Returns the full markdown
|
||||||
|
* string for the element and its children. */
|
||||||
|
serialize?: (element: HTMLElement, children: () => string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a DOM tree to markdown. Walks the tree producing inline
|
||||||
|
* tokens, then serializes the token stream to a string with correct
|
||||||
|
* escaping.
|
||||||
|
*
|
||||||
|
* const serializer = new MarkdownSerializer(tagMap, new Set(['*', '`', '~', '[', '_']));
|
||||||
|
* const markdown = serializer.serialize(containerElement);
|
||||||
|
*/
|
||||||
|
export class MarkdownSerializer {
|
||||||
|
private tagMap: Map<string, SerializerTagDef>;
|
||||||
|
private delimiterChars: Set<string>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
tagMap: Map<string, SerializerTagDef>,
|
||||||
|
delimiterChars: Set<string>,
|
||||||
|
) {
|
||||||
|
this.tagMap = tagMap;
|
||||||
|
this.delimiterChars = delimiterChars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a DOM tree to a markdown string.
|
||||||
|
*
|
||||||
|
* serializer.serialize(document.querySelector('article'))
|
||||||
|
*/
|
||||||
|
serialize(node: Node): string {
|
||||||
|
const tokens = this.nodeToTokens(node);
|
||||||
|
return this.tokensToString(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DOM node to a stream of inline tokens.
|
||||||
|
* Text nodes become text tokens; elements with known tags
|
||||||
|
* become delimiter-wrapped token sequences; unknown elements
|
||||||
|
* recurse into their children.
|
||||||
|
*/
|
||||||
|
private nodeToTokens(node: Node): InlineToken[] {
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
return [{
|
||||||
|
role: 'text',
|
||||||
|
value: node.textContent || '',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
if (node.nodeType !== 1) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
const tagDef = this.tagMap.get(element.nodeName);
|
||||||
|
|
||||||
|
// Custom serializer handles the entire element
|
||||||
|
if (tagDef?.serialize) {
|
||||||
|
const childrenMarkdown = () => this.serializeChildren(element);
|
||||||
|
const markdown = tagDef.serialize(element, childrenMarkdown);
|
||||||
|
// Custom serializers return raw markdown strings — wrap
|
||||||
|
// in a single text token that won't be escaped (it's already
|
||||||
|
// correctly formatted)
|
||||||
|
return [{
|
||||||
|
role: 'html',
|
||||||
|
value: markdown,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delimiter-based element: emit open + children + close
|
||||||
|
if (tagDef?.delimiter) {
|
||||||
|
const delimiter = tagDef.delimiter;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
role: 'open',
|
||||||
|
value: delimiter,
|
||||||
|
delimiter,
|
||||||
|
},
|
||||||
|
...this.childrenToTokens(element),
|
||||||
|
{
|
||||||
|
role: 'close',
|
||||||
|
value: delimiter,
|
||||||
|
delimiter,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown element: just recurse into children
|
||||||
|
return this.childrenToTokens(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect tokens from all child nodes of an element.
|
||||||
|
*/
|
||||||
|
private childrenToTokens(element: HTMLElement): InlineToken[] {
|
||||||
|
const tokens: InlineToken[] = [];
|
||||||
|
for (const child of Array.from(element.childNodes)) {
|
||||||
|
tokens.push(...this.nodeToTokens(child));
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize an element's children directly to a markdown string.
|
||||||
|
* Used by custom serializers (links, headings, etc.) that need
|
||||||
|
* the children as a string, not as tokens.
|
||||||
|
*/
|
||||||
|
private serializeChildren(element: HTMLElement): string {
|
||||||
|
const tokens = this.childrenToTokens(element);
|
||||||
|
return this.tokensToString(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a token stream to a markdown string. This is where
|
||||||
|
* escaping happens: text tokens have their delimiter characters
|
||||||
|
* backslash-escaped; all other token types pass through verbatim.
|
||||||
|
*/
|
||||||
|
private tokensToString(tokens: InlineToken[]): string {
|
||||||
|
let result = '';
|
||||||
|
for (const token of tokens) {
|
||||||
|
switch (token.role) {
|
||||||
|
case 'text':
|
||||||
|
result += this.escapeText(token.value);
|
||||||
|
break;
|
||||||
|
case 'open':
|
||||||
|
case 'close':
|
||||||
|
case 'html':
|
||||||
|
case 'break':
|
||||||
|
// Structural tokens are never escaped
|
||||||
|
result += token.value;
|
||||||
|
break;
|
||||||
|
case 'code':
|
||||||
|
result += token.value;
|
||||||
|
break;
|
||||||
|
case 'link':
|
||||||
|
result += token.value;
|
||||||
|
break;
|
||||||
|
case 'autolink':
|
||||||
|
result += token.value;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result += token.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape characters in literal text that would be misinterpreted
|
||||||
|
* as markdown syntax on re-parse. Only escapes characters that are
|
||||||
|
* registered as delimiter characters, plus `\`, `[`, `_`, and `<`
|
||||||
|
* before letters (HTML passthrough prevention).
|
||||||
|
*/
|
||||||
|
private escapeText(text: string): string {
|
||||||
|
let result = '';
|
||||||
|
for (let position = 0; position < text.length; position++) {
|
||||||
|
const character = text[position];
|
||||||
|
if (character === '\\') {
|
||||||
|
result += '\\\\';
|
||||||
|
} else if (character === '_') {
|
||||||
|
result += '\\_';
|
||||||
|
} else if (character === '[') {
|
||||||
|
result += '\\[';
|
||||||
|
} else if (character === '<' && position + 1 < text.length && /[a-zA-Z/]/.test(text[position + 1])) {
|
||||||
|
// Only escape < when it would start an HTML tag
|
||||||
|
result += '\\<';
|
||||||
|
} else if (this.delimiterChars.has(character)) {
|
||||||
|
result += '\\' + character;
|
||||||
|
} else {
|
||||||
|
result += character;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
948
src/ts/tags.ts
948
src/ts/tags.ts
File diff suppressed because it is too large
Load Diff
|
|
@ -4,6 +4,20 @@
|
||||||
|
|
||||||
import type { RibbitTheme } from './types';
|
import type { RibbitTheme } from './types';
|
||||||
|
|
||||||
|
/** CSS file name loaded from each theme's directory. */
|
||||||
|
const THEME_CSS_FILENAME = 'theme.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages theme registration, enabling/disabling, and CSS loading
|
||||||
|
* for a ribbit editor instance.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const themes = new ThemeManager(defaultTheme, '/themes', (current, previous) => {
|
||||||
|
* editor.rebuild();
|
||||||
|
* });
|
||||||
|
* themes.add(customTheme);
|
||||||
|
* themes.set('custom');
|
||||||
|
*/
|
||||||
export class ThemeManager {
|
export class ThemeManager {
|
||||||
private registered: Map<string, RibbitTheme>;
|
private registered: Map<string, RibbitTheme>;
|
||||||
private disabled: Set<string>;
|
private disabled: Set<string>;
|
||||||
|
|
@ -23,7 +37,10 @@ export class ThemeManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a theme. Themes must be added before they can be enabled.
|
* Register a theme. Themes must be added before they can be activated.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* themes.add({ name: 'dark', tags: darkTags });
|
||||||
*/
|
*/
|
||||||
add(theme: RibbitTheme): void {
|
add(theme: RibbitTheme): void {
|
||||||
this.registered.set(theme.name, theme);
|
this.registered.set(theme.name, theme);
|
||||||
|
|
@ -31,6 +48,9 @@ export class ThemeManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unregister a theme by name. Cannot remove the active theme.
|
* Unregister a theme by name. Cannot remove the active theme.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* themes.remove('dark');
|
||||||
*/
|
*/
|
||||||
remove(name: string): void {
|
remove(name: string): void {
|
||||||
if (this.active.name === name) {
|
if (this.active.name === name) {
|
||||||
|
|
@ -41,6 +61,9 @@ export class ThemeManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the names of all registered and enabled themes.
|
* Return the names of all registered and enabled themes.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const available = themes.list(); // ['ribbit-default', 'dark']
|
||||||
*/
|
*/
|
||||||
list(): string[] {
|
list(): string[] {
|
||||||
return Array.from(this.registered.keys()).filter(name => !this.disabled.has(name));
|
return Array.from(this.registered.keys()).filter(name => !this.disabled.has(name));
|
||||||
|
|
@ -48,6 +71,9 @@ export class ThemeManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a registered theme by name, or undefined if not found.
|
* Get a registered theme by name, or undefined if not found.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const theme = themes.get('dark');
|
||||||
*/
|
*/
|
||||||
get(name: string): RibbitTheme | undefined {
|
get(name: string): RibbitTheme | undefined {
|
||||||
return this.registered.get(name);
|
return this.registered.get(name);
|
||||||
|
|
@ -55,6 +81,9 @@ export class ThemeManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the currently active theme.
|
* Return the currently active theme.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const active = themes.current();
|
||||||
*/
|
*/
|
||||||
current(): RibbitTheme {
|
current(): RibbitTheme {
|
||||||
return this.active;
|
return this.active;
|
||||||
|
|
@ -64,6 +93,9 @@ export class ThemeManager {
|
||||||
* Switch to a registered theme by name. The theme must be
|
* Switch to a registered theme by name. The theme must be
|
||||||
* registered and enabled. Loads the theme's CSS and notifies
|
* registered and enabled. Loads the theme's CSS and notifies
|
||||||
* the editor to rebuild its converter.
|
* the editor to rebuild its converter.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* themes.set('dark');
|
||||||
*/
|
*/
|
||||||
set(name: string): void {
|
set(name: string): void {
|
||||||
const theme = this.registered.get(name);
|
const theme = this.registered.get(name);
|
||||||
|
|
@ -75,7 +107,7 @@ export class ThemeManager {
|
||||||
}
|
}
|
||||||
const previous = this.active;
|
const previous = this.active;
|
||||||
this.active = theme;
|
this.active = theme;
|
||||||
// Only load CSS when switching themes, not on initial set
|
// Only load CSS when actually switching to a different theme
|
||||||
if (previous !== theme) {
|
if (previous !== theme) {
|
||||||
this.loadCSS(name);
|
this.loadCSS(name);
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +117,9 @@ export class ThemeManager {
|
||||||
/**
|
/**
|
||||||
* Mark a theme as available for selection via set().
|
* Mark a theme as available for selection via set().
|
||||||
* Themes are enabled by default when added.
|
* Themes are enabled by default when added.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* themes.enable('dark');
|
||||||
*/
|
*/
|
||||||
enable(name: string): void {
|
enable(name: string): void {
|
||||||
if (!this.registered.has(name)) {
|
if (!this.registered.has(name)) {
|
||||||
|
|
@ -96,6 +131,9 @@ export class ThemeManager {
|
||||||
/**
|
/**
|
||||||
* Mark a theme as unavailable for selection via set().
|
* Mark a theme as unavailable for selection via set().
|
||||||
* Does not affect the current theme if it is already active.
|
* Does not affect the current theme if it is already active.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* themes.disable('dark');
|
||||||
*/
|
*/
|
||||||
disable(name: string): void {
|
disable(name: string): void {
|
||||||
if (!this.registered.has(name)) {
|
if (!this.registered.has(name)) {
|
||||||
|
|
@ -110,7 +148,7 @@ export class ThemeManager {
|
||||||
}
|
}
|
||||||
const link = document.createElement('link');
|
const link = document.createElement('link');
|
||||||
link.rel = 'stylesheet';
|
link.rel = 'stylesheet';
|
||||||
link.href = `${this.themesPath}/${name}/theme.css`;
|
link.href = `${this.themesPath}/${name}/${THEME_CSS_FILENAME}`;
|
||||||
document.head.appendChild(link);
|
document.head.appendChild(link);
|
||||||
this.themeLink = link;
|
this.themeLink = link;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
447
src/ts/tokenizer.ts
Normal file
447
src/ts/tokenizer.ts
Normal file
|
|
@ -0,0 +1,447 @@
|
||||||
|
/*
|
||||||
|
* tokenizer.ts — Inline markdown tokenizer.
|
||||||
|
*
|
||||||
|
* Scans markdown text left-to-right producing a typed token stream.
|
||||||
|
* Tokens carry their semantic role (delimiter, text, code, link, etc.)
|
||||||
|
* so downstream consumers can make correct escaping and pairing
|
||||||
|
* decisions without regex heuristics.
|
||||||
|
*
|
||||||
|
* const tokenizer = new InlineTokenizer(delimiterDefs);
|
||||||
|
* const tokens = tokenizer.tokenize('hello **bold** end');
|
||||||
|
* // [text "hello "] [open "**"] [text "bold"] [close "**"] [text " end"]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single token in the inline token stream. The `role` field
|
||||||
|
* distinguishes structural markers from literal content, which
|
||||||
|
* is the key insight that makes round-trip escaping correct.
|
||||||
|
*/
|
||||||
|
export interface InlineToken {
|
||||||
|
role: 'text' | 'open' | 'close' | 'code' | 'link' | 'autolink' | 'html' | 'break';
|
||||||
|
value: string;
|
||||||
|
/** For link tokens: the href and optional title. */
|
||||||
|
href?: string;
|
||||||
|
title?: string;
|
||||||
|
/** For delimiter tokens: which delimiter this is (e.g. '**'). */
|
||||||
|
delimiter?: string;
|
||||||
|
/** For code tokens: the raw content (not HTML-escaped). */
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A delimiter definition used by the tokenizer to recognize
|
||||||
|
* opening and closing delimiter runs.
|
||||||
|
*/
|
||||||
|
export interface DelimiterDef {
|
||||||
|
/** The delimiter string, e.g. '**', '*', '~~', '`'. */
|
||||||
|
delimiter: string;
|
||||||
|
/** The HTML tag name to emit, e.g. 'strong', 'em', 'del'. */
|
||||||
|
htmlTag: string;
|
||||||
|
/** Whether content inside this delimiter is parsed for further
|
||||||
|
* inline markup. False for code spans. */
|
||||||
|
recursive: boolean;
|
||||||
|
/** Lower values are matched first. Ensures *** matches before **. */
|
||||||
|
precedence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characters that count as punctuation for flanking delimiter rules.
|
||||||
|
* A delimiter is left-flanking if preceded by whitespace/punctuation
|
||||||
|
* and followed by non-whitespace. Right-flanking is the reverse.
|
||||||
|
*/
|
||||||
|
const PUNCTUATION = new Set(
|
||||||
|
' \t\n.,;:!?\'"()[]{}/<>\\-~#@&^|*`_'.split('')
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characters that can be backslash-escaped in markdown.
|
||||||
|
*/
|
||||||
|
const ESCAPABLE = new Set(
|
||||||
|
'\\`*_{}[]()#+-.!~|><'.split('')
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named HTML entities recognized by the tokenizer.
|
||||||
|
*/
|
||||||
|
const NAMED_ENTITIES: Record<string, string> = {
|
||||||
|
'amp': '&',
|
||||||
|
'lt': '<',
|
||||||
|
'gt': '>',
|
||||||
|
'quot': '"',
|
||||||
|
'apos': "'",
|
||||||
|
'nbsp': '\u00A0',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans markdown text into a stream of typed tokens. Handles
|
||||||
|
* backslash escapes, entities, flanking rules, code spans, links,
|
||||||
|
* autolinks, HTML tags, and hard line breaks.
|
||||||
|
*
|
||||||
|
* const tokenizer = new InlineTokenizer([
|
||||||
|
* { delimiter: '**', htmlTag: 'strong', recursive: true, precedence: 40 },
|
||||||
|
* { delimiter: '*', htmlTag: 'em', recursive: true, precedence: 50 },
|
||||||
|
* ]);
|
||||||
|
* const tokens = tokenizer.tokenize('**bold**');
|
||||||
|
*/
|
||||||
|
export class InlineTokenizer {
|
||||||
|
private delimiters: DelimiterDef[];
|
||||||
|
private codeSpansEnabled: boolean;
|
||||||
|
|
||||||
|
constructor(delimiters: DelimiterDef[], options?: { codeSpans?: boolean }) {
|
||||||
|
this.codeSpansEnabled = options?.codeSpans !== false;
|
||||||
|
// Sort by delimiter length descending so longer delimiters
|
||||||
|
// are tried first (*** before ** before *)
|
||||||
|
this.delimiters = [...delimiters].sort(
|
||||||
|
(first, second) => second.delimiter.length - first.delimiter.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize a markdown string into an inline token stream.
|
||||||
|
*
|
||||||
|
* tokenizer.tokenize('hello **world**')
|
||||||
|
* // [text "hello "] [open "**"] [text "world"] [close "**"]
|
||||||
|
*/
|
||||||
|
tokenize(source: string): InlineToken[] {
|
||||||
|
const tokens: InlineToken[] = [];
|
||||||
|
let position = 0;
|
||||||
|
let textBuffer = '';
|
||||||
|
|
||||||
|
const flushText = () => {
|
||||||
|
if (textBuffer.length > 0) {
|
||||||
|
tokens.push({
|
||||||
|
role: 'text',
|
||||||
|
value: textBuffer,
|
||||||
|
});
|
||||||
|
textBuffer = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while (position < source.length) {
|
||||||
|
const remaining = source.slice(position);
|
||||||
|
|
||||||
|
// Backslash escape: \X → literal X
|
||||||
|
if (source[position] === '\\' && position + 1 < source.length) {
|
||||||
|
const nextChar = source[position + 1];
|
||||||
|
if (ESCAPABLE.has(nextChar)) {
|
||||||
|
textBuffer += nextChar;
|
||||||
|
position += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// \ before newline is a hard break
|
||||||
|
if (nextChar === '\n') {
|
||||||
|
flushText();
|
||||||
|
tokens.push({ role: 'break', value: '<br>' });
|
||||||
|
position += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard line break: two+ trailing spaces before newline
|
||||||
|
if (source[position] === ' ') {
|
||||||
|
const spaceMatch = remaining.match(/^(?<spaces> {2,})\n/);
|
||||||
|
if (spaceMatch?.groups) {
|
||||||
|
flushText();
|
||||||
|
tokens.push({ role: 'break', value: '<br>' });
|
||||||
|
position += spaceMatch[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML entity resolution: &name; or &#digits; or &#xhex;
|
||||||
|
if (source[position] === '&') {
|
||||||
|
const resolved = this.resolveEntity(remaining);
|
||||||
|
if (resolved) {
|
||||||
|
textBuffer += resolved.character;
|
||||||
|
position += resolved.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code span: `content` — not parsed for further inline markup
|
||||||
|
if (this.codeSpansEnabled && source[position] === '`') {
|
||||||
|
const codeSpan = this.matchCodeSpan(source, position);
|
||||||
|
if (codeSpan) {
|
||||||
|
flushText();
|
||||||
|
tokens.push({
|
||||||
|
role: 'code',
|
||||||
|
value: codeSpan.raw,
|
||||||
|
content: codeSpan.content,
|
||||||
|
});
|
||||||
|
position += codeSpan.raw.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link: [text](url) or [text](url "title")
|
||||||
|
if (source[position] === '[') {
|
||||||
|
const link = this.matchLink(source, position);
|
||||||
|
if (link) {
|
||||||
|
flushText();
|
||||||
|
tokens.push({
|
||||||
|
role: 'link',
|
||||||
|
value: link.text,
|
||||||
|
href: link.href,
|
||||||
|
title: link.title,
|
||||||
|
});
|
||||||
|
position += link.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autolink: <url>
|
||||||
|
if (source[position] === '<') {
|
||||||
|
const autolink = this.matchAutolink(remaining);
|
||||||
|
if (autolink) {
|
||||||
|
flushText();
|
||||||
|
tokens.push({
|
||||||
|
role: 'autolink',
|
||||||
|
value: autolink.url,
|
||||||
|
href: autolink.url,
|
||||||
|
});
|
||||||
|
position += autolink.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// HTML tag passthrough
|
||||||
|
const htmlTagMatch = this.matchHtmlTag(remaining);
|
||||||
|
if (htmlTagMatch) {
|
||||||
|
flushText();
|
||||||
|
tokens.push({
|
||||||
|
role: 'html',
|
||||||
|
value: htmlTagMatch.tag,
|
||||||
|
});
|
||||||
|
position += htmlTagMatch.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare URL autolink: https://...
|
||||||
|
if (remaining.startsWith('http://') || remaining.startsWith('https://')) {
|
||||||
|
const bareUrl = this.matchBareUrl(remaining);
|
||||||
|
if (bareUrl) {
|
||||||
|
flushText();
|
||||||
|
tokens.push({
|
||||||
|
role: 'autolink',
|
||||||
|
value: bareUrl.url,
|
||||||
|
href: bareUrl.url,
|
||||||
|
});
|
||||||
|
position += bareUrl.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delimiter: check each registered delimiter
|
||||||
|
const delimiterMatch = this.matchDelimiter(source, position);
|
||||||
|
if (delimiterMatch) {
|
||||||
|
flushText();
|
||||||
|
tokens.push(delimiterMatch.token);
|
||||||
|
position += delimiterMatch.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain character
|
||||||
|
textBuffer += source[position];
|
||||||
|
position++;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushText();
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to resolve an HTML entity at the start of the string.
|
||||||
|
* Returns the resolved character and the length consumed, or null.
|
||||||
|
*/
|
||||||
|
private resolveEntity(text: string): { character: string; length: number } | null {
|
||||||
|
const namedPattern = /^&(?<name>[a-zA-Z]+);/;
|
||||||
|
const numericPattern = /^&#(?<code>\d+);/;
|
||||||
|
const hexPattern = /^&#x(?<hex>[0-9a-fA-F]+);/;
|
||||||
|
|
||||||
|
const named = text.match(namedPattern);
|
||||||
|
if (named?.groups) {
|
||||||
|
const resolved = NAMED_ENTITIES[named.groups.name.toLowerCase()];
|
||||||
|
if (resolved) {
|
||||||
|
return {
|
||||||
|
character: resolved,
|
||||||
|
length: named[0].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = text.match(numericPattern);
|
||||||
|
if (numeric?.groups) {
|
||||||
|
return {
|
||||||
|
character: String.fromCharCode(parseInt(numeric.groups.code, 10)),
|
||||||
|
length: numeric[0].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hex = text.match(hexPattern);
|
||||||
|
if (hex?.groups) {
|
||||||
|
return {
|
||||||
|
character: String.fromCharCode(parseInt(hex.groups.hex, 16)),
|
||||||
|
length: hex[0].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a code span starting at the given position.
|
||||||
|
* Handles single backtick delimiters only (not multi-backtick).
|
||||||
|
*/
|
||||||
|
private matchCodeSpan(
|
||||||
|
source: string,
|
||||||
|
position: number,
|
||||||
|
): { content: string; raw: string } | null {
|
||||||
|
if (source[position] !== '`') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const closeIndex = source.indexOf('`', position + 1);
|
||||||
|
if (closeIndex === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const content = source.slice(position + 1, closeIndex);
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
raw: source.slice(position, closeIndex + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a markdown link [text](url) or [text](url "title")
|
||||||
|
* starting at the given position. Disallows [ in link text
|
||||||
|
* to prevent nested link ambiguity.
|
||||||
|
*/
|
||||||
|
private matchLink(
|
||||||
|
source: string,
|
||||||
|
position: number,
|
||||||
|
): { text: string; href: string; title?: string; length: number } | null {
|
||||||
|
const linkPattern = /^\[(?<text>[^\[\]]+)\]\((?<href>[^\s)]+)(?:\s+"(?<title>[^"]*)")?\)/;
|
||||||
|
const match = source.slice(position).match(linkPattern);
|
||||||
|
if (!match?.groups) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: match.groups.text,
|
||||||
|
href: match.groups.href,
|
||||||
|
title: match.groups.title,
|
||||||
|
length: match[0].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match an angle-bracket autolink <url> at the start of the string.
|
||||||
|
*/
|
||||||
|
private matchAutolink(text: string): { url: string; length: number } | null {
|
||||||
|
const pattern = /^<(?<url>https?:\/\/[^\s>]+)>/;
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (!match?.groups) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: match.groups.url,
|
||||||
|
length: match[0].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a bare URL (https://...) at the start of the string.
|
||||||
|
*/
|
||||||
|
private matchBareUrl(text: string): { url: string; length: number } | null {
|
||||||
|
const pattern = /^https?:\/\/[^\s<>\x00]+/;
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: match[0],
|
||||||
|
length: match[0].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match an HTML tag at the start of the string.
|
||||||
|
*/
|
||||||
|
private matchHtmlTag(text: string): { tag: string; length: number } | null {
|
||||||
|
const pattern = /^<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s+[^>]*)?\s*\/?>/;
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tag: match[0],
|
||||||
|
length: match[0].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to match a delimiter at the given position. For runs of the
|
||||||
|
* same character (e.g. *** = 3 asterisks), the run is split into
|
||||||
|
* the longest registered delimiter that fits, then the remainder.
|
||||||
|
* This handles cases like **bold***italic* where *** must split
|
||||||
|
* into ** (close bold) + * (open italic).
|
||||||
|
*/
|
||||||
|
private matchDelimiter(
|
||||||
|
source: string,
|
||||||
|
position: number,
|
||||||
|
): { token: InlineToken; length: number } | null {
|
||||||
|
// Count the full run of the same character
|
||||||
|
const runChar = source[position];
|
||||||
|
let runLength = 0;
|
||||||
|
while (position + runLength < source.length && source[position + runLength] === runChar) {
|
||||||
|
runLength++;
|
||||||
|
}
|
||||||
|
if (runLength === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find registered delimiters that use this character
|
||||||
|
const candidates = this.delimiters.filter(
|
||||||
|
definition => definition.delimiter[0] === runChar
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try each candidate delimiter length (longest first, already sorted)
|
||||||
|
for (const definition of candidates) {
|
||||||
|
const delimiter = definition.delimiter;
|
||||||
|
if (delimiter.length > runLength) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const charBefore = position > 0 ? source[position - 1] : '\n';
|
||||||
|
const charAfter = source[position + delimiter.length];
|
||||||
|
|
||||||
|
const leftFlanking = (charBefore === undefined || PUNCTUATION.has(charBefore) || charBefore === '\n')
|
||||||
|
&& charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t';
|
||||||
|
|
||||||
|
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t'
|
||||||
|
&& (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n');
|
||||||
|
|
||||||
|
if (leftFlanking) {
|
||||||
|
return {
|
||||||
|
token: {
|
||||||
|
role: 'open',
|
||||||
|
value: delimiter,
|
||||||
|
delimiter,
|
||||||
|
},
|
||||||
|
length: delimiter.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (rightFlanking) {
|
||||||
|
return {
|
||||||
|
token: {
|
||||||
|
role: 'close',
|
||||||
|
value: delimiter,
|
||||||
|
delimiter,
|
||||||
|
},
|
||||||
|
length: delimiter.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,35 @@
|
||||||
import type { Tag, ToolbarSlot, Button } from './types';
|
import type { Tag, ToolbarSlot, Button } from './types';
|
||||||
import type { MacroDef } from './macros';
|
import type { MacroDef } from './macros';
|
||||||
|
|
||||||
|
const CSS_CLASS_ACTIVE = 'active';
|
||||||
|
const CSS_CLASS_DISABLED = 'disabled';
|
||||||
|
const CSS_CLASS_TOOLBAR = 'ribbit-toolbar';
|
||||||
|
const CSS_CLASS_SPACER = 'spacer';
|
||||||
|
const CSS_CLASS_GROUP = 'ribbit-btn-group';
|
||||||
|
const CSS_CLASS_DROPDOWN = 'ribbit-dropdown';
|
||||||
|
const CSS_DISPLAY_NONE = 'none';
|
||||||
|
const MACRO_ID_PREFIX = 'macro:';
|
||||||
|
const DROPDOWN_INDICATOR = ' ▾';
|
||||||
|
|
||||||
|
/** IDs of buttons that belong in the utility section, not the tag/macro area. */
|
||||||
|
const UTILITY_BUTTON_IDS = ['save', 'toggle', 'markdown'];
|
||||||
|
|
||||||
|
const MAX_HEADING_LEVEL = 6;
|
||||||
|
|
||||||
|
const EDITOR_STATE_VIEW = 'view';
|
||||||
|
const EDITOR_STATE_EDIT = 'edit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concrete implementation of the Button interface.
|
||||||
|
*
|
||||||
|
* Wraps a button definition with DOM element tracking and
|
||||||
|
* visibility toggling. Created internally by ToolbarManager.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const button = new ButtonImpl({ id: 'bold', label: 'Bold', action: 'wrap', delimiter: '**' });
|
||||||
|
* button.hide();
|
||||||
|
* button.show();
|
||||||
|
*/
|
||||||
class ButtonImpl implements Button {
|
class ButtonImpl implements Button {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -27,30 +56,48 @@ class ButtonImpl implements Button {
|
||||||
element?: HTMLElement;
|
element?: HTMLElement;
|
||||||
handler?: () => void;
|
handler?: () => void;
|
||||||
|
|
||||||
constructor(def: Partial<Button> & { id: string }) {
|
constructor(definition: Partial<Button> & { id: string }) {
|
||||||
this.id = def.id;
|
this.id = definition.id;
|
||||||
this.label = def.label || def.id;
|
this.label = definition.label || definition.id;
|
||||||
this.icon = def.icon;
|
this.icon = definition.icon;
|
||||||
this.shortcut = def.shortcut;
|
this.shortcut = definition.shortcut;
|
||||||
this.action = def.action || 'insert';
|
this.action = definition.action || 'insert';
|
||||||
this.delimiter = def.delimiter;
|
this.delimiter = definition.delimiter;
|
||||||
this.template = def.template;
|
this.template = definition.template;
|
||||||
this.replaceSelection = def.replaceSelection ?? true;
|
this.replaceSelection = definition.replaceSelection ?? true;
|
||||||
this.visible = def.visible ?? true;
|
this.visible = definition.visible ?? true;
|
||||||
this.handler = def.handler;
|
this.handler = definition.handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programmatically trigger this button's click event.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* toolbar.buttons.get('bold')?.click();
|
||||||
|
*/
|
||||||
click(): void {
|
click(): void {
|
||||||
this.element?.click();
|
this.element?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide this button from the toolbar.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* toolbar.buttons.get('table')?.hide();
|
||||||
|
*/
|
||||||
hide(): void {
|
hide(): void {
|
||||||
this.visible = false;
|
this.visible = false;
|
||||||
if (this.element) {
|
if (this.element) {
|
||||||
this.element.style.display = 'none';
|
this.element.style.display = CSS_DISPLAY_NONE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show this button in the toolbar.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* toolbar.buttons.get('table')?.show();
|
||||||
|
*/
|
||||||
show(): void {
|
show(): void {
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
if (this.element) {
|
if (this.element) {
|
||||||
|
|
@ -59,6 +106,16 @@ class ButtonImpl implements Button {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the editor toolbar: registers buttons from tags and macros,
|
||||||
|
* renders the toolbar DOM, handles keyboard shortcuts, and tracks
|
||||||
|
* active/disabled state.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const manager = new ToolbarManager(editor, tags, macros);
|
||||||
|
* document.body.prepend(manager.render());
|
||||||
|
* manager.updateActiveState(['bold', 'italic']);
|
||||||
|
*/
|
||||||
export class ToolbarManager {
|
export class ToolbarManager {
|
||||||
buttons: Map<string, Button>;
|
buttons: Map<string, Button>;
|
||||||
private layout: ToolbarSlot[];
|
private layout: ToolbarSlot[];
|
||||||
|
|
@ -68,6 +125,18 @@ export class ToolbarManager {
|
||||||
this.editor = editor;
|
this.editor = editor;
|
||||||
this.buttons = new Map();
|
this.buttons = new Map();
|
||||||
|
|
||||||
|
this.registerTagButtons(tags);
|
||||||
|
this.registerHeadingButtons();
|
||||||
|
this.registerListButtons();
|
||||||
|
this.registerMacroButtons(macros);
|
||||||
|
this.registerUtilityButtons();
|
||||||
|
|
||||||
|
this.layout = layout || this.buildDefaultLayout();
|
||||||
|
this.bindShortcuts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register buttons for tags that have button config enabled. */
|
||||||
|
private registerTagButtons(tags: Record<string, Tag>): void {
|
||||||
for (const tag of Object.values(tags)) {
|
for (const tag of Object.values(tags)) {
|
||||||
if (!tag.button || !tag.button.show) {
|
if (!tag.button || !tag.button.show) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -82,74 +151,101 @@ export class ToolbarManager {
|
||||||
replaceSelection: tag.replaceSelection,
|
replaceSelection: tag.replaceSelection,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Heading and list variants (derived from their parent tags)
|
/** Heading levels are derived from a single pattern rather than repeated blocks. */
|
||||||
for (let i = 1; i <= 6; i++) {
|
private registerHeadingButtons(): void {
|
||||||
this.register(`h${i}`, {
|
for (let level = 1; level <= MAX_HEADING_LEVEL; level++) {
|
||||||
label: `H${i}`,
|
this.register(`h${level}`, {
|
||||||
shortcut: `Ctrl+${i}`,
|
label: `H${level}`,
|
||||||
|
shortcut: `Ctrl+${level}`,
|
||||||
action: 'prefix',
|
action: 'prefix',
|
||||||
delimiter: '#'.repeat(i) + ' ',
|
delimiter: '#'.repeat(level) + ' ',
|
||||||
replaceSelection: true,
|
replaceSelection: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.register('ul', {
|
}
|
||||||
|
|
||||||
|
private registerListButtons(): void {
|
||||||
|
const listDefinitions: Array<{ id: string; label: string; shortcut: string; template: string }> = [
|
||||||
|
{
|
||||||
|
id: 'ul',
|
||||||
label: 'Bullet List',
|
label: 'Bullet List',
|
||||||
shortcut: 'Ctrl+Shift+8',
|
shortcut: 'Ctrl+Shift+8',
|
||||||
action: 'insert',
|
|
||||||
template: '- Item 1\n- Item 2\n- Item 3',
|
template: '- Item 1\n- Item 2\n- Item 3',
|
||||||
replaceSelection: false,
|
},
|
||||||
});
|
{
|
||||||
this.register('ol', {
|
id: 'ol',
|
||||||
label: 'Numbered List',
|
label: 'Numbered List',
|
||||||
shortcut: 'Ctrl+Shift+7',
|
shortcut: 'Ctrl+Shift+7',
|
||||||
action: 'insert',
|
|
||||||
template: '1. Item 1\n2. Item 2\n3. Item 3',
|
template: '1. Item 1\n2. Item 2\n3. Item 3',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const definition of listDefinitions) {
|
||||||
|
this.register(definition.id, {
|
||||||
|
label: definition.label,
|
||||||
|
shortcut: definition.shortcut,
|
||||||
|
action: 'insert',
|
||||||
|
template: definition.template,
|
||||||
replaceSelection: false,
|
replaceSelection: false,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerMacroButtons(macros: MacroDef[]): void {
|
||||||
for (const macro of macros) {
|
for (const macro of macros) {
|
||||||
if (macro.button === false) {
|
if (macro.button === false) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const btn = typeof macro.button === 'object' ? macro.button : null;
|
const buttonConfig = typeof macro.button === 'object' ? macro.button : null;
|
||||||
this.register(`macro:${macro.name}`, {
|
const capitalizedName = macro.name.charAt(0).toUpperCase() + macro.name.slice(1);
|
||||||
label: btn?.label || macro.name.charAt(0).toUpperCase() + macro.name.slice(1),
|
this.register(`${MACRO_ID_PREFIX}${macro.name}`, {
|
||||||
icon: btn?.icon,
|
label: buttonConfig?.label || capitalizedName,
|
||||||
|
icon: buttonConfig?.icon,
|
||||||
action: 'insert',
|
action: 'insert',
|
||||||
template: `@${macro.name}`,
|
template: `@${macro.name}`,
|
||||||
replaceSelection: false,
|
replaceSelection: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerUtilityButtons(): void {
|
||||||
this.register('save', {
|
this.register('save', {
|
||||||
label: 'Save', shortcut: 'Ctrl+S', action: 'custom',
|
label: 'Save',
|
||||||
|
shortcut: 'Ctrl+S',
|
||||||
|
action: 'custom',
|
||||||
handler: () => this.editor.save(),
|
handler: () => this.editor.save(),
|
||||||
});
|
});
|
||||||
this.register('toggle', {
|
this.register('toggle', {
|
||||||
label: 'Edit', shortcut: 'Ctrl+Shift+V', action: 'custom',
|
label: 'Edit',
|
||||||
|
shortcut: 'Ctrl+Shift+V',
|
||||||
|
action: 'custom',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
this.editor.getState() === 'view'
|
if (this.editor.getState() === EDITOR_STATE_VIEW) {
|
||||||
? this.editor.wysiwyg()
|
this.editor.wysiwyg();
|
||||||
: this.editor.view();
|
} else {
|
||||||
|
this.editor.view();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.register('markdown', {
|
this.register('markdown', {
|
||||||
label: 'Source', shortcut: 'Ctrl+/', action: 'custom',
|
label: 'Source',
|
||||||
|
shortcut: 'Ctrl+/',
|
||||||
|
action: 'custom',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
this.editor.getState() === 'edit'
|
if (this.editor.getState() === EDITOR_STATE_EDIT) {
|
||||||
? this.editor.wysiwyg()
|
this.editor.wysiwyg();
|
||||||
: this.editor.edit();
|
} else {
|
||||||
|
this.editor.edit();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.layout = layout || this.defaultLayout();
|
|
||||||
this.bindShortcuts();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listen for keyboard shortcuts on the document and dispatch
|
* Builds a keyboard shortcut lookup and dispatches matching
|
||||||
* to the matching toolbar button.
|
* button actions on keydown events.
|
||||||
*/
|
*/
|
||||||
private bindShortcuts(): void {
|
private bindShortcuts(): void {
|
||||||
const shortcutMap = new Map<string, Button>();
|
const shortcutMap = new Map<string, Button>();
|
||||||
|
|
@ -160,20 +256,7 @@ export class ToolbarManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||||
const parts: string[] = [];
|
const combo = this.buildKeyCombo(event);
|
||||||
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);
|
const button = shortcutMap.get(combo);
|
||||||
if (button) {
|
if (button) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -182,21 +265,42 @@ export class ToolbarManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private register(id: string, def: Partial<Button>): void {
|
/** Normalizes a KeyboardEvent into a comparable shortcut string like "ctrl+shift+b". */
|
||||||
|
private buildKeyCombo(event: KeyboardEvent): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
parts.push('ctrl');
|
||||||
|
}
|
||||||
|
if (event.shiftKey) {
|
||||||
|
parts.push('shift');
|
||||||
|
}
|
||||||
|
if (event.altKey) {
|
||||||
|
parts.push('alt');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special keys pass through as-is; letter keys are lowercased
|
||||||
|
const specialKeys = ['/', '.', '-'];
|
||||||
|
const key = specialKeys.includes(event.key) ? event.key : event.key.toLowerCase();
|
||||||
|
|
||||||
|
parts.push(key);
|
||||||
|
return parts.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
private register(id: string, definition: Partial<Button>): void {
|
||||||
if (this.buttons.has(id)) {
|
if (this.buttons.has(id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.buttons.set(id, new ButtonImpl({ id, ...def }));
|
this.buttons.set(id, new ButtonImpl({ id, ...definition }));
|
||||||
}
|
}
|
||||||
|
|
||||||
private defaultLayout(): ToolbarSlot[] {
|
private buildDefaultLayout(): ToolbarSlot[] {
|
||||||
const tagIds: string[] = [];
|
const tagIds: string[] = [];
|
||||||
const macroIds: string[] = [];
|
const macroIds: string[] = [];
|
||||||
for (const id of this.buttons.keys()) {
|
for (const id of this.buttons.keys()) {
|
||||||
if (['save', 'toggle', 'markdown'].includes(id)) {
|
if (UTILITY_BUTTON_IDS.includes(id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (id.startsWith('macro:')) {
|
if (id.startsWith(MACRO_ID_PREFIX)) {
|
||||||
macroIds.push(id);
|
macroIds.push(id);
|
||||||
} else {
|
} else {
|
||||||
tagIds.push(id);
|
tagIds.push(id);
|
||||||
|
|
@ -205,132 +309,183 @@ export class ToolbarManager {
|
||||||
const slots: ToolbarSlot[] = [...tagIds];
|
const slots: ToolbarSlot[] = [...tagIds];
|
||||||
if (macroIds.length > 0) {
|
if (macroIds.length > 0) {
|
||||||
slots.push('');
|
slots.push('');
|
||||||
slots.push({ group: 'Macros', items: macroIds });
|
slots.push({
|
||||||
|
group: 'Macros',
|
||||||
|
items: macroIds,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
slots.push('', 'markdown', 'save', 'toggle');
|
slots.push('', 'markdown', 'save', 'toggle');
|
||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update .active class on buttons matching the cursor's formatting context.
|
* Toggle the active CSS class on buttons whose IDs appear in the
|
||||||
|
* given list of currently-active tag names.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* manager.updateActiveState(['bold', 'italic']);
|
||||||
*/
|
*/
|
||||||
updateActiveState(activeTagNames: string[]): void {
|
updateActiveState(activeTagNames: string[]): void {
|
||||||
for (const [id, button] of this.buttons) {
|
for (const [id, button] of this.buttons) {
|
||||||
button.element?.classList.toggle('active', activeTagNames.includes(id));
|
button.element?.classList.toggle(CSS_CLASS_ACTIVE, activeTagNames.includes(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable all toolbar buttons.
|
* Enable all toolbar buttons by removing the disabled CSS class.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* manager.enable();
|
||||||
*/
|
*/
|
||||||
enable(): void {
|
enable(): void {
|
||||||
for (const button of this.buttons.values()) {
|
for (const button of this.buttons.values()) {
|
||||||
button.element?.classList.remove('disabled');
|
button.element?.classList.remove(CSS_CLASS_DISABLED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable all toolbar buttons.
|
* Disable all toolbar buttons by adding the disabled CSS class.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* manager.disable();
|
||||||
*/
|
*/
|
||||||
disable(): void {
|
disable(): void {
|
||||||
for (const button of this.buttons.values()) {
|
for (const button of this.buttons.values()) {
|
||||||
button.element?.classList.add('disabled');
|
button.element?.classList.add(CSS_CLASS_DISABLED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the toolbar DOM and return it. Caller inserts it.
|
* Build the toolbar DOM tree and return the root element.
|
||||||
|
* The caller is responsible for inserting it into the document.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* document.body.prepend(manager.render());
|
||||||
*/
|
*/
|
||||||
render(): HTMLElement {
|
render(): HTMLElement {
|
||||||
const nav = document.createElement('nav');
|
const nav = document.createElement('nav');
|
||||||
nav.className = 'ribbit-toolbar';
|
nav.className = CSS_CLASS_TOOLBAR;
|
||||||
const ul = document.createElement('ul');
|
const list = document.createElement('ul');
|
||||||
|
|
||||||
for (const slot of this.layout) {
|
for (const slot of this.layout) {
|
||||||
if (slot === '') {
|
const element = this.renderSlot(slot);
|
||||||
const li = document.createElement('li');
|
if (element) {
|
||||||
li.className = 'spacer';
|
list.appendChild(element);
|
||||||
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);
|
nav.appendChild(list);
|
||||||
return nav;
|
return nav;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dispatches a single layout slot to the appropriate renderer. */
|
||||||
|
private renderSlot(slot: ToolbarSlot): HTMLElement | null {
|
||||||
|
if (slot === '') {
|
||||||
|
return this.renderSpacer();
|
||||||
|
}
|
||||||
|
if (typeof slot === 'string') {
|
||||||
|
return this.renderStringSlot(slot);
|
||||||
|
}
|
||||||
|
return this.renderGroupSlot(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSpacer(): HTMLElement {
|
||||||
|
const listItem = document.createElement('li');
|
||||||
|
listItem.className = CSS_CLASS_SPACER;
|
||||||
|
return listItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderStringSlot(slot: string): HTMLElement | null {
|
||||||
|
if (slot === 'macros') {
|
||||||
|
const items = [...this.buttons.values()].filter(button => button.id.startsWith(MACRO_ID_PREFIX));
|
||||||
|
if (items.length > 0) {
|
||||||
|
return this.renderGroup({
|
||||||
|
label: 'Macros',
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const button = this.buttons.get(slot);
|
||||||
|
if (button) {
|
||||||
|
return this.renderButton(button);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderGroupSlot(slot: { group: string; items: string[] }): HTMLElement | null {
|
||||||
|
const items = slot.items
|
||||||
|
.map(id => this.buttons.get(id))
|
||||||
|
.filter((button): button is Button => button !== undefined);
|
||||||
|
if (items.length > 0) {
|
||||||
|
return this.renderGroup({
|
||||||
|
label: slot.group,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private renderButton(button: Button): HTMLElement {
|
private renderButton(button: Button): HTMLElement {
|
||||||
const li = document.createElement('li');
|
const listItem = document.createElement('li');
|
||||||
const btn = document.createElement('button');
|
const buttonElement = document.createElement('button');
|
||||||
btn.className = `ribbit-btn-${button.id}`;
|
buttonElement.className = `ribbit-btn-${button.id}`;
|
||||||
btn.textContent = button.label;
|
buttonElement.textContent = button.label;
|
||||||
btn.setAttribute('aria-label', button.label);
|
buttonElement.setAttribute('aria-label', button.label);
|
||||||
btn.title = button.shortcut
|
buttonElement.title = button.shortcut
|
||||||
? `${button.label} (${button.shortcut})`
|
? `${button.label} (${button.shortcut})`
|
||||||
: button.label;
|
: button.label;
|
||||||
if (!button.visible) {
|
if (!button.visible) {
|
||||||
li.style.display = 'none';
|
listItem.style.display = CSS_DISPLAY_NONE;
|
||||||
}
|
}
|
||||||
btn.addEventListener('click', () => this.executeAction(button));
|
buttonElement.addEventListener('click', () => this.executeAction(button));
|
||||||
button.element = btn;
|
button.element = buttonElement;
|
||||||
li.appendChild(btn);
|
listItem.appendChild(buttonElement);
|
||||||
return li;
|
return listItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderGroup(group: { label: string; items: Button[] }): HTMLElement {
|
private renderGroup(group: { label: string; items: Button[] }): HTMLElement {
|
||||||
const li = document.createElement('li');
|
const listItem = document.createElement('li');
|
||||||
const toggle = document.createElement('button');
|
const toggle = document.createElement('button');
|
||||||
toggle.className = 'ribbit-btn-group';
|
toggle.className = CSS_CLASS_GROUP;
|
||||||
toggle.textContent = group.label + ' ▾';
|
toggle.textContent = group.label + DROPDOWN_INDICATOR;
|
||||||
toggle.setAttribute('aria-label', group.label);
|
toggle.setAttribute('aria-label', group.label);
|
||||||
toggle.title = group.label;
|
toggle.title = group.label;
|
||||||
|
|
||||||
const menu = document.createElement('div');
|
const menu = document.createElement('div');
|
||||||
menu.className = 'ribbit-dropdown';
|
menu.className = CSS_CLASS_DROPDOWN;
|
||||||
menu.style.display = 'none';
|
menu.style.display = CSS_DISPLAY_NONE;
|
||||||
|
|
||||||
for (const button of group.items) {
|
for (const button of group.items) {
|
||||||
const btn = document.createElement('button');
|
const buttonElement = this.renderDropdownItem(button, menu);
|
||||||
btn.className = `ribbit-btn-${button.id}`;
|
menu.appendChild(buttonElement);
|
||||||
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', () => {
|
toggle.addEventListener('click', () => {
|
||||||
menu.style.display = menu.style.display === 'none' ? '' : 'none';
|
menu.style.display = menu.style.display === CSS_DISPLAY_NONE ? '' : CSS_DISPLAY_NONE;
|
||||||
});
|
});
|
||||||
|
|
||||||
li.appendChild(toggle);
|
listItem.appendChild(toggle);
|
||||||
li.appendChild(menu);
|
listItem.appendChild(menu);
|
||||||
return li;
|
return listItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a single button element inside a dropdown menu. */
|
||||||
|
private renderDropdownItem(button: Button, menu: HTMLElement): HTMLElement {
|
||||||
|
const buttonElement = document.createElement('button');
|
||||||
|
buttonElement.className = `ribbit-btn-${button.id}`;
|
||||||
|
buttonElement.setAttribute('aria-label', button.label);
|
||||||
|
buttonElement.title = button.label;
|
||||||
|
buttonElement.textContent = button.label;
|
||||||
|
if (!button.visible) {
|
||||||
|
buttonElement.style.display = CSS_DISPLAY_NONE;
|
||||||
|
}
|
||||||
|
buttonElement.addEventListener('click', () => {
|
||||||
|
this.executeAction(button);
|
||||||
|
menu.style.display = CSS_DISPLAY_NONE;
|
||||||
|
});
|
||||||
|
button.element = buttonElement;
|
||||||
|
return buttonElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
private executeAction(button: Button): void {
|
private executeAction(button: Button): void {
|
||||||
|
|
@ -351,23 +506,25 @@ export class ToolbarManager {
|
||||||
this.editor.element.focus();
|
this.editor.element.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wraps the current selection with the given delimiter on both sides. */
|
||||||
private wrapSelection(delimiter: string): void {
|
private wrapSelection(delimiter: string): void {
|
||||||
const sel = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!sel || sel.rangeCount === 0) {
|
if (!selection || selection.rangeCount === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const range = sel.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
const text = range.toString();
|
const text = range.toString();
|
||||||
range.deleteContents();
|
range.deleteContents();
|
||||||
range.insertNode(document.createTextNode(delimiter + text + delimiter));
|
range.insertNode(document.createTextNode(delimiter + text + delimiter));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inserts text at the cursor, optionally replacing the current selection. */
|
||||||
private insertText(text: string, replaceSelection: boolean): void {
|
private insertText(text: string, replaceSelection: boolean): void {
|
||||||
const sel = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!sel || sel.rangeCount === 0) {
|
if (!selection || selection.rangeCount === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const range = sel.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
if (replaceSelection) {
|
if (replaceSelection) {
|
||||||
range.deleteContents();
|
range.deleteContents();
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
123
src/ts/types.ts
123
src/ts/types.ts
|
|
@ -1,7 +1,15 @@
|
||||||
/*
|
/*
|
||||||
* types.ts — shared types for the ribbit editor.
|
* types.ts — shared type definitions for the ribbit editor.
|
||||||
|
*
|
||||||
|
* All interfaces used across multiple modules live here to avoid
|
||||||
|
* circular imports. Module-specific types stay in their own files.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of a Tag's match() call. Carries the matched content,
|
||||||
|
* the raw matched text, how many source lines were consumed, and
|
||||||
|
* optional metadata (e.g. heading level, link href).
|
||||||
|
*/
|
||||||
export interface SourceToken {
|
export interface SourceToken {
|
||||||
content: string;
|
content: string;
|
||||||
raw: string;
|
raw: string;
|
||||||
|
|
@ -9,13 +17,23 @@ export interface SourceToken {
|
||||||
meta?: Record<string, string>;
|
meta?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conversion functions passed to Tag.toHTML and Tag.toMarkdown so
|
||||||
|
* tags can recursively convert their children without knowing about
|
||||||
|
* the HopDown instance.
|
||||||
|
*/
|
||||||
export interface Converter {
|
export interface Converter {
|
||||||
inline: (text: string) => string;
|
inline: (text: string) => string;
|
||||||
block: (md: string) => string;
|
block: (markdown: string) => string;
|
||||||
children: (node: Node) => string;
|
children: (node: Node) => string;
|
||||||
node: (node: Node) => string;
|
node: (node: Node) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context passed to Tag.match() during block-level scanning.
|
||||||
|
* `lines` and `index` are for block matching; `text` and `offset`
|
||||||
|
* are for inline matching within a single line.
|
||||||
|
*/
|
||||||
export interface MatchContext {
|
export interface MatchContext {
|
||||||
lines: string[];
|
lines: string[];
|
||||||
index: number;
|
index: number;
|
||||||
|
|
@ -23,6 +41,9 @@ export interface MatchContext {
|
||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a toolbar button's appearance and shortcut.
|
||||||
|
*/
|
||||||
export interface ToolbarButton {
|
export interface ToolbarButton {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -30,6 +51,12 @@ export interface ToolbarButton {
|
||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Tag is the core abstraction: it knows how to match markdown syntax,
|
||||||
|
* convert it to HTML, and convert the HTML back to markdown. Tags are
|
||||||
|
* registered by HTML selector (e.g. 'STRONG,B') so the converter can
|
||||||
|
* look them up during HTML→markdown conversion.
|
||||||
|
*/
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
name: string;
|
name: string;
|
||||||
match: (context: MatchContext) => SourceToken | null;
|
match: (context: MatchContext) => SourceToken | null;
|
||||||
|
|
@ -45,16 +72,28 @@ export interface Tag {
|
||||||
button?: ToolbarButton;
|
button?: ToolbarButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single item in a parsed list, with optional nested sublist HTML.
|
||||||
|
*/
|
||||||
export interface ListItem {
|
export interface ListItem {
|
||||||
text: string;
|
text: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of parsing a list block: the generated HTML and the line
|
||||||
|
* index where the list ends (so the caller can advance past it).
|
||||||
|
*/
|
||||||
export interface ListResult {
|
export interface ListResult {
|
||||||
html: string;
|
html: string;
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand definition for creating inline tags via the inlineTag()
|
||||||
|
* factory. Covers the common case where a delimiter wraps content
|
||||||
|
* and maps to a single HTML element.
|
||||||
|
*/
|
||||||
export interface InlineTagDef {
|
export interface InlineTagDef {
|
||||||
name: string;
|
name: string;
|
||||||
delimiter: string;
|
delimiter: string;
|
||||||
|
|
@ -73,26 +112,37 @@ export interface RibbitThemeFeatures {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transport for syncing document changes between clients.
|
* Transport for syncing document changes between clients.
|
||||||
* The consumer implements this with their choice of network layer.
|
* The consumer implements this with their choice of network layer
|
||||||
|
* (WebSocket, WebRTC, HTTP polling, etc.). Ribbit never makes
|
||||||
|
* network calls itself.
|
||||||
*
|
*
|
||||||
* { connect() { ws.open(); },
|
* const transport: DocumentTransport = {
|
||||||
* disconnect() { ws.close(); },
|
* connect() { socket.open(); },
|
||||||
* send(update) { ws.send(update); },
|
* disconnect() { socket.close(); },
|
||||||
* onReceive(cb) { ws.onmessage = (e) => cb(e.data); } }
|
* send(update) { socket.send(update); },
|
||||||
|
* onReceive(callback) { socket.onmessage = (event) => callback(event.data); },
|
||||||
|
* };
|
||||||
*/
|
*/
|
||||||
export interface DocumentTransport {
|
export interface DocumentTransport {
|
||||||
connect(): void;
|
connect(): void;
|
||||||
disconnect(): void;
|
disconnect(): void;
|
||||||
send(update: Uint8Array): void;
|
send(update: Uint8Array): void;
|
||||||
onReceive(callback: (update: Uint8Array) => void): void;
|
onReceive(callback: (update: Uint8Array) => void): void;
|
||||||
|
lock?(): Promise<boolean>;
|
||||||
|
unlock?(): void;
|
||||||
|
forceLock?(): Promise<boolean>;
|
||||||
|
onLockChange?(callback: (holder: PeerInfo | null) => void): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Channel for broadcasting cursor position and user presence.
|
* Channel for broadcasting cursor position and user presence.
|
||||||
* Optional — collaboration works without it.
|
* Optional — collaboration works without it, but users won't see
|
||||||
|
* each other's cursors.
|
||||||
*
|
*
|
||||||
* { send(info) { ws.send(JSON.stringify(info)); },
|
* const presence: PresenceChannel = {
|
||||||
* onUpdate(cb) { ws.onmessage = (e) => cb(JSON.parse(e.data)); } }
|
* send(info) { socket.send(JSON.stringify(info)); },
|
||||||
|
* onUpdate(callback) { socket.onmessage = (event) => callback(JSON.parse(event.data)); },
|
||||||
|
* };
|
||||||
*/
|
*/
|
||||||
export interface PresenceChannel {
|
export interface PresenceChannel {
|
||||||
send(info: PeerInfo): void;
|
send(info: PeerInfo): void;
|
||||||
|
|
@ -118,28 +168,13 @@ export interface CollaborationSettings {
|
||||||
revisions?: RevisionProvider;
|
revisions?: RevisionProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentTransport {
|
/**
|
||||||
connect(): void;
|
* Storage backend for document revisions. The consumer implements
|
||||||
disconnect(): void;
|
* this with their persistence layer (database, API, localStorage, etc.).
|
||||||
send(update: Uint8Array): void;
|
*/
|
||||||
onReceive(callback: (update: Uint8Array) => void): void;
|
|
||||||
lock?(): Promise<boolean>;
|
|
||||||
unlock?(): void;
|
|
||||||
forceLock?(): Promise<boolean>;
|
|
||||||
onLockChange?(callback: (holder: PeerInfo | null) => void): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PresenceChannel {
|
|
||||||
send(info: PeerInfo): void;
|
|
||||||
onUpdate(callback: (peers: PeerInfo[]) => void): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RevisionProvider {
|
export interface RevisionProvider {
|
||||||
/** List all revisions for the current document. */
|
|
||||||
list(): Promise<Revision[]>;
|
list(): Promise<Revision[]>;
|
||||||
/** Get a specific revision's content. */
|
|
||||||
get(id: string): Promise<Revision & { content: string }>;
|
get(id: string): Promise<Revision & { content: string }>;
|
||||||
/** Create a new revision from the given content. */
|
|
||||||
create(content: string, metadata?: RevisionMetadata): Promise<Revision>;
|
create(content: string, metadata?: RevisionMetadata): Promise<Revision>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,7 +191,8 @@ export interface RevisionMetadata {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A slot in the toolbar layout.
|
* A slot in the toolbar layout. Strings reference tag names or
|
||||||
|
* special values; objects define dropdown groups.
|
||||||
*
|
*
|
||||||
* 'bold' — single button
|
* 'bold' — single button
|
||||||
* '' — spacer
|
* '' — spacer
|
||||||
|
|
@ -165,10 +201,13 @@ export interface RevisionMetadata {
|
||||||
*/
|
*/
|
||||||
export type ToolbarSlot =
|
export type ToolbarSlot =
|
||||||
| string
|
| string
|
||||||
| { group: string; items: string[] };
|
| {
|
||||||
|
group: string;
|
||||||
|
items: string[];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A resolved toolbar button with methods for interaction.
|
* A resolved toolbar button with DOM element and interaction methods.
|
||||||
*/
|
*/
|
||||||
export interface Button {
|
export interface Button {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -192,3 +231,23 @@ export interface RibbitTheme {
|
||||||
tags?: Record<string, Tag>;
|
tags?: Record<string, Tag>;
|
||||||
features?: RibbitThemeFeatures;
|
features?: RibbitThemeFeatures;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of finding a complete delimiter pair (e.g. **bold**) or
|
||||||
|
* an unclosed opener (e.g. **bold) in a text string. Used by the
|
||||||
|
* WYSIWYG editor to transform inline formatting in-place.
|
||||||
|
*/
|
||||||
|
export interface DelimiterMatch {
|
||||||
|
/** The Tag definition that matched. */
|
||||||
|
tag: Tag;
|
||||||
|
/** The HTML element name to use (e.g. 'strong', 'em'). */
|
||||||
|
htmlTag: string;
|
||||||
|
/** The matched content between delimiters. */
|
||||||
|
content: string;
|
||||||
|
/** Start index of the full match in the source string. */
|
||||||
|
index: number;
|
||||||
|
/** Length of the full match including delimiters. */
|
||||||
|
length: number;
|
||||||
|
/** The delimiter string (e.g. '**', '*', '`'). */
|
||||||
|
delimiter: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
187
src/ts/vim.ts
187
src/ts/vim.ts
|
|
@ -21,10 +21,53 @@
|
||||||
|
|
||||||
type VimMode = 'normal' | 'insert';
|
type VimMode = 'normal' | 'insert';
|
||||||
|
|
||||||
|
/** Direction constants for cursor movement to avoid magic strings. */
|
||||||
|
const DIRECTION = {
|
||||||
|
LEFT: 'left' as const,
|
||||||
|
RIGHT: 'right' as const,
|
||||||
|
UP: 'up' as const,
|
||||||
|
DOWN: 'down' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Selection API direction mappings. */
|
||||||
|
const SELECTION_DIRECTION = {
|
||||||
|
BACKWARD: 'backward' as const,
|
||||||
|
FORWARD: 'forward' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Selection API granularity mappings. */
|
||||||
|
const SELECTION_GRANULARITY = {
|
||||||
|
CHARACTER: 'character' as const,
|
||||||
|
LINE: 'line' as const,
|
||||||
|
WORD: 'word' as const,
|
||||||
|
LINE_BOUNDARY: 'lineboundary' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Regex to match digit keys for count prefix accumulation. */
|
||||||
|
const DIGIT_PATTERN = /^[0-9]$/;
|
||||||
|
|
||||||
|
/** Default repeat count when no count prefix is given. */
|
||||||
|
const DEFAULT_REPEAT_COUNT = '1';
|
||||||
|
|
||||||
|
/** Radix for parsing count prefix strings. */
|
||||||
|
const DECIMAL_RADIX = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles vim-style keybindings in ribbit's source edit mode.
|
||||||
|
*
|
||||||
|
* Supports normal and insert modes with standard vim motions,
|
||||||
|
* editing commands, and count prefixes.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const vim = new VimHandler((mode) => {
|
||||||
|
* statusBar.textContent = mode;
|
||||||
|
* });
|
||||||
|
* vim.attach(editorElement);
|
||||||
|
*/
|
||||||
export class VimHandler {
|
export class VimHandler {
|
||||||
mode: VimMode;
|
mode: VimMode;
|
||||||
private element: HTMLElement | null;
|
private element: HTMLElement | null;
|
||||||
private listener: ((e: KeyboardEvent) => void) | null;
|
private listener: ((event: KeyboardEvent) => void) | null;
|
||||||
private pending: string;
|
private pending: string;
|
||||||
private count: string;
|
private count: string;
|
||||||
private onModeChange: (mode: VimMode) => void;
|
private onModeChange: (mode: VimMode) => void;
|
||||||
|
|
@ -38,15 +81,27 @@ export class VimHandler {
|
||||||
this.onModeChange = onModeChange;
|
this.onModeChange = onModeChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind vim keybindings to a DOM element.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* vim.attach(document.getElementById('editor'));
|
||||||
|
*/
|
||||||
attach(element: HTMLElement): void {
|
attach(element: HTMLElement): void {
|
||||||
this.detach();
|
this.detach();
|
||||||
this.element = element;
|
this.element = element;
|
||||||
this.pending = '';
|
this.pending = '';
|
||||||
this.listener = (e: KeyboardEvent) => this.handleKey(e);
|
this.listener = (event: KeyboardEvent) => this.handleKey(event);
|
||||||
this.element.addEventListener('keydown', this.listener);
|
this.element.addEventListener('keydown', this.listener);
|
||||||
this.setMode('insert');
|
this.setMode('insert');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove vim keybindings from the current element.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* vim.detach();
|
||||||
|
*/
|
||||||
detach(): void {
|
detach(): void {
|
||||||
if (this.element && this.listener) {
|
if (this.element && this.listener) {
|
||||||
this.element.removeEventListener('keydown', this.listener);
|
this.element.removeEventListener('keydown', this.listener);
|
||||||
|
|
@ -65,54 +120,64 @@ export class VimHandler {
|
||||||
this.onModeChange(mode);
|
this.onModeChange(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleKey(e: KeyboardEvent): void {
|
/**
|
||||||
|
* Routes keystrokes to insert-mode or normal-mode handling.
|
||||||
|
* Insert mode only intercepts Escape; normal mode handles
|
||||||
|
* all vim commands and suppresses default text input.
|
||||||
|
*/
|
||||||
|
private handleKey(event: KeyboardEvent): void {
|
||||||
if (this.mode === 'insert') {
|
if (this.mode === 'insert') {
|
||||||
if (e.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
this.setMode('normal');
|
this.setMode('normal');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal mode — prevent all default text input
|
// Suppress default text input in normal mode
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
// Undo/redo with Ctrl
|
if (event.ctrlKey) {
|
||||||
if (e.ctrlKey) {
|
if (event.key === 'r') {
|
||||||
if (e.key === 'r') {
|
|
||||||
document.execCommand('redo');
|
document.execCommand('redo');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = e.key;
|
const key = event.key;
|
||||||
|
|
||||||
// Accumulate count prefix (digits, but not 0 as first char — that's line start)
|
// Accumulate count prefix — 0 as first char is line-start, not count
|
||||||
if (/^[0-9]$/.test(key) && (this.count || key !== '0')) {
|
if (DIGIT_PATTERN.test(key) && (this.count || key !== '0')) {
|
||||||
this.count += key;
|
this.count += key;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const repeat = parseInt(this.count || '1', 10);
|
const repeat = parseInt(this.count || DEFAULT_REPEAT_COUNT, DECIMAL_RADIX);
|
||||||
this.count = '';
|
this.count = '';
|
||||||
|
|
||||||
// Two-char commands
|
|
||||||
if (this.pending) {
|
if (this.pending) {
|
||||||
const combo = this.pending + key;
|
const combo = this.pending + key;
|
||||||
this.pending = '';
|
this.pending = '';
|
||||||
for (let n = 0; n < repeat; n++) {
|
for (let step = 0; step < repeat; step++) {
|
||||||
this.handlePending(combo);
|
this.handlePending(combo);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.dispatchNormalKey(key, repeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches a normal-mode key to the appropriate command.
|
||||||
|
* Separated from handleKey to keep nesting shallow.
|
||||||
|
*/
|
||||||
|
private dispatchNormalKey(key: string, repeat: number): void {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
// Mode switching — no repeat
|
|
||||||
case 'i':
|
case 'i':
|
||||||
this.setMode('insert');
|
this.setMode('insert');
|
||||||
break;
|
break;
|
||||||
case 'a':
|
case 'a':
|
||||||
this.moveCursor('right');
|
this.moveCursor(DIRECTION.RIGHT);
|
||||||
this.setMode('insert');
|
this.setMode('insert');
|
||||||
break;
|
break;
|
||||||
case 'o':
|
case 'o':
|
||||||
|
|
@ -123,28 +188,39 @@ export class VimHandler {
|
||||||
case 'O':
|
case 'O':
|
||||||
this.startOfLine();
|
this.startOfLine();
|
||||||
this.insertNewline();
|
this.insertNewline();
|
||||||
this.moveCursor('up');
|
this.moveCursor(DIRECTION.UP);
|
||||||
this.setMode('insert');
|
this.setMode('insert');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Movement — repeatable
|
|
||||||
case 'h':
|
case 'h':
|
||||||
for (let n = 0; n < repeat; n++) this.moveCursor('left');
|
for (let step = 0; step < repeat; step++) {
|
||||||
|
this.moveCursor(DIRECTION.LEFT);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'j':
|
case 'j':
|
||||||
for (let n = 0; n < repeat; n++) this.moveCursor('down');
|
for (let step = 0; step < repeat; step++) {
|
||||||
|
this.moveCursor(DIRECTION.DOWN);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'k':
|
case 'k':
|
||||||
for (let n = 0; n < repeat; n++) this.moveCursor('up');
|
for (let step = 0; step < repeat; step++) {
|
||||||
|
this.moveCursor(DIRECTION.UP);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'l':
|
case 'l':
|
||||||
for (let n = 0; n < repeat; n++) this.moveCursor('right');
|
for (let step = 0; step < repeat; step++) {
|
||||||
|
this.moveCursor(DIRECTION.RIGHT);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'w':
|
case 'w':
|
||||||
for (let n = 0; n < repeat; n++) this.wordForward();
|
for (let step = 0; step < repeat; step++) {
|
||||||
|
this.wordForward();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'b':
|
case 'b':
|
||||||
for (let n = 0; n < repeat; n++) this.wordBack();
|
for (let step = 0; step < repeat; step++) {
|
||||||
|
this.wordBack();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case '0':
|
case '0':
|
||||||
this.startOfLine();
|
this.startOfLine();
|
||||||
|
|
@ -156,19 +232,21 @@ export class VimHandler {
|
||||||
this.endOfDocument();
|
this.endOfDocument();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Editing — repeatable
|
|
||||||
case 'x':
|
case 'x':
|
||||||
for (let n = 0; n < repeat; n++) this.deleteChar();
|
for (let step = 0; step < repeat; step++) {
|
||||||
|
this.deleteChar();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'u':
|
case 'u':
|
||||||
for (let n = 0; n < repeat; n++) document.execCommand('undo');
|
for (let step = 0; step < repeat; step++) {
|
||||||
|
document.execCommand('undo');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Pending commands — count preserved for the second key
|
// Two-char commands — preserve count for the second key
|
||||||
case 'd':
|
case 'd':
|
||||||
case 'g':
|
case 'g':
|
||||||
this.pending = key;
|
this.pending = key;
|
||||||
// Restore count so it's available for the pending handler
|
|
||||||
if (repeat > 1) {
|
if (repeat > 1) {
|
||||||
this.count = String(repeat);
|
this.count = String(repeat);
|
||||||
}
|
}
|
||||||
|
|
@ -188,46 +266,57 @@ export class VimHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private moveCursor(direction: 'left' | 'right' | 'up' | 'down'): void {
|
private moveCursor(direction: 'left' | 'right' | 'up' | 'down'): void {
|
||||||
const sel = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!sel) return;
|
if (!selection) {
|
||||||
sel.modify('move', direction === 'left' || direction === 'up' ? 'backward' : 'forward',
|
return;
|
||||||
direction === 'up' || direction === 'down' ? 'line' : 'character');
|
}
|
||||||
|
const selectionDirection = (direction === DIRECTION.LEFT || direction === DIRECTION.UP)
|
||||||
|
? SELECTION_DIRECTION.BACKWARD
|
||||||
|
: SELECTION_DIRECTION.FORWARD;
|
||||||
|
const granularity = (direction === DIRECTION.UP || direction === DIRECTION.DOWN)
|
||||||
|
? SELECTION_GRANULARITY.LINE
|
||||||
|
: SELECTION_GRANULARITY.CHARACTER;
|
||||||
|
selection.modify('move', selectionDirection, granularity);
|
||||||
}
|
}
|
||||||
|
|
||||||
private wordForward(): void {
|
private wordForward(): void {
|
||||||
window.getSelection()?.modify('move', 'forward', 'word');
|
window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.WORD);
|
||||||
}
|
}
|
||||||
|
|
||||||
private wordBack(): void {
|
private wordBack(): void {
|
||||||
window.getSelection()?.modify('move', 'backward', 'word');
|
window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.WORD);
|
||||||
}
|
}
|
||||||
|
|
||||||
private startOfLine(): void {
|
private startOfLine(): void {
|
||||||
window.getSelection()?.modify('move', 'backward', 'lineboundary');
|
window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
|
||||||
}
|
}
|
||||||
|
|
||||||
private endOfLine(): void {
|
private endOfLine(): void {
|
||||||
window.getSelection()?.modify('move', 'forward', 'lineboundary');
|
window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
|
||||||
}
|
}
|
||||||
|
|
||||||
private startOfDocument(): void {
|
private startOfDocument(): void {
|
||||||
const sel = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!sel || !this.element) return;
|
if (!selection || !this.element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.setStart(this.element, 0);
|
range.setStart(this.element, 0);
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
sel.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
sel.addRange(range);
|
selection.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
private endOfDocument(): void {
|
private endOfDocument(): void {
|
||||||
const sel = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!sel || !this.element) return;
|
if (!selection || !this.element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.selectNodeContents(this.element);
|
range.selectNodeContents(this.element);
|
||||||
range.collapse(false);
|
range.collapse(false);
|
||||||
sel.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
sel.addRange(range);
|
selection.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
private deleteChar(): void {
|
private deleteChar(): void {
|
||||||
|
|
@ -236,9 +325,9 @@ export class VimHandler {
|
||||||
|
|
||||||
private deleteLine(): void {
|
private deleteLine(): void {
|
||||||
this.startOfLine();
|
this.startOfLine();
|
||||||
window.getSelection()?.modify('extend', 'forward', 'lineboundary');
|
window.getSelection()?.modify('extend', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
|
||||||
document.execCommand('delete');
|
document.execCommand('delete');
|
||||||
// Delete the newline too
|
// Remove the trailing newline left after deleting line content
|
||||||
document.execCommand('forwardDelete');
|
document.execCommand('forwardDelete');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ribbit, resetDOM } from './setup';
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
const r = ribbit();
|
const lib = ribbit();
|
||||||
|
|
||||||
function mockTransport() {
|
function mockTransport() {
|
||||||
const receiveListeners: Array<(update: Uint8Array) => void> = [];
|
const receiveListeners: Array<(update: Uint8Array) => void> = [];
|
||||||
|
|
@ -9,19 +9,39 @@ function mockTransport() {
|
||||||
connected: false,
|
connected: false,
|
||||||
sent: [] as Uint8Array[],
|
sent: [] as Uint8Array[],
|
||||||
locked: false,
|
locked: false,
|
||||||
connect() { this.connected = true; },
|
connect() {
|
||||||
disconnect() { this.connected = false; },
|
this.connected = true;
|
||||||
send(update: Uint8Array) { this.sent.push(update); },
|
},
|
||||||
onReceive(cb: (update: Uint8Array) => void) { receiveListeners.push(cb); },
|
disconnect() {
|
||||||
|
this.connected = false;
|
||||||
|
},
|
||||||
|
send(update: Uint8Array) {
|
||||||
|
this.sent.push(update);
|
||||||
|
},
|
||||||
|
onReceive(cb: (update: Uint8Array) => void) {
|
||||||
|
receiveListeners.push(cb);
|
||||||
|
},
|
||||||
simulateRemote(content: string) {
|
simulateRemote(content: string) {
|
||||||
const encoded = new TextEncoder().encode(content);
|
const encoded = new TextEncoder().encode(content);
|
||||||
receiveListeners.forEach(cb => cb(encoded));
|
receiveListeners.forEach(cb => cb(encoded));
|
||||||
},
|
},
|
||||||
lock: async function() { this.locked = true; return true; },
|
lock: async function() {
|
||||||
unlock() { this.locked = false; },
|
this.locked = true;
|
||||||
forceLock: async function() { this.locked = true; return true; },
|
return true;
|
||||||
onLockChange(cb: (holder: any) => void) { lockListeners.push(cb); },
|
},
|
||||||
simulateLock(holder: any) { lockListeners.forEach(cb => cb(holder)); },
|
unlock() {
|
||||||
|
this.locked = false;
|
||||||
|
},
|
||||||
|
forceLock: async function() {
|
||||||
|
this.locked = true;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onLockChange(cb: (holder: any) => void) {
|
||||||
|
lockListeners.push(cb);
|
||||||
|
},
|
||||||
|
simulateLock(holder: any) {
|
||||||
|
lockListeners.forEach(cb => cb(holder));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,9 +49,15 @@ function mockPresence() {
|
||||||
const listeners: Array<(peers: any[]) => void> = [];
|
const listeners: Array<(peers: any[]) => void> = [];
|
||||||
return {
|
return {
|
||||||
lastSent: null as any,
|
lastSent: null as any,
|
||||||
send(info: any) { this.lastSent = info; },
|
send(info: any) {
|
||||||
onUpdate(cb: (peers: any[]) => void) { listeners.push(cb); },
|
this.lastSent = info;
|
||||||
simulatePeers(peers: any[]) { listeners.forEach(cb => cb(peers)); },
|
},
|
||||||
|
onUpdate(cb: (peers: any[]) => void) {
|
||||||
|
listeners.push(cb);
|
||||||
|
},
|
||||||
|
simulatePeers(peers: any[]) {
|
||||||
|
listeners.forEach(cb => cb(peers));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,9 +66,14 @@ function mockRevisions() {
|
||||||
return {
|
return {
|
||||||
store,
|
store,
|
||||||
list: async () => store,
|
list: async () => store,
|
||||||
get: async (id: string) => store.find((r: any) => r.id === id),
|
get: async (id: string) => store.find((rev: any) => rev.id === id),
|
||||||
create: async (content: string, meta?: any) => {
|
create: async (content: string, meta?: any) => {
|
||||||
const rev = { id: String(store.length + 1), timestamp: new Date().toISOString(), content, ...meta };
|
const rev = {
|
||||||
|
id: String(store.length + 1),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
content,
|
||||||
|
...meta,
|
||||||
|
};
|
||||||
store.push(rev);
|
store.push(rev);
|
||||||
return rev;
|
return rev;
|
||||||
},
|
},
|
||||||
|
|
@ -53,15 +84,23 @@ describe('CollaborationManager', () => {
|
||||||
beforeEach(() => resetDOM('initial'));
|
beforeEach(() => resetDOM('initial'));
|
||||||
|
|
||||||
it('does not create manager without settings', () => {
|
it('does not create manager without settings', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.collaboration).toBeUndefined();
|
expect(editor.collaboration).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates manager with settings', () => {
|
it('creates manager with settings', () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.collaboration).toBeDefined();
|
expect(editor.collaboration).toBeDefined();
|
||||||
|
|
@ -70,8 +109,16 @@ describe('CollaborationManager', () => {
|
||||||
describe('connection lifecycle', () => {
|
describe('connection lifecycle', () => {
|
||||||
it('connects on wysiwyg', () => {
|
it('connects on wysiwyg', () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.wysiwyg();
|
editor.wysiwyg();
|
||||||
|
|
@ -80,8 +127,16 @@ describe('CollaborationManager', () => {
|
||||||
|
|
||||||
it('connects on edit', () => {
|
it('connects on edit', () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.edit();
|
editor.edit();
|
||||||
|
|
@ -90,8 +145,16 @@ describe('CollaborationManager', () => {
|
||||||
|
|
||||||
it('disconnects on view', () => {
|
it('disconnects on view', () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.wysiwyg();
|
editor.wysiwyg();
|
||||||
|
|
@ -103,8 +166,16 @@ describe('CollaborationManager', () => {
|
||||||
describe('source mode pausing', () => {
|
describe('source mode pausing', () => {
|
||||||
it('pauses on entering source mode', () => {
|
it('pauses on entering source mode', () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.edit();
|
editor.edit();
|
||||||
|
|
@ -113,8 +184,16 @@ describe('CollaborationManager', () => {
|
||||||
|
|
||||||
it('counts remote changes while paused', () => {
|
it('counts remote changes while paused', () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.edit();
|
editor.edit();
|
||||||
|
|
@ -125,9 +204,23 @@ describe('CollaborationManager', () => {
|
||||||
|
|
||||||
it('fires remoteActivity event while paused', (done) => {
|
it('fires remoteActivity event while paused', (done) => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
on: { remoteActivity: ({ count }: any) => { if (count === 1) done(); } },
|
transport,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
on: {
|
||||||
|
remoteActivity: ({ count }: any) => {
|
||||||
|
if (count === 1) {
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.edit();
|
editor.edit();
|
||||||
|
|
@ -136,8 +229,16 @@ describe('CollaborationManager', () => {
|
||||||
|
|
||||||
it('resumes on switching to wysiwyg', () => {
|
it('resumes on switching to wysiwyg', () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.edit();
|
editor.edit();
|
||||||
|
|
@ -149,8 +250,16 @@ describe('CollaborationManager', () => {
|
||||||
describe('locking', () => {
|
describe('locking', () => {
|
||||||
it('lock returns true', async () => {
|
it('lock returns true', async () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(await editor.lockForEditing()).toBe(true);
|
expect(await editor.lockForEditing()).toBe(true);
|
||||||
|
|
@ -158,8 +267,16 @@ describe('CollaborationManager', () => {
|
||||||
|
|
||||||
it('forceLock returns true', async () => {
|
it('forceLock returns true', async () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(await editor.forceLockEditing()).toBe(true);
|
expect(await editor.forceLockEditing()).toBe(true);
|
||||||
|
|
@ -167,12 +284,31 @@ describe('CollaborationManager', () => {
|
||||||
|
|
||||||
it('fires lockChange event', (done) => {
|
it('fires lockChange event', (done) => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
on: { lockChange: ({ holder }: any) => { if (holder?.userId === 'alice') done(); } },
|
transport,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
on: {
|
||||||
|
lockChange: ({ holder }: any) => {
|
||||||
|
if (holder?.userId === 'alice') {
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
transport.simulateLock({ userId: 'alice', displayName: 'Alice', status: 'active', lastActive: Date.now() });
|
transport.simulateLock({
|
||||||
|
userId: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -180,8 +316,18 @@ describe('CollaborationManager', () => {
|
||||||
it('sends cursor with status', () => {
|
it('sends cursor with status', () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const presence = mockPresence();
|
const presence = mockPresence();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, presence, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now(), color: '#f00' } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
presence,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
color: '#f00',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.wysiwyg();
|
editor.wysiwyg();
|
||||||
|
|
@ -193,8 +339,17 @@ describe('CollaborationManager', () => {
|
||||||
it('sends editing status when paused', () => {
|
it('sends editing status when paused', () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const presence = mockPresence();
|
const presence = mockPresence();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, presence, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
presence,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.edit();
|
editor.edit();
|
||||||
|
|
@ -205,13 +360,33 @@ describe('CollaborationManager', () => {
|
||||||
it('applies idle status to peers', () => {
|
it('applies idle status to peers', () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const presence = mockPresence();
|
const presence = mockPresence();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, presence, idleTimeout: 100, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
presence,
|
||||||
|
idleTimeout: 100,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
presence.simulatePeers([
|
presence.simulatePeers([
|
||||||
{ userId: 'a', displayName: 'A', status: 'active', lastActive: Date.now() - 200 },
|
{
|
||||||
{ userId: 'b', displayName: 'B', status: 'active', lastActive: Date.now() },
|
userId: 'a',
|
||||||
|
displayName: 'A',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now() - 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: 'b',
|
||||||
|
displayName: 'B',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
const peers = editor.collaboration!.getPeers();
|
const peers = editor.collaboration!.getPeers();
|
||||||
expect(peers[0].status).toBe('idle');
|
expect(peers[0].status).toBe('idle');
|
||||||
|
|
@ -224,8 +399,17 @@ describe('CollaborationManager', () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const revisions = mockRevisions();
|
const revisions = mockRevisions();
|
||||||
await revisions.create('v1', { author: 'test' });
|
await revisions.create('v1', { author: 'test' });
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
revisions,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
const list = await editor.listRevisions();
|
const list = await editor.listRevisions();
|
||||||
|
|
@ -235,11 +419,23 @@ describe('CollaborationManager', () => {
|
||||||
it('creates revision', async () => {
|
it('creates revision', async () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const revisions = mockRevisions();
|
const revisions = mockRevisions();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
revisions,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
const rev = await editor.createRevision({ author: 'test', summary: 'test rev' });
|
const rev = await editor.createRevision({
|
||||||
|
author: 'test',
|
||||||
|
summary: 'test rev',
|
||||||
|
});
|
||||||
expect(rev).toBeDefined();
|
expect(rev).toBeDefined();
|
||||||
expect(revisions.store).toHaveLength(1);
|
expect(revisions.store).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
@ -248,8 +444,17 @@ describe('CollaborationManager', () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const revisions = mockRevisions();
|
const revisions = mockRevisions();
|
||||||
await revisions.create('old content', { author: 'test' });
|
await revisions.create('old content', { author: 'test' });
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
revisions,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.wysiwyg();
|
editor.wysiwyg();
|
||||||
|
|
@ -261,9 +466,22 @@ describe('CollaborationManager', () => {
|
||||||
const transport = mockTransport();
|
const transport = mockTransport();
|
||||||
const revisions = mockRevisions();
|
const revisions = mockRevisions();
|
||||||
let fired = false;
|
let fired = false;
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
collaboration: {
|
||||||
on: { revisionCreated: () => { fired = true; } },
|
transport,
|
||||||
|
revisions,
|
||||||
|
user: {
|
||||||
|
userId: 'test',
|
||||||
|
displayName: 'Test',
|
||||||
|
status: 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
on: {
|
||||||
|
revisionCreated: () => {
|
||||||
|
fired = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
await editor.createRevision({ author: 'test' });
|
await editor.createRevision({ author: 'test' });
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,109 @@
|
||||||
import { ribbit, resetDOM } from './setup';
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
const r = ribbit();
|
const lib = ribbit();
|
||||||
|
|
||||||
describe('Custom inline tags', () => {
|
|
||||||
const strikethrough = r.inlineTag({
|
|
||||||
name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE', precedence: 45,
|
|
||||||
});
|
|
||||||
const h = new r.HopDown({ tags: { ...r.defaultTags, 'DEL,S,STRIKE': strikethrough } });
|
|
||||||
|
|
||||||
it('md→html', () => expect(h.toHTML('~~struck~~')).toBe('<p><del>struck</del></p>'));
|
|
||||||
it('html→md', () => expect(h.toMarkdown('<p><del>struck</del></p>')).toContain('~~struck~~'));
|
|
||||||
it('round-trip', () => expect(h.toMarkdown(h.toHTML('~~struck~~'))).toBe('~~struck~~'));
|
|
||||||
it('mixed with bold', () => expect(h.toHTML('**bold** and ~~struck~~')).toContain('<del>struck</del>'));
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Custom block tags', () => {
|
describe('Custom block tags', () => {
|
||||||
const spoiler = {
|
const spoiler = {
|
||||||
name: 'spoiler',
|
name: 'spoiler',
|
||||||
match: (context: any) => {
|
match: (context: any) => {
|
||||||
if (!/^\|{3,}/.test(context.lines[context.index])) return null;
|
const fencePattern = /^\|{3,}/;
|
||||||
|
if (!fencePattern.test(context.lines[context.index])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const content: string[] = [];
|
const content: string[] = [];
|
||||||
let i = context.index + 1;
|
let lineIndex = context.index + 1;
|
||||||
while (i < context.lines.length && !/^\|{3,}/.test(context.lines[i])) content.push(context.lines[i++]);
|
while (lineIndex < context.lines.length && !fencePattern.test(context.lines[lineIndex])) {
|
||||||
return { content: content.join('\n'), raw: '', consumed: i + 1 - context.index };
|
content.push(context.lines[lineIndex++]);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: content.join('\n'),
|
||||||
|
raw: '',
|
||||||
|
consumed: lineIndex + 1 - context.index,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
toHTML: (token: any, convert: any) => '<details>' + convert.block(token.content) + '</details>',
|
toHTML: (token: any, convert: any) => '<details>' + convert.block(token.content) + '</details>',
|
||||||
selector: 'DETAILS',
|
selector: 'DETAILS',
|
||||||
toMarkdown: (el: any, convert: any) => '\n\n|||\n' + convert.children(el).trim() + '\n|||\n\n',
|
toMarkdown: (element: any, convert: any) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
|
||||||
};
|
};
|
||||||
const h = new r.HopDown({ tags: { 'DETAILS': spoiler, ...r.defaultTags } });
|
const converter = new lib.HopDown({
|
||||||
|
tags: {
|
||||||
|
'DETAILS': spoiler,
|
||||||
|
...lib.defaultTags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
it('renders', () => expect(h.toHTML('|||\nhidden\n|||')).toContain('<details>'));
|
it('renders', () => expect(converter.toHTML('|||\nhidden\n|||')).toContain('<details>'));
|
||||||
it('nested md', () => expect(h.toHTML('|||\n**bold**\n|||')).toContain('<strong>bold</strong>'));
|
it('nested md', () => expect(converter.toHTML('|||\n**bold**\n|||')).toContain('<strong>bold</strong>'));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('HopDown({ exclude })', () => {
|
describe('HopDown({ exclude })', () => {
|
||||||
it('excludes table', () => {
|
it('excludes table', () => {
|
||||||
const h = new r.HopDown({ exclude: ['table'] });
|
const converter = new lib.HopDown({ exclude: ['table'] });
|
||||||
expect(h.toHTML('| a |\n|---|\n| 1 |')).not.toContain('<table>');
|
expect(converter.toHTML('| a |\n|---|\n| 1 |')).not.toContain('<table>');
|
||||||
});
|
});
|
||||||
it('excludes code', () => {
|
it('excludes code', () => {
|
||||||
const h = new r.HopDown({ exclude: ['code'] });
|
const converter = new lib.HopDown({ exclude: ['code'] });
|
||||||
expect(h.toHTML('`code`')).toBe('<p>`code`</p>');
|
expect(converter.toHTML('`code`')).toBe('<p>`code`</p>');
|
||||||
});
|
});
|
||||||
it('other tags still work', () => {
|
it('other tags still work', () => {
|
||||||
const h = new r.HopDown({ exclude: ['table'] });
|
const converter = new lib.HopDown({ exclude: ['table'] });
|
||||||
expect(h.toHTML('**bold**')).toContain('<strong>bold</strong>');
|
expect(converter.toHTML('**bold**')).toContain('<strong>bold</strong>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Collision detection', () => {
|
describe('Collision detection', () => {
|
||||||
it('delimiter collision throws', () => {
|
it('delimiter collision throws', () => {
|
||||||
const bad = r.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 });
|
const bad = lib.inlineTag({
|
||||||
expect(() => new r.HopDown({ tags: { ...r.defaultTags, 'SPAN': bad } })).toThrow();
|
name: 'bad',
|
||||||
|
delimiter: '*',
|
||||||
|
htmlTag: 'span',
|
||||||
|
precedence: 10,
|
||||||
|
});
|
||||||
|
expect(() => new lib.HopDown({
|
||||||
|
tags: {
|
||||||
|
...lib.defaultTags,
|
||||||
|
'SPAN': bad,
|
||||||
|
},
|
||||||
|
})).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selector collision throws', () => {
|
it('selector collision throws', () => {
|
||||||
const dup = { name: 'dup', match: () => null, toHTML: () => '', selector: 'STRONG', toMarkdown: () => '' };
|
const dup = {
|
||||||
expect(() => new r.HopDown({ tags: { ...r.defaultTags, 'STRONG': dup } })).toThrow();
|
name: 'dup',
|
||||||
|
match: () => null,
|
||||||
|
toHTML: () => '',
|
||||||
|
selector: 'STRONG',
|
||||||
|
toMarkdown: () => '',
|
||||||
|
};
|
||||||
|
expect(() => new lib.HopDown({
|
||||||
|
tags: {
|
||||||
|
...lib.defaultTags,
|
||||||
|
'STRONG': dup,
|
||||||
|
},
|
||||||
|
})).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('valid precedence does not throw', () => {
|
it('valid precedence does not throw', () => {
|
||||||
const short = r.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 });
|
const short = lib.inlineTag({
|
||||||
const long = r.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 });
|
name: 'short',
|
||||||
expect(() => new r.HopDown({ tags: { ...r.defaultTags, 'S': short, 'DEL': long } })).not.toThrow();
|
delimiter: '~',
|
||||||
|
htmlTag: 's',
|
||||||
|
precedence: 50,
|
||||||
|
});
|
||||||
|
const long = lib.inlineTag({
|
||||||
|
name: 'long',
|
||||||
|
delimiter: '~~',
|
||||||
|
htmlTag: 'del',
|
||||||
|
precedence: 40,
|
||||||
|
});
|
||||||
|
// Remove default strikethrough to avoid collision with the custom S/DEL tags
|
||||||
|
const { 'DEL,S,STRIKE': _, ...tagsWithoutStrikethrough } = lib.defaultTags;
|
||||||
|
expect(() => new lib.HopDown({
|
||||||
|
tags: {
|
||||||
|
...tagsWithoutStrikethrough,
|
||||||
|
'S': short,
|
||||||
|
'DEL': long,
|
||||||
|
},
|
||||||
|
})).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,29 @@
|
||||||
import { ribbit, resetDOM } from './setup';
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
const r = ribbit();
|
const lib = ribbit();
|
||||||
|
|
||||||
describe('RibbitEmitter', () => {
|
describe('RibbitEmitter', () => {
|
||||||
beforeEach(() => resetDOM());
|
beforeEach(() => resetDOM());
|
||||||
|
|
||||||
it('fires save event', () => {
|
it('fires save event', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
let received: any = null;
|
let received: any = null;
|
||||||
editor.on('save', (p: any) => { received = p; });
|
editor.on('save', (payload: any) => {
|
||||||
|
received = payload;
|
||||||
|
});
|
||||||
editor.save();
|
editor.save();
|
||||||
expect(received).toHaveProperty('markdown');
|
expect(received).toHaveProperty('markdown');
|
||||||
expect(received).toHaveProperty('html');
|
expect(received).toHaveProperty('html');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('off removes handler', () => {
|
it('off removes handler', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
const handler = () => { count++; };
|
const handler = () => {
|
||||||
|
count++;
|
||||||
|
};
|
||||||
editor.on('save', handler);
|
editor.on('save', handler);
|
||||||
editor.save();
|
editor.save();
|
||||||
editor.off('save', handler);
|
editor.off('save', handler);
|
||||||
|
|
@ -28,11 +32,15 @@ describe('RibbitEmitter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('multiple listeners', () => {
|
it('multiple listeners', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
editor.on('save', () => { count++; });
|
editor.on('save', () => {
|
||||||
editor.on('save', () => { count++; });
|
count++;
|
||||||
|
});
|
||||||
|
editor.on('save', () => {
|
||||||
|
count++;
|
||||||
|
});
|
||||||
editor.save();
|
editor.save();
|
||||||
expect(count).toBe(2);
|
expect(count).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
@ -42,24 +50,24 @@ describe('Ribbit viewer', () => {
|
||||||
beforeEach(() => resetDOM('**bold**'));
|
beforeEach(() => resetDOM('**bold**'));
|
||||||
|
|
||||||
it('starts with null state', () => {
|
it('starts with null state', () => {
|
||||||
const viewer = new r.Viewer({});
|
const viewer = new lib.Viewer({});
|
||||||
expect(viewer.getState()).toBeNull();
|
expect(viewer.getState()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('run sets view state', () => {
|
it('run sets view state', () => {
|
||||||
const viewer = new r.Viewer({});
|
const viewer = new lib.Viewer({});
|
||||||
viewer.run();
|
viewer.run();
|
||||||
expect(viewer.getState()).toBe('view');
|
expect(viewer.getState()).toBe('view');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders html', () => {
|
it('renders html', () => {
|
||||||
const viewer = new r.Viewer({});
|
const viewer = new lib.Viewer({});
|
||||||
viewer.run();
|
viewer.run();
|
||||||
expect(viewer.element.innerHTML).toContain('<strong>bold</strong>');
|
expect(viewer.element.innerHTML).toContain('<strong>bold</strong>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getMarkdown returns source', () => {
|
it('getMarkdown returns source', () => {
|
||||||
const viewer = new r.Viewer({});
|
const viewer = new lib.Viewer({});
|
||||||
expect(viewer.getMarkdown()).toBe('**bold**');
|
expect(viewer.getMarkdown()).toBe('**bold**');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -68,7 +76,13 @@ describe('Ribbit events', () => {
|
||||||
it('ready fires on run', () => {
|
it('ready fires on run', () => {
|
||||||
resetDOM('hello');
|
resetDOM('hello');
|
||||||
let payload: any = null;
|
let payload: any = null;
|
||||||
const viewer = new r.Viewer({ on: { ready: (p: any) => { payload = p; } } });
|
const viewer = new lib.Viewer({
|
||||||
|
on: {
|
||||||
|
ready: (eventPayload: any) => {
|
||||||
|
payload = eventPayload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
viewer.run();
|
viewer.run();
|
||||||
expect(payload).toHaveProperty('markdown');
|
expect(payload).toHaveProperty('markdown');
|
||||||
expect(payload).toHaveProperty('mode', 'view');
|
expect(payload).toHaveProperty('mode', 'view');
|
||||||
|
|
@ -80,13 +94,13 @@ describe('RibbitEditor modes', () => {
|
||||||
beforeEach(() => resetDOM('**bold**'));
|
beforeEach(() => resetDOM('**bold**'));
|
||||||
|
|
||||||
it('starts in view', () => {
|
it('starts in view', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.getState()).toBe('view');
|
expect(editor.getState()).toBe('view');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switches to wysiwyg', () => {
|
it('switches to wysiwyg', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.wysiwyg();
|
editor.wysiwyg();
|
||||||
expect(editor.getState()).toBe('wysiwyg');
|
expect(editor.getState()).toBe('wysiwyg');
|
||||||
|
|
@ -94,7 +108,7 @@ describe('RibbitEditor modes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switches to edit', () => {
|
it('switches to edit', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.wysiwyg();
|
editor.wysiwyg();
|
||||||
editor.edit();
|
editor.edit();
|
||||||
|
|
@ -102,7 +116,7 @@ describe('RibbitEditor modes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switches back to view', () => {
|
it('switches back to view', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.wysiwyg();
|
editor.wysiwyg();
|
||||||
editor.view();
|
editor.view();
|
||||||
|
|
@ -112,8 +126,12 @@ describe('RibbitEditor modes', () => {
|
||||||
|
|
||||||
it('fires modeChange events', () => {
|
it('fires modeChange events', () => {
|
||||||
const modes: string[] = [];
|
const modes: string[] = [];
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
on: { modeChange: ({ current }: any) => { modes.push(current); } },
|
on: {
|
||||||
|
modeChange: ({ current }: any) => {
|
||||||
|
modes.push(current);
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.wysiwyg();
|
editor.wysiwyg();
|
||||||
|
|
@ -124,9 +142,12 @@ describe('RibbitEditor modes', () => {
|
||||||
|
|
||||||
it('sourceMode disabled blocks edit', () => {
|
it('sourceMode disabled blocks edit', () => {
|
||||||
resetDOM();
|
resetDOM();
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
currentTheme: 'no-source',
|
currentTheme: 'no-source',
|
||||||
themes: [{ name: 'no-source', features: { sourceMode: false } }],
|
themes: [{
|
||||||
|
name: 'no-source',
|
||||||
|
features: { sourceMode: false },
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.wysiwyg();
|
editor.wysiwyg();
|
||||||
|
|
@ -139,28 +160,28 @@ describe('ThemeManager', () => {
|
||||||
beforeEach(() => resetDOM());
|
beforeEach(() => resetDOM());
|
||||||
|
|
||||||
it('lists registered themes', () => {
|
it('lists registered themes', () => {
|
||||||
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
|
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.themes.list()).toContain('ribbit-default');
|
expect(editor.themes.list()).toContain('ribbit-default');
|
||||||
expect(editor.themes.list()).toContain('dark');
|
expect(editor.themes.list()).toContain('dark');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('set switches theme', () => {
|
it('set switches theme', () => {
|
||||||
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
|
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.themes.set('dark');
|
editor.themes.set('dark');
|
||||||
expect(editor.themes.current().name).toBe('dark');
|
expect(editor.themes.current().name).toBe('dark');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disable hides from list', () => {
|
it('disable hides from list', () => {
|
||||||
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
|
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.themes.disable('dark');
|
editor.themes.disable('dark');
|
||||||
expect(editor.themes.list()).not.toContain('dark');
|
expect(editor.themes.list()).not.toContain('dark');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enable restores to list', () => {
|
it('enable restores to list', () => {
|
||||||
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
|
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.themes.disable('dark');
|
editor.themes.disable('dark');
|
||||||
editor.themes.enable('dark');
|
editor.themes.enable('dark');
|
||||||
|
|
@ -168,29 +189,33 @@ describe('ThemeManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('set disabled throws', () => {
|
it('set disabled throws', () => {
|
||||||
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
|
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.themes.disable('dark');
|
editor.themes.disable('dark');
|
||||||
expect(() => editor.themes.set('dark')).toThrow();
|
expect(() => editor.themes.set('dark')).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('set unknown throws', () => {
|
it('set unknown throws', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(() => editor.themes.set('nonexistent')).toThrow();
|
expect(() => editor.themes.set('nonexistent')).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('remove active throws', () => {
|
it('remove active throws', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(() => editor.themes.remove(editor.themes.current().name)).toThrow();
|
expect(() => editor.themes.remove(editor.themes.current().name)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fires themeChange', () => {
|
it('fires themeChange', () => {
|
||||||
let payload: any = null;
|
let payload: any = null;
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
themes: [{ name: 'dark' }],
|
themes: [{ name: 'dark' }],
|
||||||
on: { themeChange: (p: any) => { payload = p; } },
|
on: {
|
||||||
|
themeChange: (eventPayload: any) => {
|
||||||
|
payload = eventPayload;
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.themes.set('dark');
|
editor.themes.set('dark');
|
||||||
|
|
@ -202,27 +227,27 @@ describe('ThemeManager', () => {
|
||||||
|
|
||||||
describe('defaultTheme', () => {
|
describe('defaultTheme', () => {
|
||||||
it('has correct shape', () => {
|
it('has correct shape', () => {
|
||||||
expect(r.defaultTheme.name).toBe('ribbit-default');
|
expect(lib.defaultTheme.name).toBe('ribbit-default');
|
||||||
expect(r.defaultTheme.tags).toBeDefined();
|
expect(lib.defaultTheme.tags).toBeDefined();
|
||||||
expect(r.defaultTheme.features.sourceMode).toBe(true);
|
expect(lib.defaultTheme.features.sourceMode).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Utility functions', () => {
|
describe('Utility functions', () => {
|
||||||
it('encodeHtmlEntities', () => {
|
it('encodeHtmlEntities', () => {
|
||||||
expect(r.encodeHtmlEntities('<')).toBe('<');
|
expect(lib.encodeHtmlEntities('<')).toBe('<');
|
||||||
expect(r.encodeHtmlEntities('>')).toBe('>');
|
expect(lib.encodeHtmlEntities('>')).toBe('>');
|
||||||
expect(r.encodeHtmlEntities('&')).toBe('&');
|
expect(lib.encodeHtmlEntities('&')).toBe('&');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decodeHtmlEntities', () => {
|
it('decodeHtmlEntities', () => {
|
||||||
expect(r.decodeHtmlEntities('<')).toBe('<');
|
expect(lib.decodeHtmlEntities('<')).toBe('<');
|
||||||
expect(r.decodeHtmlEntities('&')).toBe('&');
|
expect(lib.decodeHtmlEntities('&')).toBe('&');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('camelCase', () => {
|
it('camelCase', () => {
|
||||||
expect(r.camelCase('hello').join('')).toBe('Hello');
|
expect(lib.camelCase('hello').join('')).toBe('Hello');
|
||||||
expect(r.camelCase('hello world').join(' ')).toBe('Hello World');
|
expect(lib.camelCase('hello world').join(' ')).toBe('Hello World');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -230,13 +255,13 @@ describe('Editor htmlToMarkdown', () => {
|
||||||
beforeEach(() => resetDOM());
|
beforeEach(() => resetDOM());
|
||||||
|
|
||||||
it('converts strong', () => {
|
it('converts strong', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**');
|
expect(editor.htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts em', () => {
|
it('converts em', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.htmlToMarkdown('<em>italic</em>')).toBe('*italic*');
|
expect(editor.htmlToMarkdown('<em>italic</em>')).toBe('*italic*');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ribbit } from './setup';
|
import { ribbit } from './setup';
|
||||||
|
|
||||||
const r = ribbit();
|
const lib = ribbit();
|
||||||
const hopdown = new r.HopDown();
|
const hopdown = new lib.HopDown();
|
||||||
const H = (md: string) => hopdown.toHTML(md);
|
const H = (md: string) => hopdown.toHTML(md);
|
||||||
const M = (html: string) => hopdown.toMarkdown(html);
|
const M = (html: string) => hopdown.toMarkdown(html);
|
||||||
const rt = (md: string) => M(H(md));
|
const rt = (md: string) => M(H(md));
|
||||||
|
|
@ -18,9 +18,9 @@ describe('Markdown → HTML', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('headings', () => {
|
describe('headings', () => {
|
||||||
it.each([1,2,3,4,5,6])('h%i', (n) => {
|
it.each([1, 2, 3, 4, 5, 6])('h%i', (level) => {
|
||||||
const prefix = '#'.repeat(n);
|
const prefix = '#'.repeat(level);
|
||||||
expect(H(`${prefix} Sub`)).toContain(`<h${n}`);
|
expect(H(`${prefix} Sub`)).toContain(`<h${level}`);
|
||||||
});
|
});
|
||||||
it('heading id', () => expect(H('## Hello World')).toContain("id='HelloWorld'"));
|
it('heading id', () => expect(H('## Hello World')).toContain("id='HelloWorld'"));
|
||||||
it('heading inline md', () => expect(H('## **Bold** text')).toContain('<strong>Bold</strong>'));
|
it('heading inline md', () => expect(H('## **Bold** text')).toContain('<strong>Bold</strong>'));
|
||||||
|
|
@ -149,3 +149,388 @@ describe('Tables with nested markdown', () => {
|
||||||
it('td bold rt', () => expect(rt('| h |\n|---|\n| **b** |')).toBe('| h |\n| --- |\n| **b** |'));
|
it('td bold rt', () => expect(rt('| h |\n|---|\n| **b** |')).toBe('| h |\n| --- |\n| **b** |'));
|
||||||
it('multi-cell rt', () => expect(rt('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |')).toBe('| **a** | *b* |\n| --- | --- |\n| `c` | [d](e) |'));
|
it('multi-cell rt', () => expect(rt('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |')).toBe('| **a** | *b* |\n| --- | --- |\n| `c` | [d](e) |'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Backslash escapes', () => {
|
||||||
|
it('escaped asterisk', () => expect(H('\\*not italic\\*')).toBe('<p>*not italic*</p>'));
|
||||||
|
it('escaped backslash', () => expect(H('a \\\\ b')).toBe('<p>a \\ b</p>'));
|
||||||
|
it('escaped backtick', () => expect(H('\\`not code\\`')).toBe('<p>`not code`</p>'));
|
||||||
|
it('round-trip preserves escape', () => {
|
||||||
|
const html = H('\\*literal\\*');
|
||||||
|
expect(html).toContain('*literal*');
|
||||||
|
expect(html).not.toContain('<em>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Strikethrough', () => {
|
||||||
|
it('md→html', () => expect(H('~~deleted~~')).toBe('<p><del>deleted</del></p>'));
|
||||||
|
it('html→md', () => expect(M('<p><del>gone</del></p>')).toBe('~~gone~~'));
|
||||||
|
it('round-trip', () => expect(rt('~~struck~~')).toBe('~~struck~~'));
|
||||||
|
it('mixed with bold', () => expect(H('**bold** and ~~struck~~')).toContain('<del>struck</del>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Link titles', () => {
|
||||||
|
it('link with title', () => expect(H('[t](http://x "My Title")')).toBe('<p><a href="http://x" title="My Title">t</a></p>'));
|
||||||
|
it('title round-trip', () => expect(rt('[t](http://x "My Title")')).toBe('[t](http://x "My Title")'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reference links', () => {
|
||||||
|
it('basic reference', () => expect(H('[text][ref]\n\n[ref]: http://x')).toContain('<a href="http://x">text</a>'));
|
||||||
|
it('shortcut reference', () => expect(H('[ref][]\n\n[ref]: http://x')).toContain('<a href="http://x">ref</a>'));
|
||||||
|
it('reference with title', () => expect(H('[t][r]\n\n[r]: http://x "T"')).toContain('title="T"'));
|
||||||
|
it('case insensitive', () => expect(H('[t][REF]\n\n[ref]: http://x')).toContain('<a href="http://x">'));
|
||||||
|
it('undefined reference passes through', () => expect(H('[t][missing]')).toContain('[t][missing]'));
|
||||||
|
it('definition not rendered', () => expect(H('[ref]: http://x\n\ntext')).toBe('<p>text</p>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HTML passthrough', () => {
|
||||||
|
it('inline html preserved', () => expect(H('a <span class="x">b</span> c')).toContain('<span class="x">b</span>'));
|
||||||
|
it('self-closing tag', () => expect(H('a <br/> b')).toContain('<br/>'));
|
||||||
|
it('html not double-escaped', () => expect(H('<em>hi</em>')).not.toContain('<'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Autolinks', () => {
|
||||||
|
it('angle bracket autolink', () => expect(H('<https://example.com>')).toContain('<a href="https://example.com">'));
|
||||||
|
it('bare URL', () => expect(H('visit https://example.com today')).toContain('<a href="https://example.com">'));
|
||||||
|
it('URL not matched inside link', () => {
|
||||||
|
const html = H('[text](https://example.com)');
|
||||||
|
// Should have exactly one <a> tag, not nested
|
||||||
|
const anchorPattern = /<a /g;
|
||||||
|
const count = (html.match(anchorPattern) || []).length;
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Alternate syntax (parse-only, canonical output)', () => {
|
||||||
|
describe('underscore emphasis', () => {
|
||||||
|
it('_italic_ → *italic*', () => {
|
||||||
|
expect(H('_italic_')).toBe('<p><em>italic</em></p>');
|
||||||
|
expect(rt('_italic_')).toBe('*italic*');
|
||||||
|
});
|
||||||
|
it('__bold__ → **bold**', () => {
|
||||||
|
expect(H('__bold__')).toBe('<p><strong>bold</strong></p>');
|
||||||
|
expect(rt('__bold__')).toBe('**bold**');
|
||||||
|
});
|
||||||
|
it('___both___ → ***both***', () => {
|
||||||
|
expect(H('___both___')).toContain('<em><strong>both</strong></em>');
|
||||||
|
expect(rt('___both___')).toBe('***both***');
|
||||||
|
});
|
||||||
|
it('mid-word _ not converted', () => {
|
||||||
|
expect(H('foo_bar_baz')).toBe('<p>foo_bar_baz</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setext headings', () => {
|
||||||
|
it('=== underline → h1', () => {
|
||||||
|
expect(H('Title\n=====')).toContain('<h1');
|
||||||
|
expect(H('Title\n=====')).toContain('Title');
|
||||||
|
});
|
||||||
|
it('--- underline → h2', () => {
|
||||||
|
expect(H('Sub\n---')).toContain('<h2');
|
||||||
|
});
|
||||||
|
it('round-trips to ATX', () => {
|
||||||
|
expect(rt('Title\n=====')).toBe('# Title');
|
||||||
|
expect(rt('Sub\n---')).toBe('## Sub');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ATX closing hashes', () => {
|
||||||
|
it('## Title ## → h2', () => {
|
||||||
|
expect(H('## Title ##')).toContain('<h2');
|
||||||
|
expect(H('## Title ##')).toContain('Title');
|
||||||
|
});
|
||||||
|
it('round-trips without closing', () => {
|
||||||
|
expect(rt('## Title ##')).toBe('## Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tilde fenced code', () => {
|
||||||
|
it('~~~ fence accepted', () => {
|
||||||
|
expect(H('~~~\ncode\n~~~')).toContain('<code>code</code>');
|
||||||
|
});
|
||||||
|
it('round-trips to backtick', () => {
|
||||||
|
expect(rt('~~~\ncode\n~~~')).toContain('```');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('plus list marker', () => {
|
||||||
|
it('+ item accepted', () => {
|
||||||
|
expect(H('+ item')).toContain('<li>');
|
||||||
|
});
|
||||||
|
it('round-trips to -', () => {
|
||||||
|
expect(rt('+ item')).toContain('- item');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HopDown delimiter matching API', () => {
|
||||||
|
describe('findCompletePair', () => {
|
||||||
|
it('finds bold pair', () => {
|
||||||
|
const result = hopdown.findCompletePair('hello **world** end');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.htmlTag).toBe('strong');
|
||||||
|
expect(result!.content).toBe('world');
|
||||||
|
expect(result!.delimiter).toBe('**');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds italic pair', () => {
|
||||||
|
const result = hopdown.findCompletePair('hello *world* end');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.htmlTag).toBe('em');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds strikethrough pair', () => {
|
||||||
|
const result = hopdown.findCompletePair('hello ~~gone~~ end');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.htmlTag).toBe('del');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no pair exists', () => {
|
||||||
|
expect(hopdown.findCompletePair('hello world')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips sentinel-wrapped content', () => {
|
||||||
|
expect(hopdown.findCompletePair('hello \x01<strong>world</strong>\x02 end')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects precedence (boldItalic before bold)', () => {
|
||||||
|
const result = hopdown.findCompletePair('***both***');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.htmlTag).toBe('em');
|
||||||
|
expect(result!.tag.name).toBe('boldItalic');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findUnmatchedOpener', () => {
|
||||||
|
it('finds unclosed bold', () => {
|
||||||
|
const result = hopdown.findUnmatchedOpener('hello **world');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.htmlTag).toBe('strong');
|
||||||
|
expect(result!.content).toBe('world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no opener exists', () => {
|
||||||
|
expect(hopdown.findUnmatchedOpener('hello world end')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for plain text', () => {
|
||||||
|
expect(hopdown.findUnmatchedOpener('hello world')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTagForElement', () => {
|
||||||
|
it('returns tag for strong element', () => {
|
||||||
|
const element = document.createElement('strong');
|
||||||
|
const tag = hopdown.getTagForElement(element);
|
||||||
|
expect(tag).not.toBeNull();
|
||||||
|
expect(tag!.name).toBe('bold');
|
||||||
|
expect(tag!.delimiter).toBe('**');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns tag for em element', () => {
|
||||||
|
const element = document.createElement('em');
|
||||||
|
const tag = hopdown.getTagForElement(element);
|
||||||
|
expect(tag).not.toBeNull();
|
||||||
|
expect(tag!.name).toBe('italic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for div element', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
expect(hopdown.getTagForElement(element)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEditableSelector', () => {
|
||||||
|
it('returns a non-empty string', () => {
|
||||||
|
const selector = hopdown.getEditableSelector();
|
||||||
|
expect(selector.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes inline tag selectors', () => {
|
||||||
|
const selector = hopdown.getEditableSelector();
|
||||||
|
expect(selector).toContain('strong');
|
||||||
|
expect(selector).toContain('em');
|
||||||
|
expect(selector).toContain('code');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes block tag selectors', () => {
|
||||||
|
const selector = hopdown.getEditableSelector();
|
||||||
|
expect(selector).toContain('pre');
|
||||||
|
expect(selector).toContain('blockquote');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Hard line breaks', () => {
|
||||||
|
it('trailing two spaces', () => {
|
||||||
|
expect(H('line one \nline two')).toContain('<br>');
|
||||||
|
});
|
||||||
|
it('trailing backslash', () => {
|
||||||
|
expect(H('line one\\\nline two')).toContain('<br>');
|
||||||
|
});
|
||||||
|
it('single space does not break', () => {
|
||||||
|
expect(H('line one \nline two')).not.toContain('<br>');
|
||||||
|
});
|
||||||
|
it('round-trip', () => {
|
||||||
|
const html = H('line one \nline two');
|
||||||
|
const markdown = M(html);
|
||||||
|
expect(markdown).toContain(' \n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Link nesting prevention', () => {
|
||||||
|
it('nested brackets prevent link match', () => {
|
||||||
|
const html = H('[outer [inner](http://b)](http://a)');
|
||||||
|
// The outer [ prevents matching as a single link — the inner
|
||||||
|
// link matches instead, and the outer brackets are literal text
|
||||||
|
expect(html).toContain('<a href="http://b">inner</a>');
|
||||||
|
});
|
||||||
|
it('preserves inner link text', () => {
|
||||||
|
const html = H('[outer [inner](http://b)](http://a)');
|
||||||
|
expect(html).toContain('inner');
|
||||||
|
});
|
||||||
|
it('autolink inside link is stripped', () => {
|
||||||
|
const html = H('[see <https://b.com>](http://a)');
|
||||||
|
const anchorPattern = /<a /g;
|
||||||
|
const linkCount = (html.match(anchorPattern) || []).length;
|
||||||
|
expect(linkCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Multiple-of-3 emphasis rule', () => {
|
||||||
|
it('***foo*** is bold-italic', () => {
|
||||||
|
expect(H('***foo***')).toContain('<em><strong>foo</strong></em>');
|
||||||
|
});
|
||||||
|
it('**foo** is bold', () => {
|
||||||
|
expect(H('**foo**')).toBe('<p><strong>foo</strong></p>');
|
||||||
|
});
|
||||||
|
it('*foo* is italic', () => {
|
||||||
|
expect(H('*foo*')).toBe('<p><em>foo</em></p>');
|
||||||
|
});
|
||||||
|
it('*foo** does not match (1+2=3, rule applies)', () => {
|
||||||
|
const html = H('*foo**');
|
||||||
|
expect(html).not.toContain('<em>');
|
||||||
|
expect(html).not.toContain('<strong>');
|
||||||
|
});
|
||||||
|
it('**foo* does not match (2+1=3, rule applies)', () => {
|
||||||
|
const html = H('**foo*');
|
||||||
|
expect(html).not.toContain('<em>');
|
||||||
|
expect(html).not.toContain('<strong>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HTML entity resolution', () => {
|
||||||
|
it('& resolves to &', () => {
|
||||||
|
expect(H('a & b')).toBe('<p>a & b</p>');
|
||||||
|
});
|
||||||
|
it('< resolves to <', () => {
|
||||||
|
expect(H('a < b')).toBe('<p>a < b</p>');
|
||||||
|
});
|
||||||
|
it('> resolves to >', () => {
|
||||||
|
expect(H('a > b')).toBe('<p>a > b</p>');
|
||||||
|
});
|
||||||
|
it('{ resolves to {', () => {
|
||||||
|
expect(H('{')).toBe('<p>{</p>');
|
||||||
|
});
|
||||||
|
it('{ resolves to {', () => {
|
||||||
|
expect(H('{')).toBe('<p>{</p>');
|
||||||
|
});
|
||||||
|
it('unknown entity passes through', () => {
|
||||||
|
expect(H('&unknown;')).toContain('&unknown;');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Nested inline scenarios', () => {
|
||||||
|
describe('markdown → HTML nesting', () => {
|
||||||
|
it('strikethrough wraps bold', () => {
|
||||||
|
expect(H('~~**bold** struck~~')).toBe('<p><del><strong>bold</strong> struck</del></p>');
|
||||||
|
});
|
||||||
|
it('bold wraps strikethrough', () => {
|
||||||
|
expect(H('**~~struck~~ bold**')).toBe('<p><strong><del>struck</del> bold</strong></p>');
|
||||||
|
});
|
||||||
|
it('italic wraps link', () => {
|
||||||
|
expect(H('*[text](http://x)*')).toContain('<em><a href="http://x">text</a></em>');
|
||||||
|
});
|
||||||
|
it('code inside strikethrough', () => {
|
||||||
|
expect(H('~~`code` struck~~')).toContain('<del><code>code</code> struck</del>');
|
||||||
|
});
|
||||||
|
it('adjacent bold and italic', () => {
|
||||||
|
const html = H('**bold***italic*');
|
||||||
|
expect(html).toContain('<strong>bold</strong>');
|
||||||
|
expect(html).toContain('<em>italic</em>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HTML → markdown → HTML round-trip nesting', () => {
|
||||||
|
it('bold wraps italic', () => {
|
||||||
|
const html = '<p><strong>a <em>b</em> c</strong></p>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
it('italic wraps bold', () => {
|
||||||
|
const html = '<p><em>a <strong>b</strong> c</em></p>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
it('bold wraps code', () => {
|
||||||
|
const html = '<p><strong>a <code>b</code> c</strong></p>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
it('bold wraps link', () => {
|
||||||
|
const html = '<p><strong><a href="http://x">t</a></strong></p>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
it('strikethrough wraps bold', () => {
|
||||||
|
const html = '<p><del><strong>bold</strong> struck</del></p>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
it('italic wraps link', () => {
|
||||||
|
const html = '<p><em><a href="http://x">t</a></em></p>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('literal delimiters in text round-trip', () => {
|
||||||
|
it('literal * in bold', () => {
|
||||||
|
const html = '<p><strong>a * b</strong></p>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
it('literal ~ in strikethrough', () => {
|
||||||
|
const html = '<p><del>a ~ b</del></p>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
it('literal ` adjacent to code', () => {
|
||||||
|
const html = '<p>a ` b <code>c</code></p>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
it('literal * in plain text', () => {
|
||||||
|
const html = '<p>hello * world</p>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
it('literal ** in plain text', () => {
|
||||||
|
const html = '<p>hello ** world</p>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
it('literal _ in plain text', () => {
|
||||||
|
const html = '<p>hello _ world</p>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Backslash-escaped HTML tags', () => {
|
||||||
|
it('\\<em> does not produce a real em element', () => {
|
||||||
|
const html = H('\\<em>text');
|
||||||
|
expect(html).not.toContain('<em>');
|
||||||
|
expect(html).toContain('<em>');
|
||||||
|
});
|
||||||
|
it('\\<b> does not produce a real b element', () => {
|
||||||
|
const html = H('\\<b>text');
|
||||||
|
expect(html).not.toContain('<b>');
|
||||||
|
});
|
||||||
|
it('round-trip of escaped HTML tag in text', () => {
|
||||||
|
const html = '<p>~~\\<em>---\\<b></em></p>';
|
||||||
|
const markdown = M(html);
|
||||||
|
const rehtml = H(markdown);
|
||||||
|
const markdown2 = M(rehtml);
|
||||||
|
const rehtml2 = H(markdown2);
|
||||||
|
expect(rehtml).toBe(rehtml2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ function mulberry32(seed) {
|
||||||
/* ── Keystroke generation ── */
|
/* ── Keystroke generation ── */
|
||||||
|
|
||||||
const PRINTABLE = 'abcdefghijklmnopqrstuvwxyz 0123456789.,!?';
|
const PRINTABLE = 'abcdefghijklmnopqrstuvwxyz 0123456789.,!?';
|
||||||
const DELIMITERS = ['*', '**', '***', '`', '~~'];
|
const DELIMITERS = ['*', '**', '***', '`', '~~', '_', '__', '___'];
|
||||||
const BLOCK_PREFIXES = ['# ', '## ', '### ', '- ', '1. ', '> ', '---'];
|
const BLOCK_PREFIXES = ['# ', '## ', '### ', '- ', '+ ', '1. ', '> ', '---', '~~~'];
|
||||||
const SPECIAL_KEYS = [
|
const SPECIAL_KEYS = [
|
||||||
{ name: 'Enter', keys: Key.ENTER, isSpecial: true },
|
{ name: 'Enter', keys: Key.ENTER, isSpecial: true },
|
||||||
{ name: 'Backspace', keys: Key.BACK_SPACE, isSpecial: true },
|
{ name: 'Backspace', keys: Key.BACK_SPACE, isSpecial: true },
|
||||||
|
|
@ -70,8 +70,14 @@ function generateSequence(random, length) {
|
||||||
} else if (roll < 0.94) {
|
} else if (roll < 0.94) {
|
||||||
/* repeated delimiter (stress test) */
|
/* repeated delimiter (stress test) */
|
||||||
const count = 2 + Math.floor(random() * 4);
|
const count = 2 + Math.floor(random() * 4);
|
||||||
const character = '*';
|
const delimiters = ['*', '_', '~'];
|
||||||
|
const character = delimiters[Math.floor(random() * delimiters.length)];
|
||||||
sequence.push({ name: character.repeat(count), keys: character.repeat(count) });
|
sequence.push({ name: character.repeat(count), keys: character.repeat(count) });
|
||||||
|
} else if (roll < 0.97) {
|
||||||
|
/* backslash sequences */
|
||||||
|
const escaped = ['\\*', '\\_', '\\`', '\\~', '\\\\', '\\'];
|
||||||
|
const fragment = escaped[Math.floor(random() * escaped.length)];
|
||||||
|
sequence.push({ name: fragment, keys: fragment });
|
||||||
} else {
|
} else {
|
||||||
/* angle bracket / HTML-like content */
|
/* angle bracket / HTML-like content */
|
||||||
const fragments = ['<', '>', '<div>', '</div>', '<b>', '&'];
|
const fragments = ['<', '>', '<div>', '</div>', '<b>', '&'];
|
||||||
|
|
@ -188,7 +194,8 @@ async function checkInvariants() {
|
||||||
var forbiddenRules = {
|
var forbiddenRules = {
|
||||||
'STRONG': ['STRONG','B'], 'B': ['STRONG','B'],
|
'STRONG': ['STRONG','B'], 'B': ['STRONG','B'],
|
||||||
'EM': ['EM','I'], 'I': ['EM','I'],
|
'EM': ['EM','I'], 'I': ['EM','I'],
|
||||||
'CODE': ['CODE','STRONG','B','EM','I','A'],
|
'CODE': ['CODE','STRONG','B','EM','I','A','DEL'],
|
||||||
|
'DEL': ['DEL','S','STRIKE'], 'S': ['DEL','S','STRIKE'], 'STRIKE': ['DEL','S','STRIKE'],
|
||||||
'A': ['A'],
|
'A': ['A'],
|
||||||
};
|
};
|
||||||
var allElements = editor.querySelectorAll('*');
|
var allElements = editor.querySelectorAll('*');
|
||||||
|
|
@ -212,28 +219,32 @@ async function checkInvariants() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Invariant 6: rendered HTML is stable through markdown round-trip.
|
/* Invariant 6: rendered HTML is stable through markdown round-trip.
|
||||||
md → toHTML → toMarkdown → toHTML must produce the same HTML.
|
md → toHTML → toMarkdown → toHTML must eventually stabilize.
|
||||||
The markdown representation may change (e.g. ***** → ***) but
|
The first round-trip may change the HTML (e.g. literal <strong>
|
||||||
the rendered output must be identical.
|
in text becomes a real element via HTML passthrough, then
|
||||||
|
serializes as **). But the second round-trip must be stable.
|
||||||
Skip if there are speculative elements (in-progress editing). */
|
Skip if there are speculative elements (in-progress editing). */
|
||||||
var hasSpeculative = editor.querySelector('[data-speculative]');
|
var hasSpeculative = editor.querySelector('[data-speculative]');
|
||||||
if (!hasSpeculative) {
|
if (!hasSpeculative) {
|
||||||
try {
|
try {
|
||||||
var md = window.__ribbitEditor.getMarkdown();
|
var md = window.__ribbitEditor.getMarkdown();
|
||||||
var converter = window.__ribbitEditor.converter;
|
var converter = window.__ribbitEditor.converter;
|
||||||
|
// Two round-trips: allow the first to normalize, check
|
||||||
|
// that the second produces identical HTML
|
||||||
var html1 = converter.toHTML(md);
|
var html1 = converter.toHTML(md);
|
||||||
var md2 = converter.toMarkdown(html1);
|
var md2 = converter.toMarkdown(html1);
|
||||||
var html2 = converter.toHTML(md2);
|
var html2 = converter.toHTML(md2);
|
||||||
/* Compare the rendered HTML, not the markdown */
|
var md3 = converter.toMarkdown(html2);
|
||||||
var div1 = document.createElement('div');
|
var html3 = converter.toHTML(md3);
|
||||||
div1.innerHTML = html1;
|
var normalize = function(html) {
|
||||||
var div2 = document.createElement('div');
|
return html
|
||||||
div2.innerHTML = html2;
|
.replace(/\s*id='[^']*'/g, '')
|
||||||
var text1 = div1.textContent.replace(/\s+/g, ' ').trim();
|
.replace(/\s+/g, ' ')
|
||||||
var text2 = div2.textContent.replace(/\s+/g, ' ').trim();
|
.trim();
|
||||||
if (text1 !== text2) {
|
};
|
||||||
return 'Round-trip HTML mismatch:\n html1: "' + text1.slice(0, 80) +
|
if (normalize(html2) !== normalize(html3)) {
|
||||||
'"\n html2: "' + text2.slice(0, 80) + '"';
|
return 'Round-trip HTML not stable after 2 passes:\n pass2: "' + normalize(html2).slice(0, 80) +
|
||||||
|
'"\n pass3: "' + normalize(html3).slice(0, 80) + '"';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return 'Round-trip check threw: ' + err.message;
|
return 'Round-trip check threw: ' + err.message;
|
||||||
|
|
@ -241,7 +252,7 @@ async function checkInvariants() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Invariant 7: only valid inline elements inside block content */
|
/* Invariant 7: only valid inline elements inside block content */
|
||||||
var validInline = ['STRONG','B','EM','I','CODE','A','BR'];
|
var validInline = ['STRONG','B','EM','I','CODE','A','BR','DEL','S','STRIKE'];
|
||||||
var blocks = editor.querySelectorAll('p,h1,h2,h3,h4,h5,h6,li,blockquote,td,th');
|
var blocks = editor.querySelectorAll('p,h1,h2,h3,h4,h5,h6,li,blockquote,td,th');
|
||||||
for (var b = 0; b < blocks.length; b++) {
|
for (var b = 0; b < blocks.length; b++) {
|
||||||
var inlineEls = blocks[b].querySelectorAll('*');
|
var inlineEls = blocks[b].querySelectorAll('*');
|
||||||
|
|
|
||||||
|
|
@ -433,6 +433,55 @@ async function runTests() {
|
||||||
assert(html.includes('<h2'), `Missing h2: ${html}`);
|
assert(html.includes('<h2'), `Missing h2: ${html}`);
|
||||||
assert(html.includes('<li') || html.includes('<ul'), `Missing list: ${html}`);
|
assert(html.includes('<li') || html.includes('<ul'), `Missing list: ${html}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(' Strikethrough:');
|
||||||
|
|
||||||
|
await test('~~text~~ transforms to <del>', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('~~gone~~');
|
||||||
|
const html = await getHTML();
|
||||||
|
assert(html.includes('<del'), `No <del>: ${html}`);
|
||||||
|
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
|
||||||
|
assert(html.includes('gone'), `Missing content: ${html}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('~~text shows speculative strikethrough', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('~~hel');
|
||||||
|
const html = await getHTML();
|
||||||
|
assert(html.includes('data-speculative'), `No speculative: ${html}`);
|
||||||
|
assert(html.includes('<del'), `No <del>: ${html}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(' Alternate syntax:');
|
||||||
|
|
||||||
|
await test('~~~ transforms to fenced code', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('~~~');
|
||||||
|
await driver.sleep(50);
|
||||||
|
const html = await getHTML();
|
||||||
|
assert(html.includes('<pre') || html.includes('<code'), `No code block: ${html}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('+ space transforms to unordered list', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeChar('+');
|
||||||
|
let html = await getHTML();
|
||||||
|
assert(!html.includes('<ul'), `Premature ul: ${html}`);
|
||||||
|
|
||||||
|
await typeChar(' ');
|
||||||
|
html = await getHTML();
|
||||||
|
assert(html.includes('<ul') || html.includes('<li'), `No list after "+ ": ${html}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(' Backslash escapes:');
|
||||||
|
|
||||||
|
await test('backslash is just a character in WYSIWYG', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('hello\\world');
|
||||||
|
const html = await getHTML();
|
||||||
|
assert(html.includes('hello') && html.includes('world'), `Missing content: ${html}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { ribbit } from './setup';
|
import { ribbit } from './setup';
|
||||||
|
|
||||||
const r = ribbit();
|
const lib = ribbit();
|
||||||
|
|
||||||
|
const spacePattern = / /g;
|
||||||
|
|
||||||
const macros = [
|
const macros = [
|
||||||
{
|
{
|
||||||
|
|
@ -11,7 +13,7 @@ const macros = [
|
||||||
name: 'npc',
|
name: 'npc',
|
||||||
toHTML: ({ keywords }: any) => {
|
toHTML: ({ keywords }: any) => {
|
||||||
const name = keywords.join(' ');
|
const name = keywords.join(' ');
|
||||||
return '<a href="/NPC/' + name.replace(/ /g, '') + '">' + name + '</a>';
|
return '<a href="/NPC/' + name.replace(spacePattern, '') + '">' + name + '</a>';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -24,9 +26,9 @@ const macros = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const h = new r.HopDown({ macros });
|
const converter = new lib.HopDown({ macros });
|
||||||
const H = (md: string) => h.toHTML(md);
|
const H = (md: string) => converter.toHTML(md);
|
||||||
const M = (html: string) => h.toMarkdown(html);
|
const M = (html: string) => converter.toMarkdown(html);
|
||||||
|
|
||||||
describe('Macros', () => {
|
describe('Macros', () => {
|
||||||
describe('self-closing', () => {
|
describe('self-closing', () => {
|
||||||
|
|
@ -61,7 +63,8 @@ describe('Macros', () => {
|
||||||
it('keyword stripped from data-keywords', () => {
|
it('keyword stripped from data-keywords', () => {
|
||||||
const html = H('@style(box verbatim\ncontent\n)');
|
const html = H('@style(box verbatim\ncontent\n)');
|
||||||
expect(html).toContain('data-keywords="box"');
|
expect(html).toContain('data-keywords="box"');
|
||||||
expect(html).not.toMatch(/data-keywords="[^"]*verbatim/);
|
const verbatimKeywordPattern = /data-keywords="[^"]*verbatim/;
|
||||||
|
expect(html).not.toMatch(verbatimKeywordPattern);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,10 @@ export function getWindow(): any {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ribbit(): any {
|
export function ribbit(): any {
|
||||||
const w = getWindow();
|
const browserWindow = getWindow();
|
||||||
const r = w.ribbit;
|
const lib = browserWindow.ribbit;
|
||||||
r.window = w;
|
lib.window = browserWindow;
|
||||||
return r;
|
return lib;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetDOM(content = 'test'): void {
|
export function resetDOM(content = 'test'): void {
|
||||||
|
|
|
||||||
322
test/tokenizer.test.ts
Normal file
322
test/tokenizer.test.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
import { ribbit, getWindow } from './setup';
|
||||||
|
import { InlineTokenizer, type InlineToken } from '../src/ts/tokenizer';
|
||||||
|
import { MarkdownSerializer, type SerializerTagDef } from '../src/ts/serializer';
|
||||||
|
|
||||||
|
// Set up DOM globals before any tests run
|
||||||
|
getWindow();
|
||||||
|
|
||||||
|
const boldDef = {
|
||||||
|
delimiter: '**',
|
||||||
|
htmlTag: 'strong',
|
||||||
|
recursive: true,
|
||||||
|
precedence: 40,
|
||||||
|
};
|
||||||
|
const italicDef = {
|
||||||
|
delimiter: '*',
|
||||||
|
htmlTag: 'em',
|
||||||
|
recursive: true,
|
||||||
|
precedence: 50,
|
||||||
|
};
|
||||||
|
const strikeDef = {
|
||||||
|
delimiter: '~~',
|
||||||
|
htmlTag: 'del',
|
||||||
|
recursive: true,
|
||||||
|
precedence: 45,
|
||||||
|
};
|
||||||
|
const codeDef = {
|
||||||
|
delimiter: '`',
|
||||||
|
htmlTag: 'code',
|
||||||
|
recursive: false,
|
||||||
|
precedence: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenizer = new InlineTokenizer([boldDef, italicDef, strikeDef, codeDef]);
|
||||||
|
|
||||||
|
function roles(tokens: InlineToken[]): string[] {
|
||||||
|
return tokens.map(token => token.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
function values(tokens: InlineToken[]): string[] {
|
||||||
|
return tokens.map(token => token.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InlineTokenizer', () => {
|
||||||
|
describe('plain text', () => {
|
||||||
|
it('produces a single text token', () => {
|
||||||
|
const tokens = tokenizer.tokenize('hello world');
|
||||||
|
expect(roles(tokens)).toEqual(['text']);
|
||||||
|
expect(values(tokens)).toEqual(['hello world']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bold', () => {
|
||||||
|
it('tokenizes **bold**', () => {
|
||||||
|
const tokens = tokenizer.tokenize('**bold**');
|
||||||
|
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
|
||||||
|
expect(tokens[0].delimiter).toBe('**');
|
||||||
|
expect(tokens[1].value).toBe('bold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tokenizes text **bold** text', () => {
|
||||||
|
const tokens = tokenizer.tokenize('hello **bold** end');
|
||||||
|
expect(roles(tokens)).toEqual(['text', 'open', 'text', 'close', 'text']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('italic', () => {
|
||||||
|
it('tokenizes *italic*', () => {
|
||||||
|
const tokens = tokenizer.tokenize('*italic*');
|
||||||
|
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
|
||||||
|
expect(tokens[0].delimiter).toBe('*');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('strikethrough', () => {
|
||||||
|
it('tokenizes ~~struck~~', () => {
|
||||||
|
const tokens = tokenizer.tokenize('~~struck~~');
|
||||||
|
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
|
||||||
|
expect(tokens[0].delimiter).toBe('~~');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('code spans', () => {
|
||||||
|
it('tokenizes `code`', () => {
|
||||||
|
const tokens = tokenizer.tokenize('`code`');
|
||||||
|
expect(roles(tokens)).toEqual(['code']);
|
||||||
|
expect(tokens[0].content).toBe('code');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not parse delimiters inside code', () => {
|
||||||
|
const tokens = tokenizer.tokenize('`**not bold**`');
|
||||||
|
expect(roles(tokens)).toEqual(['code']);
|
||||||
|
expect(tokens[0].content).toBe('**not bold**');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('backslash escapes', () => {
|
||||||
|
it('\\* becomes literal *', () => {
|
||||||
|
const tokens = tokenizer.tokenize('\\*hello');
|
||||||
|
expect(roles(tokens)).toEqual(['text']);
|
||||||
|
expect(tokens[0].value).toBe('*hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('\\\\ becomes literal \\', () => {
|
||||||
|
const tokens = tokenizer.tokenize('\\\\');
|
||||||
|
expect(roles(tokens)).toEqual(['text']);
|
||||||
|
expect(tokens[0].value).toBe('\\');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('\\n at end of line is a hard break', () => {
|
||||||
|
const tokens = tokenizer.tokenize('hello\\\nworld');
|
||||||
|
expect(roles(tokens)).toEqual(['text', 'break', 'text']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hard line breaks', () => {
|
||||||
|
it('two trailing spaces before newline', () => {
|
||||||
|
const tokens = tokenizer.tokenize('hello \nworld');
|
||||||
|
expect(roles(tokens)).toEqual(['text', 'break', 'text']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single space does not break', () => {
|
||||||
|
const tokens = tokenizer.tokenize('hello \nworld');
|
||||||
|
const breakTokens = tokens.filter(token => token.role === 'break');
|
||||||
|
expect(breakTokens.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('entity resolution', () => {
|
||||||
|
it('& becomes &', () => {
|
||||||
|
const tokens = tokenizer.tokenize('a & b');
|
||||||
|
expect(tokens[0].value).toBe('a & b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('{ becomes {', () => {
|
||||||
|
const tokens = tokenizer.tokenize('{');
|
||||||
|
expect(tokens[0].value).toBe('{');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('{ becomes {', () => {
|
||||||
|
const tokens = tokenizer.tokenize('{');
|
||||||
|
expect(tokens[0].value).toBe('{');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('links', () => {
|
||||||
|
it('tokenizes [text](url)', () => {
|
||||||
|
const tokens = tokenizer.tokenize('[click](http://x)');
|
||||||
|
expect(roles(tokens)).toEqual(['link']);
|
||||||
|
expect(tokens[0].href).toBe('http://x');
|
||||||
|
expect(tokens[0].value).toBe('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tokenizes [text](url "title")', () => {
|
||||||
|
const tokens = tokenizer.tokenize('[click](http://x "My Title")');
|
||||||
|
expect(tokens[0].title).toBe('My Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disallows [ in link text', () => {
|
||||||
|
const tokens = tokenizer.tokenize('[outer [inner](b)](a)');
|
||||||
|
// Should not match as a single link
|
||||||
|
const linkTokens = tokens.filter(token => token.role === 'link');
|
||||||
|
expect(linkTokens.length).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('autolinks', () => {
|
||||||
|
it('tokenizes <url>', () => {
|
||||||
|
const tokens = tokenizer.tokenize('<https://example.com>');
|
||||||
|
expect(roles(tokens)).toEqual(['autolink']);
|
||||||
|
expect(tokens[0].href).toBe('https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tokenizes bare URL', () => {
|
||||||
|
const tokens = tokenizer.tokenize('visit https://example.com today');
|
||||||
|
expect(tokens.some(token => token.role === 'autolink')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HTML passthrough', () => {
|
||||||
|
it('tokenizes HTML tags', () => {
|
||||||
|
const tokens = tokenizer.tokenize('a <span>b</span> c');
|
||||||
|
const htmlTokens = tokens.filter(token => token.role === 'html');
|
||||||
|
expect(htmlTokens.length).toBe(2);
|
||||||
|
expect(htmlTokens[0].value).toBe('<span>');
|
||||||
|
expect(htmlTokens[1].value).toBe('</span>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flanking rules', () => {
|
||||||
|
it('mid-word * is not a delimiter', () => {
|
||||||
|
const tokens = tokenizer.tokenize('2*3*4');
|
||||||
|
expect(roles(tokens)).toEqual(['text']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('* at word boundary is a delimiter', () => {
|
||||||
|
const tokens = tokenizer.tokenize('*hello*');
|
||||||
|
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nested delimiters', () => {
|
||||||
|
it('bold inside italic', () => {
|
||||||
|
const tokens = tokenizer.tokenize('*hello **world***');
|
||||||
|
const openTokens = tokens.filter(token => token.role === 'open');
|
||||||
|
expect(openTokens.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownSerializer', () => {
|
||||||
|
const tagMap = new Map<string, SerializerTagDef>([
|
||||||
|
['STRONG', { delimiter: '**' }],
|
||||||
|
['B', { delimiter: '**' }],
|
||||||
|
['EM', { delimiter: '*' }],
|
||||||
|
['I', { delimiter: '*' }],
|
||||||
|
['DEL', { delimiter: '~~' }],
|
||||||
|
['CODE', {
|
||||||
|
serialize: (element) => '`' + (element.textContent || '') + '`',
|
||||||
|
}],
|
||||||
|
['A', {
|
||||||
|
serialize: (element, children) => {
|
||||||
|
const href = element.getAttribute('href') || '';
|
||||||
|
const title = element.getAttribute('title');
|
||||||
|
const titlePart = title ? ` "${title}"` : '';
|
||||||
|
return '[' + children() + '](' + href + titlePart + ')';
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
['BR', {
|
||||||
|
serialize: () => ' \n',
|
||||||
|
}],
|
||||||
|
]);
|
||||||
|
const delimiterChars = new Set(['*', '`', '~']);
|
||||||
|
const serializer = new MarkdownSerializer(tagMap, delimiterChars);
|
||||||
|
|
||||||
|
it('serializes plain text', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = 'hello world';
|
||||||
|
expect(serializer.serialize(div)).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes bold', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = '<strong>bold</strong>';
|
||||||
|
expect(serializer.serialize(div)).toBe('**bold**');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes italic', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = '<em>italic</em>';
|
||||||
|
expect(serializer.serialize(div)).toBe('*italic*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes * in text nodes', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = 'hello * world';
|
||||||
|
expect(serializer.serialize(div)).toBe('hello \\* world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes _ in text nodes', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = 'hello_world';
|
||||||
|
expect(serializer.serialize(div)).toBe('hello\\_world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes \\ in text nodes', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = 'back\\slash';
|
||||||
|
expect(serializer.serialize(div)).toBe('back\\\\slash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes < before letters', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = 'a <b> c';
|
||||||
|
expect(serializer.serialize(div)).toBe('a \\<b> c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not escape < before non-letters', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = '1 < 2';
|
||||||
|
expect(serializer.serialize(div)).toBe('1 < 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not escape * inside delimiters', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = '<strong>bold</strong>';
|
||||||
|
const result = serializer.serialize(div);
|
||||||
|
// The ** are delimiter tokens, not escaped
|
||||||
|
expect(result).toBe('**bold**');
|
||||||
|
expect(result).not.toContain('\\*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes * in text adjacent to delimiters', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = '<strong>bold</strong> * text';
|
||||||
|
const result = serializer.serialize(div);
|
||||||
|
expect(result).toContain('\\*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes link', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = '<a href="http://x">click</a>';
|
||||||
|
expect(serializer.serialize(div)).toBe('[click](http://x)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes link with title', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = '<a href="http://x" title="T">click</a>';
|
||||||
|
expect(serializer.serialize(div)).toBe('[click](http://x "T")');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes code', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = '<code>x</code>';
|
||||||
|
expect(serializer.serialize(div)).toBe('`x`');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes hard break', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = 'hello<br>world';
|
||||||
|
expect(serializer.serialize(div)).toBe('hello \nworld');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { ribbit, resetDOM } from './setup';
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
const r = ribbit();
|
const lib = ribbit();
|
||||||
|
|
||||||
describe('ToolbarManager', () => {
|
describe('ToolbarManager', () => {
|
||||||
beforeEach(() => resetDOM('**bold** text'));
|
beforeEach(() => resetDOM('**bold** text'));
|
||||||
|
|
||||||
describe('button registration', () => {
|
describe('button registration', () => {
|
||||||
it('registers tag buttons', () => {
|
it('registers tag buttons', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.toolbar.buttons.get('bold')).toBeDefined();
|
expect(editor.toolbar.buttons.get('bold')).toBeDefined();
|
||||||
expect(editor.toolbar.buttons.get('italic')).toBeDefined();
|
expect(editor.toolbar.buttons.get('italic')).toBeDefined();
|
||||||
|
|
@ -15,7 +15,7 @@ describe('ToolbarManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers editor actions', () => {
|
it('registers editor actions', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.toolbar.buttons.get('save')).toBeDefined();
|
expect(editor.toolbar.buttons.get('save')).toBeDefined();
|
||||||
expect(editor.toolbar.buttons.get('toggle')).toBeDefined();
|
expect(editor.toolbar.buttons.get('toggle')).toBeDefined();
|
||||||
|
|
@ -23,23 +23,30 @@ describe('ToolbarManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers macro buttons', () => {
|
it('registers macro buttons', () => {
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
macros: [{ name: 'user', toHTML: () => 'u' }],
|
macros: [{
|
||||||
|
name: 'user',
|
||||||
|
toHTML: () => 'u',
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.toolbar.buttons.get('macro:user')).toBeDefined();
|
expect(editor.toolbar.buttons.get('macro:user')).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips macros with button: false', () => {
|
it('skips macros with button: false', () => {
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
macros: [{ name: 'hidden', toHTML: () => '', button: false }],
|
macros: [{
|
||||||
|
name: 'hidden',
|
||||||
|
toHTML: () => '',
|
||||||
|
button: false,
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.toolbar.buttons.get('macro:hidden')).toBeUndefined();
|
expect(editor.toolbar.buttons.get('macro:hidden')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips tags without button', () => {
|
it('skips tags without button', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.toolbar.buttons.get('paragraph')).toBeUndefined();
|
expect(editor.toolbar.buttons.get('paragraph')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
@ -47,7 +54,7 @@ describe('ToolbarManager', () => {
|
||||||
|
|
||||||
describe('button properties', () => {
|
describe('button properties', () => {
|
||||||
it('bold has correct label and shortcut', () => {
|
it('bold has correct label and shortcut', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
const bold = editor.toolbar.buttons.get('bold')!;
|
const bold = editor.toolbar.buttons.get('bold')!;
|
||||||
expect(bold.label).toBe('Bold');
|
expect(bold.label).toBe('Bold');
|
||||||
|
|
@ -55,19 +62,19 @@ describe('ToolbarManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bold action is wrap', () => {
|
it('bold action is wrap', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.toolbar.buttons.get('bold')!.action).toBe('wrap');
|
expect(editor.toolbar.buttons.get('bold')!.action).toBe('wrap');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('save action is custom', () => {
|
it('save action is custom', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.toolbar.buttons.get('save')!.action).toBe('custom');
|
expect(editor.toolbar.buttons.get('save')!.action).toBe('custom');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('table has template', () => {
|
it('table has template', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
const table = editor.toolbar.buttons.get('table')!;
|
const table = editor.toolbar.buttons.get('table')!;
|
||||||
expect(table.template).toContain('Header');
|
expect(table.template).toContain('Header');
|
||||||
|
|
@ -75,8 +82,11 @@ describe('ToolbarManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('macro button has insert action', () => {
|
it('macro button has insert action', () => {
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
macros: [{ name: 'toc', toHTML: () => '' }],
|
macros: [{
|
||||||
|
name: 'toc',
|
||||||
|
toHTML: () => '',
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
const btn = editor.toolbar.buttons.get('macro:toc')!;
|
const btn = editor.toolbar.buttons.get('macro:toc')!;
|
||||||
|
|
@ -87,7 +97,7 @@ describe('ToolbarManager', () => {
|
||||||
|
|
||||||
describe('button.hide() and button.show()', () => {
|
describe('button.hide() and button.show()', () => {
|
||||||
it('hide sets visible false', () => {
|
it('hide sets visible false', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
const bold = editor.toolbar.buttons.get('bold')!;
|
const bold = editor.toolbar.buttons.get('bold')!;
|
||||||
expect(bold.visible).toBe(true);
|
expect(bold.visible).toBe(true);
|
||||||
|
|
@ -96,7 +106,7 @@ describe('ToolbarManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('show restores visible', () => {
|
it('show restores visible', () => {
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
const bold = editor.toolbar.buttons.get('bold')!;
|
const bold = editor.toolbar.buttons.get('bold')!;
|
||||||
bold.hide();
|
bold.hide();
|
||||||
|
|
@ -107,121 +117,124 @@ describe('ToolbarManager', () => {
|
||||||
|
|
||||||
describe('render()', () => {
|
describe('render()', () => {
|
||||||
it('returns an HTMLElement', () => {
|
it('returns an HTMLElement', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
const el = editor.toolbar.render();
|
const toolbar = editor.toolbar.render();
|
||||||
expect(el.tagName).toBe('NAV');
|
expect(toolbar.tagName).toBe('NAV');
|
||||||
expect(el.className).toBe('ribbit-toolbar');
|
expect(toolbar.className).toBe('ribbit-toolbar');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains buttons', () => {
|
it('contains buttons', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
const el = editor.toolbar.render();
|
const toolbar = editor.toolbar.render();
|
||||||
expect(el.querySelector('.ribbit-btn-bold')).not.toBeNull();
|
expect(toolbar.querySelector('.ribbit-btn-bold')).not.toBeNull();
|
||||||
expect(el.querySelector('.ribbit-btn-save')).not.toBeNull();
|
expect(toolbar.querySelector('.ribbit-btn-save')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('buttons have aria-label', () => {
|
it('buttons have aria-label', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
const el = editor.toolbar.render();
|
const toolbar = editor.toolbar.render();
|
||||||
const bold = el.querySelector('.ribbit-btn-bold');
|
const bold = toolbar.querySelector('.ribbit-btn-bold');
|
||||||
expect(bold?.getAttribute('aria-label')).toBe('Bold');
|
expect(bold?.getAttribute('aria-label')).toBe('Bold');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('buttons have title with shortcut', () => {
|
it('buttons have title with shortcut', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
const el = editor.toolbar.render();
|
const toolbar = editor.toolbar.render();
|
||||||
const bold = el.querySelector('.ribbit-btn-bold');
|
const bold = toolbar.querySelector('.ribbit-btn-bold');
|
||||||
expect(bold?.getAttribute('title')).toBe('Bold (Ctrl+B)');
|
expect(bold?.getAttribute('title')).toBe('Bold (Ctrl+B)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders spacers', () => {
|
it('renders spacers', () => {
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
autoToolbar: false,
|
autoToolbar: false,
|
||||||
toolbar: ['bold', '', 'save'],
|
toolbar: ['bold', '', 'save'],
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
const el = editor.toolbar.render();
|
const toolbar = editor.toolbar.render();
|
||||||
expect(el.querySelector('.spacer')).not.toBeNull();
|
expect(toolbar.querySelector('.spacer')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders dropdown groups', () => {
|
it('renders dropdown groups', () => {
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
autoToolbar: false,
|
autoToolbar: false,
|
||||||
toolbar: [{ group: 'Test', items: ['bold', 'italic'] }],
|
toolbar: [{
|
||||||
|
group: 'Test',
|
||||||
|
items: ['bold', 'italic'],
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
const el = editor.toolbar.render();
|
const toolbar = editor.toolbar.render();
|
||||||
expect(el.querySelector('.ribbit-dropdown')).not.toBeNull();
|
expect(toolbar.querySelector('.ribbit-dropdown')).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('auto-render', () => {
|
describe('auto-render', () => {
|
||||||
it('inserts toolbar before editor by default', () => {
|
it('inserts toolbar before editor by default', () => {
|
||||||
resetDOM();
|
resetDOM();
|
||||||
const editor = new r.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
const toolbar = editor.element.previousElementSibling;
|
const toolbarElement = editor.element.previousElementSibling;
|
||||||
expect(toolbar?.className).toBe('ribbit-toolbar');
|
expect(toolbarElement?.className).toBe('ribbit-toolbar');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not insert when autoToolbar is false', () => {
|
it('does not insert when autoToolbar is false', () => {
|
||||||
resetDOM();
|
resetDOM();
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
const toolbar = editor.element.previousElementSibling;
|
const toolbarElement = editor.element.previousElementSibling;
|
||||||
expect(toolbar?.className || '').not.toBe('ribbit-toolbar');
|
expect(toolbarElement?.className || '').not.toBe('ribbit-toolbar');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('custom layout', () => {
|
describe('custom layout', () => {
|
||||||
it('respects custom toolbar order', () => {
|
it('respects custom toolbar order', () => {
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
autoToolbar: false,
|
autoToolbar: false,
|
||||||
toolbar: ['save', 'bold'],
|
toolbar: ['save', 'bold'],
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
const el = editor.toolbar.render();
|
const toolbar = editor.toolbar.render();
|
||||||
const buttons = el.querySelectorAll('button');
|
const buttons = toolbar.querySelectorAll('button');
|
||||||
expect(buttons[0]?.className).toBe('ribbit-btn-save');
|
expect(buttons[0]?.className).toBe('ribbit-btn-save');
|
||||||
expect(buttons[1]?.className).toBe('ribbit-btn-bold');
|
expect(buttons[1]?.className).toBe('ribbit-btn-bold');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('auto-generates layout when not specified', () => {
|
it('auto-generates layout when not specified', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
const el = editor.toolbar.render();
|
const toolbar = editor.toolbar.render();
|
||||||
expect(el.querySelectorAll('button').length).toBeGreaterThan(3);
|
expect(toolbar.querySelectorAll('button').length).toBeGreaterThan(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('enable/disable', () => {
|
describe('enable/disable', () => {
|
||||||
it('disable adds disabled class', () => {
|
it('disable adds disabled class', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
const el = editor.toolbar.render();
|
const toolbar = editor.toolbar.render();
|
||||||
editor.toolbar.disable();
|
editor.toolbar.disable();
|
||||||
const bold = el.querySelector('.ribbit-btn-bold');
|
const bold = toolbar.querySelector('.ribbit-btn-bold');
|
||||||
expect(bold?.classList.contains('disabled')).toBe(true);
|
expect(bold?.classList.contains('disabled')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enable removes disabled class', () => {
|
it('enable removes disabled class', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
const el = editor.toolbar.render();
|
const toolbar = editor.toolbar.render();
|
||||||
editor.toolbar.disable();
|
editor.toolbar.disable();
|
||||||
editor.toolbar.enable();
|
editor.toolbar.enable();
|
||||||
const bold = el.querySelector('.ribbit-btn-bold');
|
const bold = toolbar.querySelector('.ribbit-btn-bold');
|
||||||
expect(bold?.classList.contains('disabled')).toBe(false);
|
expect(bold?.classList.contains('disabled')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateActiveState', () => {
|
describe('updateActiveState', () => {
|
||||||
it('sets active class on matching buttons', () => {
|
it('sets active class on matching buttons', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.toolbar.render();
|
editor.toolbar.render();
|
||||||
editor.toolbar.updateActiveState(['bold']);
|
editor.toolbar.updateActiveState(['bold']);
|
||||||
|
|
@ -230,7 +243,7 @@ describe('ToolbarManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears active when not in list', () => {
|
it('clears active when not in list', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.toolbar.render();
|
editor.toolbar.render();
|
||||||
editor.toolbar.updateActiveState(['bold']);
|
editor.toolbar.updateActiveState(['bold']);
|
||||||
|
|
@ -241,19 +254,19 @@ describe('ToolbarManager', () => {
|
||||||
|
|
||||||
describe('heading and list buttons', () => {
|
describe('heading and list buttons', () => {
|
||||||
it('registers h1-h6', () => {
|
it('registers h1-h6', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
for (let i = 1; i <= 6; i++) {
|
for (let level = 1; level <= 6; level++) {
|
||||||
const btn = editor.toolbar.buttons.get(`h${i}`);
|
const btn = editor.toolbar.buttons.get(`h${level}`);
|
||||||
expect(btn).toBeDefined();
|
expect(btn).toBeDefined();
|
||||||
expect(btn!.label).toBe(`H${i}`);
|
expect(btn!.label).toBe(`H${level}`);
|
||||||
expect(btn!.shortcut).toBe(`Ctrl+${i}`);
|
expect(btn!.shortcut).toBe(`Ctrl+${level}`);
|
||||||
expect(btn!.action).toBe('prefix');
|
expect(btn!.action).toBe('prefix');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers ul and ol', () => {
|
it('registers ul and ol', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.toolbar.buttons.get('ul')!.shortcut).toBe('Ctrl+Shift+8');
|
expect(editor.toolbar.buttons.get('ul')!.shortcut).toBe('Ctrl+Shift+8');
|
||||||
expect(editor.toolbar.buttons.get('ol')!.shortcut).toBe('Ctrl+Shift+7');
|
expect(editor.toolbar.buttons.get('ol')!.shortcut).toBe('Ctrl+Shift+7');
|
||||||
|
|
@ -262,7 +275,7 @@ describe('ToolbarManager', () => {
|
||||||
|
|
||||||
describe('keyboard shortcuts', () => {
|
describe('keyboard shortcuts', () => {
|
||||||
it('all formatting buttons have shortcuts', () => {
|
it('all formatting buttons have shortcuts', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
const expected = ['bold', 'italic', 'code', 'link', 'save'];
|
const expected = ['bold', 'italic', 'code', 'link', 'save'];
|
||||||
for (const id of expected) {
|
for (const id of expected) {
|
||||||
|
|
@ -271,7 +284,7 @@ describe('ToolbarManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('block buttons have shortcuts', () => {
|
it('block buttons have shortcuts', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.toolbar.buttons.get('fencedCode')!.shortcut).toBe('Ctrl+Shift+E');
|
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('blockquote')!.shortcut).toBe('Ctrl+Shift+.');
|
||||||
|
|
@ -280,7 +293,7 @@ describe('ToolbarManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('editor actions have shortcuts', () => {
|
it('editor actions have shortcuts', () => {
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.toolbar.buttons.get('toggle')!.shortcut).toBe('Ctrl+Shift+V');
|
expect(editor.toolbar.buttons.get('toggle')!.shortcut).toBe('Ctrl+Shift+V');
|
||||||
expect(editor.toolbar.buttons.get('markdown')!.shortcut).toBe('Ctrl+/');
|
expect(editor.toolbar.buttons.get('markdown')!.shortcut).toBe('Ctrl+/');
|
||||||
|
|
@ -291,9 +304,13 @@ describe('ToolbarManager', () => {
|
||||||
it('triggers editor.save()', () => {
|
it('triggers editor.save()', () => {
|
||||||
resetDOM();
|
resetDOM();
|
||||||
let saved = false;
|
let saved = false;
|
||||||
const editor = new r.Editor({
|
const editor = new lib.Editor({
|
||||||
autoToolbar: false,
|
autoToolbar: false,
|
||||||
on: { save: () => { saved = true; } },
|
on: {
|
||||||
|
save: () => {
|
||||||
|
saved = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.toolbar.render();
|
editor.toolbar.render();
|
||||||
|
|
@ -305,7 +322,7 @@ describe('ToolbarManager', () => {
|
||||||
describe('toggle button', () => {
|
describe('toggle button', () => {
|
||||||
it('switches from view to wysiwyg', () => {
|
it('switches from view to wysiwyg', () => {
|
||||||
resetDOM();
|
resetDOM();
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.toolbar.render();
|
editor.toolbar.render();
|
||||||
expect(editor.getState()).toBe('view');
|
expect(editor.getState()).toBe('view');
|
||||||
|
|
@ -315,7 +332,7 @@ describe('ToolbarManager', () => {
|
||||||
|
|
||||||
it('switches from wysiwyg to view', () => {
|
it('switches from wysiwyg to view', () => {
|
||||||
resetDOM();
|
resetDOM();
|
||||||
const editor = new r.Editor({ autoToolbar: false });
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.wysiwyg();
|
editor.wysiwyg();
|
||||||
editor.toolbar.render();
|
editor.toolbar.render();
|
||||||
|
|
|
||||||
104
test/vim.test.ts
104
test/vim.test.ts
|
|
@ -1,65 +1,127 @@
|
||||||
import { ribbit, resetDOM } from './setup';
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
const r = ribbit();
|
const lib = ribbit();
|
||||||
|
|
||||||
describe('VimHandler', () => {
|
describe('VimHandler', () => {
|
||||||
beforeEach(() => resetDOM('hello world'));
|
beforeEach(() => resetDOM('hello world'));
|
||||||
|
|
||||||
it('starts in insert mode', () => {
|
it('starts in insert mode', () => {
|
||||||
const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
const editor = new lib.Editor({
|
||||||
|
currentTheme: 'vim',
|
||||||
|
themes: [{
|
||||||
|
name: 'vim',
|
||||||
|
features: {
|
||||||
|
sourceMode: true,
|
||||||
|
vim: true,
|
||||||
|
},
|
||||||
|
tags: lib.defaultTags,
|
||||||
|
}],
|
||||||
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.edit();
|
editor.edit();
|
||||||
expect(editor.element.classList.contains('vim-insert')).toBe(true);
|
expect(editor.element.classList.contains('vim-insert')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Esc enters normal mode', () => {
|
it('Esc enters normal mode', () => {
|
||||||
const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
const editor = new lib.Editor({
|
||||||
|
currentTheme: 'vim',
|
||||||
|
themes: [{
|
||||||
|
name: 'vim',
|
||||||
|
features: {
|
||||||
|
sourceMode: true,
|
||||||
|
vim: true,
|
||||||
|
},
|
||||||
|
tags: lib.defaultTags,
|
||||||
|
}],
|
||||||
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.edit();
|
editor.edit();
|
||||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
expect(editor.element.classList.contains('vim-normal')).toBe(true);
|
expect(editor.element.classList.contains('vim-normal')).toBe(true);
|
||||||
expect(editor.element.classList.contains('vim-insert')).toBe(false);
|
expect(editor.element.classList.contains('vim-insert')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('i returns to insert mode', () => {
|
it('i returns to insert mode', () => {
|
||||||
const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
const editor = new lib.Editor({
|
||||||
|
currentTheme: 'vim',
|
||||||
|
themes: [{
|
||||||
|
name: 'vim',
|
||||||
|
features: {
|
||||||
|
sourceMode: true,
|
||||||
|
vim: true,
|
||||||
|
},
|
||||||
|
tags: lib.defaultTags,
|
||||||
|
}],
|
||||||
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.edit();
|
editor.edit();
|
||||||
// Enter normal mode
|
// Enter normal mode
|
||||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
// Back to insert
|
// Back to insert
|
||||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'i' }));
|
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
|
||||||
expect(editor.element.classList.contains('vim-insert')).toBe(true);
|
expect(editor.element.classList.contains('vim-insert')).toBe(true);
|
||||||
expect(editor.element.classList.contains('vim-normal')).toBe(false);
|
expect(editor.element.classList.contains('vim-normal')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables toolbar in normal mode', () => {
|
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 }] });
|
const editor = new lib.Editor({
|
||||||
|
autoToolbar: false,
|
||||||
|
currentTheme: 'vim',
|
||||||
|
themes: [{
|
||||||
|
name: 'vim',
|
||||||
|
features: {
|
||||||
|
sourceMode: true,
|
||||||
|
vim: true,
|
||||||
|
},
|
||||||
|
tags: lib.defaultTags,
|
||||||
|
}],
|
||||||
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.toolbar.render();
|
editor.toolbar.render();
|
||||||
editor.edit();
|
editor.edit();
|
||||||
editor.toolbar.enable();
|
editor.toolbar.enable();
|
||||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
const bold = editor.toolbar.buttons.get('bold');
|
const bold = editor.toolbar.buttons.get('bold');
|
||||||
expect(bold?.element?.classList.contains('disabled')).toBe(true);
|
expect(bold?.element?.classList.contains('disabled')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('re-enables toolbar in insert mode', () => {
|
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 }] });
|
const editor = new lib.Editor({
|
||||||
|
autoToolbar: false,
|
||||||
|
currentTheme: 'vim',
|
||||||
|
themes: [{
|
||||||
|
name: 'vim',
|
||||||
|
features: {
|
||||||
|
sourceMode: true,
|
||||||
|
vim: true,
|
||||||
|
},
|
||||||
|
tags: lib.defaultTags,
|
||||||
|
}],
|
||||||
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.toolbar.render();
|
editor.toolbar.render();
|
||||||
editor.edit();
|
editor.edit();
|
||||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'i' }));
|
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
|
||||||
const bold = editor.toolbar.buttons.get('bold');
|
const bold = editor.toolbar.buttons.get('bold');
|
||||||
expect(bold?.element?.classList.contains('disabled')).toBe(false);
|
expect(bold?.element?.classList.contains('disabled')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detaches when leaving edit mode', () => {
|
it('detaches when leaving edit mode', () => {
|
||||||
const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
const editor = new lib.Editor({
|
||||||
|
currentTheme: 'vim',
|
||||||
|
themes: [{
|
||||||
|
name: 'vim',
|
||||||
|
features: {
|
||||||
|
sourceMode: true,
|
||||||
|
vim: true,
|
||||||
|
},
|
||||||
|
tags: lib.defaultTags,
|
||||||
|
}],
|
||||||
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.edit();
|
editor.edit();
|
||||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
expect(editor.element.classList.contains('vim-normal')).toBe(true);
|
expect(editor.element.classList.contains('vim-normal')).toBe(true);
|
||||||
editor.wysiwyg();
|
editor.wysiwyg();
|
||||||
// vim classes should be gone after mode switch
|
// vim classes should be gone after mode switch
|
||||||
|
|
@ -68,11 +130,21 @@ describe('VimHandler', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('only activates in edit mode', () => {
|
it('only activates in edit mode', () => {
|
||||||
const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
const editor = new lib.Editor({
|
||||||
|
currentTheme: 'vim',
|
||||||
|
themes: [{
|
||||||
|
name: 'vim',
|
||||||
|
features: {
|
||||||
|
sourceMode: true,
|
||||||
|
vim: true,
|
||||||
|
},
|
||||||
|
tags: lib.defaultTags,
|
||||||
|
}],
|
||||||
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.wysiwyg();
|
editor.wysiwyg();
|
||||||
// Esc in wysiwyg should not add vim classes
|
// Esc in wysiwyg should not add vim classes
|
||||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
expect(editor.element.classList.contains('vim-normal')).toBe(false);
|
expect(editor.element.classList.contains('vim-normal')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "ES2017",
|
"target": "ES2018",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user