Compare commits

..
Author SHA1 Message Date
evilchili 95f6ffcf86 checkpoint 2026-07-10 16:21:32 -07:00
evilchili c1dc49f0b3 all tests pass but only if md-delim display: inline 2026-07-10 12:39:35 -07:00
11 changed files with 1746 additions and 5982 deletions
@@ -14,16 +14,6 @@
#status { font-size: 12px; color: #666; margin-bottom: 10px; } #status { font-size: 12px; color: #666; margin-bottom: 10px; }
#revisions { margin-top: 20px; } #revisions { margin-top: 20px; }
#revisions button { margin: 2px; } #revisions button { margin: 2px; }
#ribbit { border: 1px solid #ccc; border-radius: 4px; padding: 20px; min-height: 200px; }
.ribbit-toolbar { background: #f5f5f5; border: 1px solid #ccc; border-radius: 4px; padding: 4px; margin-bottom: 8px; }
.ribbit-toolbar ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 2px; align-items: center; }
.ribbit-toolbar button { padding: 4px 8px; border: 1px solid #ddd; border-radius: 3px; background: white; cursor: pointer; font-size: 12px; }
.ribbit-toolbar button:hover { background: #e8e8e8; }
.ribbit-toolbar button.active { background: #d0d0ff; border-color: #99f; }
.ribbit-toolbar button.disabled { opacity: 0.3; cursor: default; }
.ribbit-toolbar .spacer { width: 12px; }
.ribbit-dropdown { position: absolute; background: white; border: 1px solid #ccc; border-radius: 4px; padding: 4px; z-index: 10; }
.ribbit-dropdown button { display: block; width: 100%; text-align: left; margin: 1px 0; }
</style> </style>
</head> </head>
<body> <body>
+1191 -5650
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -32,5 +32,8 @@
"selenium-webdriver": "^4.43.0", "selenium-webdriver": "^4.43.0",
"ts-jest": "^29.4.9", "ts-jest": "^29.4.9",
"typescript": "^6.0.3" "typescript": "^6.0.3"
},
"dependencies": {
"bootstrap-icons": "^1.13.1"
} }
} }
+2 -13
View File
@@ -46,20 +46,9 @@
background: #EEE; background: #EEE;
} }
.ribbit-editing .md-delim { .ribbit-editing > .md-delim {
opacity: 0.8;
}
#ribbit.wysiwyg [class^="md-h"] > .md-delim,
#ribbit.wysiwyg .md-blockquote > .md-delim,
#ribbit.wysiwyg .md-list-prefix {
display: inline; display: inline;
/* opacity: 0.8;
font-weight: normal;
font-style: normal;
font-family: monospace;
font-size: 0.85em;
*/
} }
/* List prefixes use a separate class so CSS can replace them with /* List prefixes use a separate class so CSS can replace them with
+1
View File
@@ -0,0 +1 @@
../../../../node_modules/bootstrap-icons/icons
@@ -6,6 +6,11 @@
@import "../../ribbit-core.css"; @import "../../ribbit-core.css";
body { font-family: sans-serif; margin: 20px; }
main { max-width: 960px; margin: auto }
#ribbit { border: 1px solid #ccc; border-radius: 4px; padding: 20px; min-height: 200px; }
a { a {
text-decoration: none; text-decoration: none;
} }
@@ -50,3 +55,80 @@ code {
background: #EEE; background: #EEE;
margin: 3px; margin: 3px;
} }
.ribbit-toolbar {
background: #f5f5f5;
border: 1px solid #ccc; border-radius: 4px; padding: 4px; margin-bottom: 8px;
}
.ribbit-toolbar ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 2px;
align-items: center;
}
.ribbit-toolbar button {
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
background-repeat: no-repeat;
background-position: center;
background-size: 1rem 1rem;
width: 2rem;
height: 2rem;
}
.ribbit-toolbar button:hover {
background-color: #DDD;
background-blend-mode: darken;
}
.ribbit-toolbar button.disabled {
opacity: 0.3;
cursor: default;
}
.ribbit-btn-fencedCode { background-image: url("icons/code-square.svg"); }
.ribbit-btn-blockquote { background-image: url("icons/blockquote-left.svg"); }
.ribbit-btn-hr { background-image: url("icons/hr.svg"); }
.ribbit-btn-table { background-image: url("icons/table.svg"); }
.ribbit-btn-code { background-image: url("icons/code.svg"); }
.ribbit-btn-link { background-image: url("icons/link.svg"); }
.ribbit-btn-boldItalic { background-image: url("icons/type-bold.svg"); }
.ribbit-btn-bold { background-image: url("icons/type-bold.svg"); }
.ribbit-btn-italic { background-image: url("icons/type-italic.svg"); }
.ribbit-btn-strikethrough { background-image: url("icons/type-strikethrough.svg"); }
.ribbit-btn-h1 { background-image: url("icons/type-h1.svg"); }
.ribbit-btn-h2 { background-image: url("icons/type-h2.svg"); }
.ribbit-btn-h3 { background-image: url("icons/type-h3.svg"); }
.ribbit-btn-h4 { background-image: url("icons/type-h4.svg"); }
.ribbit-btn-h5 { background-image: url("icons/type-h5.svg"); }
.ribbit-btn-h6 { background-image: url("icons/type-h6.svg"); }
.ribbit-btn-ul { background-image: url("icons/list-ul.svg"); }
.ribbit-btn-ol { background-image: url("icons/list-ol.svg"); }
.ribbit-btn-edit { background-image: url("icons/pen.svg"); }
.ribbit-btn-save { background-image: url("icons/floppy.svg"); }
.ribbit-btn-toggle { background-image: url("icons/toggle-off.svg"); }
.ribbit-toolbar .spacer {
width: 12px;
}
.ribbit-dropdown {
position: absolute;
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px;
z-index: 10;
}
.ribbit-dropdown button {
display: block;
width: 100%;
text-align: left;
margin: 1px 0;
}
+228 -242
View File
@@ -37,35 +37,68 @@ import { type MacroDef } from './macros';
const EDITING_CONTEXT_CLASS = 'ribbit-editing'; const EDITING_CONTEXT_CLASS = 'ribbit-editing';
// CSS class prefix for all styled-source block divs. // CSS class prefix for all styled-source block divs.
// Each block div gets one of: md-paragraph, md-h1…md-h6,
// md-blockquote, md-list-item, md-ol-list-item, md-pre.
const BLOCK_CLASS_PREFIX = 'md-'; const BLOCK_CLASS_PREFIX = 'md-';
// CSS class applied to all delimiter spans (e.g. the ** in **bold**). // CSS class applied to all delimiter spans (e.g. the ** in **bold**).
// CSS hides these in viewing state, reveals them in editing state.
const DELIM_CLASS = 'md-delim'; const DELIM_CLASS = 'md-delim';
// CSS class applied to list-item prefix spans (e.g. "- " or "1. "). // CSS class applied to list-item prefix spans (e.g. "- " or "1. ").
// Kept in textContent so getMarkdown() sees the marker; CSS hides
// it in viewing state and replaces it with a real list-item bullet.
const LIST_PREFIX_CLASS = 'md-list-prefix'; const LIST_PREFIX_CLASS = 'md-list-prefix';
// data- attribute on inline formatting spans, used to identify them // data- attribute on inline formatting spans.
// during selectionchange so we can toggle EDITING_CONTEXT_CLASS.
const INLINE_SPAN_ATTR = 'data-md-span'; const INLINE_SPAN_ATTR = 'data-md-span';
// Characters this implementation inserts purely as internal bookkeeping
// (never something the user typed, never part of real markdown content):
// \u200B — placeholder text node so an empty block-prefix line (e.g.
// "# " with nothing after it) still has a text node for the
// caret to land in.
// \u200C — boundary marker inserted by disambiguateAsteriskRuns
// between two adjacent closing delimiters that would
// otherwise form an ambiguous run (e.g. "***" closing both
// an inner "*" and an outer "**").
// These are stripped entirely from getMarkdown() output and skipped
// (never counted) by caret-offset math in both directions. \u00A0 is
// handled separately by normalizeLineText below — it is NOT an
// internal marker, since it stands in for a real space the user typed.
const INTERNAL_MARKER_CHARS = '\u200B\u200C';
const INTERNAL_MARKER_PATTERN = new RegExp(`[${INTERNAL_MARKER_CHARS}]`, 'g');
const INTERNAL_MARKER_SINGLE = new RegExp(`^[${INTERNAL_MARKER_CHARS}]$`);
/**
* Remove all internal bookkeeping markers from a string.
* stripInternalMarkers('**bold *italic*\u200C**') // '**bold *italic***'
*/
function stripInternalMarkers(text: string): string {
return text.replace(INTERNAL_MARKER_PATTERN, '');
}
/**
* True if a single character is purely internal bookkeeping.
* isInternalMarker('\u200C') // true
*/
function isInternalMarker(character: string): boolean {
return INTERNAL_MARKER_SINGLE.test(character);
}
/**
* Normalize a block's raw textContent into clean markdown source:
* convert NBSP (inserted for Chromium caret-stability when a line ends
* in a space — never deleted, since it represents a real space the
* user typed) back to a plain space, then strip internal bookkeeping
* markers (never user content, always deleted entirely).
*
* normalizeLineText('#\u00A0Title\u200C') // '# Title'
*/
function normalizeLineText(text: string): string {
return stripInternalMarkers(text.replace(/\u00A0/g, ' '));
}
// ─── Block classification ───────────────────────────────────────────────────── // ─── Block classification ─────────────────────────────────────────────────────
// A block rule maps a test against a raw markdown line to a CSS class
// and the length of the prefix that should be wrapped in .md-delim.
// Rules are checked in order; the first match wins.
interface BlockRule { interface BlockRule {
// Name used to build the CSS class: md-{name}
name: string; name: string;
// Returns the length of the block prefix (e.g. 3 for "## "),
// or null if this rule does not match the line.
prefixLength: (line: string) => number | null; prefixLength: (line: string) => number | null;
// True if the prefix should use LIST_PREFIX_CLASS instead of DELIM_CLASS.
isList?: boolean; isList?: boolean;
} }
@@ -74,7 +107,6 @@ const BLOCKQUOTE_PATTERN = /^> /;
const UNORDERED_LIST_PATTERN = /^[-*+] /; const UNORDERED_LIST_PATTERN = /^[-*+] /;
const ORDERED_LIST_PATTERN = /^\d+\. /; const ORDERED_LIST_PATTERN = /^\d+\. /;
// Block rules in priority order. Paragraph is the implicit fallback.
const BLOCK_RULES: BlockRule[] = [ const BLOCK_RULES: BlockRule[] = [
{ {
name: 'pre', name: 'pre',
@@ -117,13 +149,103 @@ const BLOCK_RULES: BlockRule[] = [
}, },
]; ];
// ─── Inline delimiter disambiguation ───────────────────────────────────────────
/**
* Pre-pass over a line of markdown text that disambiguates runs of 3
* consecutive asterisks (e.g. "**bold *italic***") into two separate,
* unambiguous tokens, separated by a sentinel.
*
* Markdown allows * and ** to nest (one inside the other) but this
* implementation does not support nesting a delimiter inside itself,
* so at most two distinct asterisk-based delimiters can be open at
* once. A run of exactly 3 asterisks therefore always means "open or
* close the most-recently-opened one first, then the other."
*
* Handles both directions of the ambiguity:
* - closing-side: "**bold *italic***" — the trailing run of 3
* closes italic (innermost) then bold.
* - opening-side: "***italic* bold**" — the leading run of 3 opens
* italic then bold (by convention, single-char delimiter is
* treated as inner/most-recently-opened, matching the closing
* rule, for symmetry).
*
* disambiguateAsteriskRuns('**bold *italic***')
* // '**bold *italic*\u200C**'
*/
const SENTINEL = '\u200C';
function disambiguateAsteriskRuns(line: string): string {
const ASTERISK_RUN = /\*{1,3}/g;
const stack: ('*' | '**' | '***')[] = [];
let result = '';
let lastIndex = 0;
let match: RegExpExecArray | null;
const bold = '**';
const italic = '*';
let lastSequence = '';
while ((match = ASTERISK_RUN.exec(line)) !== null) {
result += line.slice(lastIndex, match.index);
const sequence = match[0];
if (sequence.length === 3) {
// split *** into opening ** and *
if (stack.length === 0) {
result += sequence;
stack.push('***');
// closing ***, close the stack
} else if (stack.length === 1 && stack[0] === sequence) {
result = result.replace('***', bold + SENTINEL + italic);
result += italic + SENTINEL + bold;
stack.pop();
} else if (stack.length === 2) {
const inner = stack.pop();
const outer = stack.pop();
result += inner + SENTINEL + outer;
} else if (stack.length === 1) {
console.warn(`Cannot parsed line '${line}': invalid sequence ${sequence} with stack ${stack}!`);
} else {
console.warn(`UNHANDLED '${sequence}', last sequence '${lastSequence}'`);
}
} else if (stack.length) {
if (stack[stack.length - 1] === sequence) {
result += stack.pop();
} else if (stack.length === 1 && stack[0] === '***') {
const opener = stack[0].substring(0, stack[0].length - sequence.length);
result = result.replace('***', opener + SENTINEL + sequence);
result += sequence;
stack[0] = opener as '*' | '**';
} else {
stack.push(sequence as '*' | '**');
result += sequence;
}
} else {
stack.push(sequence as '*' | '**');
result += sequence;
}
lastIndex = match.index + sequence.length;
}
result += line.slice(lastIndex);
return result;
}
// ─── RibbitEditor ───────────────────────────────────────────────────────────── // ─── RibbitEditor ─────────────────────────────────────────────────────────────
/** /**
* Styled-source WYSIWYG editor. Extends Ribbit's read-only viewer with * Styled-source WYSIWYG editor. Extends Ribbit's read-only viewer with
* contentEditable support. The user always edits raw markdown; CSS renders * contentEditable support. The user always edits raw markdown; CSS
* it visually. Replaces the old flatten→rebuild pipeline with a per-line * renders it visually. Per-line incremental DOM update.
* incremental DOM update [see STYLED_SOURCE_DESIGN.md].
* *
* const editor = new RibbitEditor({ editorId: 'my-element' }); * const editor = new RibbitEditor({ editorId: 'my-element' });
* editor.run(); * editor.run();
@@ -131,41 +253,14 @@ const BLOCK_RULES: BlockRule[] = [
*/ */
export class RibbitEditor extends Ribbit { export class RibbitEditor extends Ribbit {
// The formatting span the cursor was last inside. Tracked so we
// can remove EDITING_CONTEXT_CLASS when the cursor moves away.
private activeFormattingSpan: HTMLElement | null = null; private activeFormattingSpan: HTMLElement | null = null;
/**
* Initialize the editor with view/wysiwyg states, bind DOM events,
* and optionally attach vim keybindings.
*
* const editor = new RibbitEditor({ editorId: 'content' });
* editor.run();
* editor.wysiwyg(); // enter styled-source editing
*/
run(): void { run(): void {
this.states = { this.states = {
VIEW: 'view', VIEW: 'view',
WYSIWYG: 'wysiwyg', WYSIWYG: 'wysiwyg',
}; };
if (this.theme.features?.vim) {
// TODO
/*
this.vim = new VimHandler((mode) => {
if (mode === 'normal') {
this.toolbar.disable();
this.element.classList.add('vim-normal');
this.element.classList.remove('vim-insert');
} else {
this.toolbar.enable();
this.element.classList.add('vim-insert');
this.element.classList.remove('vim-normal');
}
});
*/
}
this.#bindEvents(); this.#bindEvents();
this.element.classList.add('loaded'); this.element.classList.add('loaded');
if (this.autoToolbar) { if (this.autoToolbar) {
@@ -175,8 +270,6 @@ export class RibbitEditor extends Ribbit {
this.emitReady(); this.emitReady();
} }
// ── Event binding ──────────────────────────────────────────────────────────
#bindEvents(): void { #bindEvents(): void {
let debounceTimer: number | undefined; let debounceTimer: number | undefined;
@@ -191,24 +284,16 @@ export class RibbitEditor extends Ribbit {
}, 300); }, 300);
}); });
/*
this.element.addEventListener('keydown', (event: KeyboardEvent) => { this.element.addEventListener('keydown', (event: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) { if (this.state !== this.states.WYSIWYG) {
return; return;
} }
this.#dispatchKeydown(event);
});
*/
this.element.addEventListener('keydown', (event: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) return;
if (event.key === 'Backspace') { if (event.key === 'Backspace') {
// Check if editor is already empty or about to become a bare <br>
const children = Array.from(this.element.children); const children = Array.from(this.element.children);
const onlyChild = children.length === 1 ? children[0] as HTMLElement : null; const onlyChild = children.length === 1 ? children[0] as HTMLElement : null;
const isEmpty = onlyChild && const isEmpty = onlyChild &&
!onlyChild.textContent!.replace(/\u200B/g, '').trim() && !stripInternalMarkers(onlyChild.textContent!).trim() &&
onlyChild.querySelector('br'); onlyChild.querySelector('br');
if (isEmpty) { if (isEmpty) {
event.preventDefault(); event.preventDefault();
@@ -245,29 +330,16 @@ export class RibbitEditor extends Ribbit {
}); });
} }
// ── Mode switching ─────────────────────────────────────────────────────────
/**
* Switch to styled-source editing mode. Renders the current markdown
* as a styled DOM (one block div per line) and enables contentEditable.
*
* editor.wysiwyg();
* // user now edits markdown directly with CSS rendering
*/
wysiwyg(): void { wysiwyg(): void {
if (this.getState() === this.states.WYSIWYG) { if (this.getState() === this.states.WYSIWYG) {
return; return;
} }
// Capture markdown before building the styled DOM, so getMarkdown()
// in wysiwyg state reads from the live styled DOM rather than
// sourceMarkdown (which belongs to view state).
const markdown = this.getMarkdown(); const markdown = this.getMarkdown();
this.sourceMarkdown = null; this.sourceMarkdown = null;
this.collaboration?.connect(); this.collaboration?.connect();
this.element.innerHTML = ''; this.element.innerHTML = '';
this.element.appendChild(this.#markdownToStyledDOM(markdown)); this.element.appendChild(this.#markdownToStyledDOM(markdown));
this.element.contentEditable = 'true'; this.element.contentEditable = 'true';
// Macro islands are non-editable; their source is in data-source
for (const macroElement of Array.from(this.element.querySelectorAll('.macro'))) { for (const macroElement of Array.from(this.element.querySelectorAll('.macro'))) {
const htmlMacro = macroElement as HTMLElement; const htmlMacro = macroElement as HTMLElement;
if (htmlMacro.dataset.editable === 'false') { if (htmlMacro.dataset.editable === 'false') {
@@ -277,36 +349,15 @@ export class RibbitEditor extends Ribbit {
this.setState(this.states.WYSIWYG); this.setState(this.states.WYSIWYG);
} }
/**
* Convert the editor's current styled DOM back to markdown.
* In wysiwyg state reads directly from the styled DOM — because
* every delimiter lives in a .md-delim text node, textContent
* always equals the original markdown source [see STYLED_SOURCE_DESIGN.md].
* In view state delegates to the base class which reads sourceMarkdown.
*
* const markdown = editor.getMarkdown(); // "**hello** world"
*/
getMarkdown(): string { getMarkdown(): string {
if (this.getState() === this.states.WYSIWYG) { if (this.getState() === this.states.WYSIWYG) {
// Each block div's textContent is one markdown line.
// Macro islands emit their data-source value instead.
return Array.from(this.element.children) return Array.from(this.element.children)
.map((block) => this.#blockToMarkdown(block as HTMLElement)) .map((block) => this.#blockToMarkdown(block as HTMLElement))
.join('\n'); .join('\n');
} }
// VIEW state: delegate to base class, which reads sourceMarkdown
// (set by view() before rendering) or falls back to textContent.
return super.getMarkdown(); return super.getMarkdown();
} }
/**
* 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 selection = window.getSelection()!; const selection = window.getSelection()!;
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
@@ -318,15 +369,6 @@ export class RibbitEditor extends Ribbit {
selection.addRange(range); selection.addRange(range);
} }
// ── Styled DOM construction ────────────────────────────────────────────────
/**
* Convert a full markdown string to a styled-source DocumentFragment.
* One block <div> per line; inline delimiters wrapped in .md-delim spans.
* Called once on wysiwyg() entry and after paste.
*
* editor.element.appendChild(editor.#markdownToStyledDOM('# Hello\n**bold**'));
*/
#markdownToStyledDOM(markdown: string): DocumentFragment { #markdownToStyledDOM(markdown: string): DocumentFragment {
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const line of markdown.split('\n')) { for (const line of markdown.split('\n')) {
@@ -335,17 +377,6 @@ export class RibbitEditor extends Ribbit {
return fragment; return fragment;
} }
/**
* Build a single styled block <div> from one markdown line.
* Classifies the line, wraps the block prefix in a .md-delim span,
* and parses inline formatting for the remaining content.
*
* this.#buildBlock('## Hello **world**')
* // <div class="md-h2">
* // <span class="md-delim">## </span>
* // Hello <span class="md-bold">…</span>
* // </div>
*/
#buildBlock(line: string): HTMLDivElement { #buildBlock(line: string): HTMLDivElement {
const block = document.createElement('div'); const block = document.createElement('div');
@@ -355,13 +386,14 @@ export class RibbitEditor extends Ribbit {
return block; return block;
} }
//console.log(`Parsing line '${line}'`);
for (const rule of BLOCK_RULES) { for (const rule of BLOCK_RULES) {
const prefixLength = rule.prefixLength(line); const prefixLength = rule.prefixLength(line);
if (prefixLength === null) { if (prefixLength === null) {
continue; continue;
} }
// Headings carry their level in the class name [C10]
if (rule.name === 'heading') { if (rule.name === 'heading') {
const match = line.match(HEADING_PATTERN)!; const match = line.match(HEADING_PATTERN)!;
block.className = `${BLOCK_CLASS_PREFIX}h${match.groups!.hashes.length}`; block.className = `${BLOCK_CLASS_PREFIX}h${match.groups!.hashes.length}`;
@@ -383,7 +415,6 @@ export class RibbitEditor extends Ribbit {
return block; return block;
} }
// Fallback: plain paragraph
block.className = `${BLOCK_CLASS_PREFIX}paragraph`; block.className = `${BLOCK_CLASS_PREFIX}paragraph`;
block.appendChild(this.#parseInline(line)); block.appendChild(this.#parseInline(line));
return block; return block;
@@ -394,8 +425,7 @@ export class RibbitEditor extends Ribbit {
span.className = className; span.className = className;
span.setAttribute(INLINE_SPAN_ATTR, '1'); span.setAttribute(INLINE_SPAN_ATTR, '1');
if (span.className == 'md-link') { if (span.className === 'md-link') {
// Link: [label](href)
span.appendChild(this.#makeDelimSpan('[')); span.appendChild(this.#makeDelimSpan('['));
const linkTextNode = document.createElement('span'); const linkTextNode = document.createElement('span');
linkTextNode.className = 'md-link-text'; linkTextNode.className = 'md-link-text';
@@ -405,14 +435,7 @@ export class RibbitEditor extends Ribbit {
return span; return span;
} }
// Delimiter run: **content**, *content*, `content`, ~~content~~
span.appendChild(this.#makeDelimSpan(match.groups!.delimiter)); span.appendChild(this.#makeDelimSpan(match.groups!.delimiter));
// Recurse: content may itself contain nested delimiters
// (e.g. "*italic*" inside a "**bold ... **" span). Each
// recursive call only ever sees the substring between this
// span's own delimiters, so nesting depth is naturally
// bounded by the original text's structure.
span.appendChild(this.#parseInline(match.groups!.content)); span.appendChild(this.#parseInline(match.groups!.content));
if (match.groups!.closer) { if (match.groups!.closer) {
span.appendChild(this.#makeDelimSpan(match.groups!.closer)); span.appendChild(this.#makeDelimSpan(match.groups!.closer));
@@ -421,29 +444,23 @@ export class RibbitEditor extends Ribbit {
return span; return span;
} }
/**
* Parse an inline markdown string into a DocumentFragment of text
* nodes and styled <span> elements. Each span wraps its delimiters
* in .md-delim children so that span.textContent == the original
* markdown source (enabling getMarkdown() = textContent).
*
* this.#parseInline('hello **world** and `code`')
*/
#parseInline(text: string): DocumentFragment { #parseInline(text: string): DocumentFragment {
var t = text;
text = disambiguateAsteriskRuns(text);
//console.log(`${t} => ${text}`);
const classes: Record<string, string> = { const classes: Record<string, string> = {
'***': 'md-bold-italic',
'**': 'md-bold', '**': 'md-bold',
'*': 'md-italic', '*': 'md-italic',
'~~': 'md-strikethrough', '~~': 'md-strikethrough',
'`': 'md-code', '`': 'md-code',
}; };
// Combined pattern: try a link first, then a delimiter run.
// Named groups are mutually exclusive per match (only one branch fires).
const INLINE_PATTERN = new RegExp( const INLINE_PATTERN = new RegExp(
'\\[(?<linkLabel>[^\\]]+)\\]\\((?<linkHref>[^)]+)\\)' + '\\[(?<linkLabel>[^\\]]+)\\]\\((?<linkHref>[^)]+)\\)' +
'|' + '|' + '(?<![*])' +
'(?<delimiter>\\*{1,3}|~~|`)(?<content>.+?)(?<closer>\\k<delimiter>|$)', '(?<delimiter>${SENTINEL}\\*{2}|\\*{2}|${SENTINEL}\\*|\\*|~~|`)' +
'(?<content>.*)' +
`(?<closer>(?:${SENTINEL}\\k<delimiter>|\\k<delimiter>)(?![*]))`,
'g' 'g'
); );
@@ -452,6 +469,7 @@ export class RibbitEditor extends Ribbit {
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = INLINE_PATTERN.exec(text)) !== null) { while ((match = INLINE_PATTERN.exec(text)) !== null) {
// console.log(`DELIM: ${match.groups!.delimiter} CONTENT: ${match.groups!.content} CLOSER: ${match.groups!.closer}`)
if (match.index > lastIndex) { if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
} }
@@ -464,10 +482,10 @@ export class RibbitEditor extends Ribbit {
fragment.appendChild(document.createTextNode(text.slice(lastIndex))); fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
} }
// console.log(`'${t}' => '${text.replace('\u200C', '|')}`, fragment.children);
return fragment; return fragment;
} }
/** Create a <span class="md-delim"> with the given text content. */
#makeDelimSpan(text: string): HTMLSpanElement { #makeDelimSpan(text: string): HTMLSpanElement {
const span = document.createElement('span'); const span = document.createElement('span');
span.className = DELIM_CLASS; span.className = DELIM_CLASS;
@@ -475,22 +493,16 @@ export class RibbitEditor extends Ribbit {
return span; return span;
} }
// ── Per-line incremental update ────────────────────────────────────────────
/**
* On each input event, find the block div containing the cursor,
* read its textContent (which is valid markdown), and rebuild only
* that block's children. The rest of the document is untouched.
*
* Complexity: O(block length) per keystroke, not O(document length).
*/
#updateCurrentBlock(): void { #updateCurrentBlock(): void {
const block = this.#findCurrentBlock(); const block = this.#findCurrentBlock();
if (!block) return; if (!block) {
return;
}
const caretOffset = this.#getCaretOffset(block); const caretOffset = this.#getCaretOffset(block);
const lineText = block.textContent!.replace(/\u00A0/g, ' ').replace(/\u200B/g, ''); const lineText = normalizeLineText(block.textContent!);
console.log(`'${block.textContent}' normalized to '${lineText}'`);
const newBlock = this.#buildBlock(lineText); const newBlock = this.#buildBlock(lineText);
block.className = newBlock.className; block.className = newBlock.className;
@@ -499,23 +511,35 @@ export class RibbitEditor extends Ribbit {
block.appendChild(newBlock.firstChild); block.appendChild(newBlock.firstChild);
} }
// Chromium drops the caret-positioning effect of a trailing plain /*
// space in some cases; swapping it for a non-breaking space on the
// live text node (never via innerHTML) keeps the caret sticky
// without touching how lineText is computed on the next keystroke.
if (lineText.endsWith(' ')) { if (lineText.endsWith(' ')) {
const lastChild = block.lastChild; const lastChild = block.lastChild;
if (lastChild) { if (lastChild && lastChild.nodeType === 3) {
lastChild.textContent = lastChild.textContent!.replace(/ $/, '\u00A0'); lastChild.textContent = lastChild.textContent!.replace(/ $/, '\u00A0');
} }
} }
if (lineText.endsWith(' ')) {
// Find the last text node in the block (may be nested inside spans)
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let lastTextNode: Text | null = null;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
lastTextNode = node;
}
if (lastTextNode) {
lastTextNode.textContent = lastTextNode.textContent!.replace(/ $/, '\u00A0');
}
console.log(`updating lastTextNode.textContent to '${lastTextNode!.textContent}'`);
}
*/
// Place caret after any prefix span, never inside it
const prefixSpan = block.firstElementChild; const prefixSpan = block.firstElementChild;
const prefixLen = (prefixSpan?.classList.contains(DELIM_CLASS) || const prefixLen = (prefixSpan?.classList.contains(DELIM_CLASS) ||
prefixSpan?.classList.contains(LIST_PREFIX_CLASS)) prefixSpan?.classList.contains(LIST_PREFIX_CLASS))
? prefixSpan.textContent!.length : 0; ? prefixSpan.textContent!.length : 0;
console.log(`${block.textContent!}: ${caretOffset} <= ${prefixLen} && ${prefixSpan} ?`);
if (caretOffset <= prefixLen && prefixSpan) { if (caretOffset <= prefixLen && prefixSpan) {
const sel = window.getSelection()!; const sel = window.getSelection()!;
const range = document.createRange(); const range = document.createRange();
@@ -535,15 +559,7 @@ export class RibbitEditor extends Ribbit {
this.#updateEditingContext(); this.#updateEditingContext();
} }
// ── Keyboard handling ──────────────────────────────────────────────────────
/**
* Handle Enter and Backspace ourselves; route all other keys to the
* block tag's handleKeydown if it has one.
*/
#dispatchKeydown(event: KeyboardEvent): void { #dispatchKeydown(event: KeyboardEvent): void {
// Dispatch to the block tag's own key handler first, so that
// tags like HeadingTag and ListTag can override Enter/Backspace.
const block = this.#findCurrentBlock(); const block = this.#findCurrentBlock();
if (block) { if (block) {
const selection = window.getSelection(); const selection = window.getSelection();
@@ -579,25 +595,20 @@ export class RibbitEditor extends Ribbit {
} }
} }
/**
* Enter: split the current block at the caret into two block divs.
* Before: one div with text "hello|world"
* After: two divs — "hello" and "world"
*/
#handleEnter(): void { #handleEnter(): void {
const block = this.#findCurrentBlock(); const block = this.#findCurrentBlock();
if (!block) return; if (!block) {
return;
}
const offset = this.#getCaretOffset(block); const offset = this.#getCaretOffset(block);
const text = block.textContent!.replace(/\u00A0/g, ' ').replace(/\u200B/g, ''); const text = normalizeLineText(block.textContent!);
// Detect prefix
const prefixSpan = block.firstElementChild; const prefixSpan = block.firstElementChild;
const isListPrefix = prefixSpan?.classList.contains(LIST_PREFIX_CLASS); const isListPrefix = prefixSpan?.classList.contains(LIST_PREFIX_CLASS);
const isBlockquote = prefixSpan?.classList.contains(DELIM_CLASS) && const isBlockquote = prefixSpan?.classList.contains(DELIM_CLASS) &&
block.classList.contains('md-blockquote'); block.classList.contains('md-blockquote');
// Determine what prefix the next line should carry
let prefix = ''; let prefix = '';
if (isListPrefix) { if (isListPrefix) {
const orderedMatch = prefixSpan!.textContent!.match(/^(?<num>\d+)\. /); const orderedMatch = prefixSpan!.textContent!.match(/^(?<num>\d+)\. /);
@@ -611,17 +622,16 @@ export class RibbitEditor extends Ribbit {
prefix = prefixSpan!.textContent!; prefix = prefixSpan!.textContent!;
} }
//console.log('handleEnter text:', JSON.stringify(text), 'prefix:', JSON.stringify(prefix)); const currentPrefix = stripInternalMarkers(prefixSpan?.textContent ?? '');
// Current prefix (before incrementing) if ((isListPrefix || isBlockquote) &&
const currentPrefix = prefixSpan?.textContent?.replace(/\u200B/g, '') ?? ''; (text === currentPrefix || text.trim() === currentPrefix.trim())) {
// Double Enter on empty prefixed line exits
if (prefix && (text === currentPrefix || text.trim() === currentPrefix.trim())) {
const emptyBlock = this.#buildBlock(''); const emptyBlock = this.#buildBlock('');
block.className = emptyBlock.className; block.className = emptyBlock.className;
block.innerHTML = ''; block.innerHTML = '';
while (emptyBlock.firstChild) block.appendChild(emptyBlock.firstChild); while (emptyBlock.firstChild) {
block.appendChild(emptyBlock.firstChild);
}
this.#restoreCaret(block, 0); this.#restoreCaret(block, 0);
return; return;
} }
@@ -634,11 +644,12 @@ export class RibbitEditor extends Ribbit {
block.className = firstBlock.className; block.className = firstBlock.className;
block.innerHTML = ''; block.innerHTML = '';
while (firstBlock.firstChild) block.appendChild(firstBlock.firstChild); while (firstBlock.firstChild) {
block.appendChild(firstBlock.firstChild);
}
block.after(secondBlock); block.after(secondBlock);
// Place caret after prefix in new block
const newPrefixSpan = secondBlock.firstElementChild; const newPrefixSpan = secondBlock.firstElementChild;
if (newPrefixSpan && (newPrefixSpan.classList.contains(DELIM_CLASS) || if (newPrefixSpan && (newPrefixSpan.classList.contains(DELIM_CLASS) ||
newPrefixSpan.classList.contains(LIST_PREFIX_CLASS))) { newPrefixSpan.classList.contains(LIST_PREFIX_CLASS))) {
@@ -658,11 +669,6 @@ export class RibbitEditor extends Ribbit {
} }
} }
/**
* Backspace at offset 0: merge the current block with the previous one.
* Returns true if we handled it (caller should preventDefault), false
* to let the browser handle it normally.
*/
#handleBackspace(): boolean { #handleBackspace(): boolean {
const block = this.#findCurrentBlock(); const block = this.#findCurrentBlock();
if (!block) { if (!block) {
@@ -679,8 +685,9 @@ export class RibbitEditor extends Ribbit {
return false; return false;
} }
const previousLength = previousBlock.textContent!.length; const previousLength = stripInternalMarkers(previousBlock.textContent!).length;
const merged = previousBlock.textContent! + block.textContent!.replace(/\u00A0/g, ' '); const merged = stripInternalMarkers(previousBlock.textContent!) +
normalizeLineText(block.textContent!);
const mergedBlock = this.#buildBlock(merged); const mergedBlock = this.#buildBlock(merged);
previousBlock.className = mergedBlock.className; previousBlock.className = mergedBlock.className;
@@ -694,14 +701,6 @@ export class RibbitEditor extends Ribbit {
return true; return true;
} }
// ── Cursor tracking ────────────────────────────────────────────────────────
/**
* Walk up from the cursor to find the direct child of the editor
* element that contains it. That is the current block div.
*
* const block = this.#findCurrentBlock(); // <div class="md-paragraph">
*/
#findCurrentBlock(): HTMLElement | null { #findCurrentBlock(): HTMLElement | null {
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) { if (!selection || selection.rangeCount === 0) {
@@ -717,13 +716,6 @@ export class RibbitEditor extends Ribbit {
return null; return null;
} }
/**
* Walk the block's text nodes to get the total character offset
* from the start of the block to the cursor. Survives DOM rebuilds
* because it works on character counts, not node references.
*
* const offset = this.#getCaretOffset(block); // 7
*/
#getCaretOffset(block: HTMLElement): number { #getCaretOffset(block: HTMLElement): number {
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) { if (!selection || selection.rangeCount === 0) {
@@ -735,22 +727,13 @@ export class RibbitEditor extends Ribbit {
return range.toString().length; return range.toString().length;
} }
/**
* Walk the block's text nodes and place the cursor at the given
* character offset. Called after every block rebuild to restore
* the caret to where the user was typing.
*
* this.#restoreCaret(block, 7);
*/
#restoreCaret(block: HTMLElement, offset: number): void { #restoreCaret(block: HTMLElement, offset: number): void {
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection) { if (!selection) {
return; return;
} }
const range = document.createRange(); const range = document.createRange();
let remaining = offset; const placed = this.#walkForCaret(block, range, offset);
const placed = this.#walkForCaret(block, range, remaining);
if (!placed) { if (!placed) {
range.selectNodeContents(block); range.selectNodeContents(block);
@@ -762,49 +745,65 @@ export class RibbitEditor extends Ribbit {
} }
/** /**
* Recursively walk text nodes in the subtree rooted at `node`, * Recursively walk text nodes counting only "real" characters
* decrementing `remaining` for each character encountered. When * toward `remaining` — internal bookkeeping markers are skipped
* remaining reaches zero, sets range.start and returns true. * over entirely and never consume a unit of `remaining`.
*/ */
#walkForCaret(node: Node, range: Range, remaining: number): boolean { #walkForCaret(node: Node, range: Range, remaining: number): boolean {
if (node.nodeType === 3) { if (node.nodeType === 3) {
const textNode = node as Text; const textNode = node as Text;
if (remaining <= textNode.length) { const text = textNode.textContent!;
range.setStart(textNode, remaining);
let domOffset = 0;
for (const character of text) {
if (isInternalMarker(character)) {
domOffset += character.length;
continue;
}
if (remaining === 0) {
range.setStart(textNode, domOffset);
range.collapse(true); range.collapse(true);
return true; return true;
} }
remaining -= 1;
domOffset += character.length;
}
if (remaining === 0) {
range.setStart(textNode, domOffset);
range.collapse(true);
return true;
}
return false; return false;
} }
let consumed = 0; let consumed = 0;
for (const child of Array.from(node.childNodes)) { for (const child of Array.from(node.childNodes)) {
if (child.nodeType === 3) { if (child.nodeType === 3) {
const textNode = child as Text; const textNode = child as Text;
if (remaining - consumed <= textNode.length) { const realLength = stripInternalMarkers(textNode.textContent!).length;
range.setStart(textNode, remaining - consumed); if (remaining - consumed <= realLength) {
range.collapse(true); const placed = this.#walkForCaret(textNode, range, remaining - consumed);
if (placed) {
return true; return true;
} }
consumed += textNode.length; }
consumed += realLength;
} else { } else {
const childLength = (child.textContent || '').length; const childRealLength = stripInternalMarkers(child.textContent || '').length;
if (remaining - consumed <= childLength) { if (remaining - consumed <= childRealLength) {
const placed = this.#walkForCaret(child, range, remaining - consumed); const placed = this.#walkForCaret(child, range, remaining - consumed);
if (placed) { if (placed) {
return true; return true;
} }
} }
consumed += childLength; consumed += childRealLength;
} }
} }
return false; return false;
} }
/**
* On selectionchange, find the nearest ancestor that is an inline
* formatting span and add EDITING_CONTEXT_CLASS to it so CSS reveals
* its delimiters. Remove it from the previous span.
*/
#updateEditingContext(): void { #updateEditingContext(): void {
this.#clearEditingContext(); this.#clearEditingContext();
@@ -827,7 +826,6 @@ export class RibbitEditor extends Ribbit {
} }
} }
/** Remove EDITING_CONTEXT_CLASS from the previously active span. */
#clearEditingContext(): void { #clearEditingContext(): void {
if (this.activeFormattingSpan) { if (this.activeFormattingSpan) {
this.activeFormattingSpan.classList.remove(EDITING_CONTEXT_CLASS); this.activeFormattingSpan.classList.remove(EDITING_CONTEXT_CLASS);
@@ -835,29 +833,17 @@ export class RibbitEditor extends Ribbit {
} }
} }
// ── getMarkdown helpers ────────────────────────────────────────────────────
/**
* Serialize a single block div to its markdown line. Macro islands
* emit their data-source value; all other blocks emit textContent.
*
* this.#blockToMarkdown(block) // "## Hello **world**"
*/
#blockToMarkdown(block: HTMLElement): string { #blockToMarkdown(block: HTMLElement): string {
// Empty block (just a <br>) → blank line
if (!block.textContent!.trim() && block.querySelector('br')) { if (!block.textContent!.trim() && block.querySelector('br')) {
return ''; return '';
} }
// Macro islands store their source text in data-source
if (block.dataset.macro) { if (block.dataset.macro) {
return block.dataset.source || ''; return block.dataset.source || '';
} }
return block.textContent!.replace(/\u200B/g, '').replace(/\u00A0/g, ' '); return normalizeLineText(block.textContent!);
} }
} }
// Public API — matches the previous export shape so consumers don't break.
export { RibbitEditor as Editor }; export { RibbitEditor as Editor };
export { Ribbit as Viewer }; export { Ribbit as Viewer };
export { inlineTag }; export { inlineTag };
+4 -16
View File
@@ -25,7 +25,7 @@ const MACRO_ID_PREFIX = 'macro:';
const DROPDOWN_INDICATOR = ' ▾'; const DROPDOWN_INDICATOR = ' ▾';
/** IDs of buttons that belong in the utility section, not the tag/macro area. */ /** IDs of buttons that belong in the utility section, not the tag/macro area. */
const UTILITY_BUTTON_IDS = ['save', 'toggle', 'markdown']; const UTILITY_BUTTON_IDS = ['save', 'edit'];
const MAX_HEADING_LEVEL = 6; const MAX_HEADING_LEVEL = 6;
@@ -217,7 +217,7 @@ export class ToolbarManager {
action: 'custom', action: 'custom',
handler: () => this.editor.save(), handler: () => this.editor.save(),
}); });
this.register('toggle', { this.register('edit', {
label: 'Edit', label: 'Edit',
shortcut: 'Ctrl+Shift+V', shortcut: 'Ctrl+Shift+V',
action: 'custom', action: 'custom',
@@ -229,18 +229,6 @@ export class ToolbarManager {
} }
}, },
}); });
this.register('markdown', {
label: 'Source',
shortcut: 'Ctrl+/',
action: 'custom',
handler: () => {
if (this.editor.getState() === EDITOR_STATE_EDIT) {
this.editor.wysiwyg();
} else {
this.editor.edit();
}
},
});
} }
/** /**
@@ -314,7 +302,7 @@ export class ToolbarManager {
items: macroIds, items: macroIds,
}); });
} }
slots.push('', 'markdown', 'save', 'toggle'); slots.push('', 'save', 'edit');
return slots; return slots;
} }
@@ -430,7 +418,7 @@ export class ToolbarManager {
const listItem = document.createElement('li'); const listItem = document.createElement('li');
const buttonElement = document.createElement('button'); const buttonElement = document.createElement('button');
buttonElement.className = `ribbit-btn-${button.id}`; buttonElement.className = `ribbit-btn-${button.id}`;
buttonElement.textContent = button.label; //buttonElement.textContent = button.label;
buttonElement.setAttribute('aria-label', button.label); buttonElement.setAttribute('aria-label', button.label);
buttonElement.title = button.shortcut buttonElement.title = button.shortcut
? `${button.label} (${button.shortcut})` ? `${button.label} (${button.shortcut})`
+149
View File
@@ -0,0 +1,149 @@
function disambiguateAsteriskRuns(line: string): string {
const SENTINEL = '\u200C';
const ASTERISK_RUN = /\*{1,3}/g;
const stack: ('*' | '**')[] = [];
let result = '';
let lastIndex = 0;
let match: RegExpExecArray | null;
console.log(`DISAMBIGUATE: '${line}'`);
while ((match = ASTERISK_RUN.exec(line)) !== null) {
result += line.slice(lastIndex, match.index);
const run = match[0];
console.log(`DISAMBIGUATE: run == '${run}', stack == ${stack}`);
if (run.length === 3 && stack.length === 2) {
const innerCloser = stack.pop()!;
const outerCloser = stack.pop()!;
result += innerCloser + SENTINEL + outerCloser;
} else if (run.length === 3 && stack.length === 0) {
if (match.index == 0) {
result += '***';
} else {
result += '*' + SENTINEL + '**';
stack.push('**');
stack.push('*');
}
} else if (run.length === 3) {
result += run;
} else if (stack.length > 0 && stack[stack.length - 1] === run) {
stack.pop();
result += run;
} else {
stack.push(run as '*' | '**');
result += run;
}
lastIndex = match.index + run.length;
}
result += line.slice(lastIndex);
console.log(`DISAMBIGUATE: '${result}'`);
return result;
}
function assert(condition, message) {
if (!condition) { throw new Error(message); }
}
function rewrite(line: string): string {
const SENTINEL = '|' //'\u200C';
const ASTERISK_RUN = /\*{1,3}/g;
const stack: ('*' | '**' | '***')[] = [];
let result = '';
let lastIndex = 0;
let match: RegExpExecArray | null;
const bold = '**';
const italic = '*';
let lastSequence = '';
while ((match = ASTERISK_RUN.exec(line)) !== null) {
result += line.slice(lastIndex, match.index);
const sequence = match[0];
if (sequence.length === 3) {
// split *** into opening ** and *
if (stack.length === 0) {
result += sequence;
stack.push('***');
// closing ***, close the stack
} else if (stack.length === 1 && stack[0] === sequence) {
result = result.replace('***', bold + SENTINEL + italic);
result += italic + SENTINEL + bold;
stack.pop();
} else if (stack.length === 2) {
const inner = stack.pop();
const outer = stack.pop();
result += inner + SENTINEL + outer;
} else if (stack.length === 1) {
console.warn(`Cannot parsed line '${line}': invalid sequence ${sequence} with stack ${stack}!`);
} else {
console.warn(`UNHANDLED '${sequence}', last sequence '${lastSequence}'`);
}
} else if (stack.length) {
if (stack[stack.length - 1] === sequence) {
result += stack.pop();
} else if (stack.length === 1 && stack[0] === '***') {
const opener = stack[0].substring(0, stack[0].length - sequence.length);
result = result.replace('***', opener + SENTINEL + sequence);
result += sequence;
stack[0] = opener as '*' | '**';
} else {
result += sequence;
stack.push(sequence as '*' | '**');
}
} else {
stack.push(sequence as '*' | '**');
result += sequence;
}
lastIndex = match.index + sequence.length;
}
result += line.slice(lastIndex);
return result;
}
function test_it() {
console.log(rewrite('*italic **bold***'));
let cases = [
{
'input': '***bold-italic***',
'expected': '**|*bold-italic*|**'
},
{
'input': '***bold** italic*',
'expected': '*|**bold** italic*',
},
{
'input': '***italic* bold**',
'expected': '**|*italic* bold**',
},
{
'input': '**bold, *italic***',
'expected': '**bold, *italic*|**'
},
{
'input': '*italic, **bold***',
'expected': '*italic, **bold**|*'
},
];
for (const testCase of cases) {
let result = rewrite(testCase.input);
assert(result == testCase.expected, `'${result}' (${result.length}) != '${testCase.expected}' (${testCase.expected.length})`);
}
};
test_it();
-23
View File
@@ -4,29 +4,6 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>Ribbit Integration Test Page</title> <title>Ribbit Integration Test Page</title>
<link rel="stylesheet" href="/static/themes/ribbit-default/theme.css"> <link rel="stylesheet" href="/static/themes/ribbit-default/theme.css">
<style>
body { font-family: sans-serif; margin: 20px; }
main { max-width: 960px; margin: auto }
#ribbit {
border: 1px solid #ccc;
padding: 20px;
min-height: 200px;
margin: auto;
}
.ribbit-toolbar {
background: #f5f5f5;
border: 1px solid #ccc;
padding: 4px;
margin-bottom: 8px;
}
.ribbit-toolbar ul { list-style: none; margin: 0; padding: 0; display: flex; gap: 2px; }
.ribbit-toolbar button { padding: 4px 8px; border: 1px solid #ddd; border-radius: 3px; background: white; cursor: pointer; font-size: 12px; }
.ribbit-toolbar button.active { background: #d0d0ff; }
.ribbit-toolbar button.disabled { opacity: 0.3; }
.ribbit-toolbar .spacer { width: 12px; }
.ribbit-dropdown { position: absolute; background: white; border: 1px solid #ccc; padding: 4px; }
.ribbit-dropdown button { display: block; width: 100%; }
</style>
</head> </head>
<body> <body>
<main> <main>
+64 -6
View File
@@ -26,6 +26,10 @@ const PORT = (() => {
const portArg = process.argv.find(arg => arg.startsWith('--port=')); const portArg = process.argv.find(arg => arg.startsWith('--port='));
return portArg ? parseInt(portArg.split('=')[1]) : 5023; return portArg ? parseInt(portArg.split('=')[1]) : 5023;
})(); })();
const FILTER = (() => {
const filterArg = process.argv.find(arg => arg.startsWith('--filter='));
return filterArg ? filterArg.split('=')[1] : null;
})();
const DELAY = 20; // ms between keystrokes const DELAY = 20; // ms between keystrokes
// ── State ───────────────────────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────────────────────
@@ -121,11 +125,7 @@ async function resetEditor() {
*/ */
async function typeString(text) { async function typeString(text) {
for (const character of text) { for (const character of text) {
if (character == ' ') {
await page.keyboard.press('Space');
} else {
await page.keyboard.insertText(character); await page.keyboard.insertText(character);
}
await page.waitForTimeout(DELAY); await page.waitForTimeout(DELAY);
} }
} }
@@ -169,6 +169,9 @@ function assert(condition, message) {
} }
async function test(name, fn) { async function test(name, fn) {
if (FILTER && !name.toLowerCase().includes(FILTER.toLowerCase())) {
return;
}
try { try {
await fn(); await fn();
passed++; passed++;
@@ -277,15 +280,70 @@ async function runTests() {
assert(markdown === '*italic*', `Expected "*italic*", got: "${markdown}"`); assert(markdown === '*italic*', `Expected "*italic*", got: "${markdown}"`);
}); });
await test('***bold-italic*** produces md-bold-italic span', async () => { await test('***bold-italic*** produces md-bold anad md-italic spans', async () => {
await resetEditor(); await resetEditor();
await typeString('***both***'); await typeString('***both***');
const html = await getHTML(); const html = await getHTML();
assert(html.includes('md-bold-italic'), `Expected md-bold-italic span: ${html}`); assert(html.includes('md-bold'), `Expected md-bold-italic span: ${html}`);
assert(html.includes('md-italic'), `Expected md-italic span: ${html}`);
const markdown = await getMarkdown(); const markdown = await getMarkdown();
assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`); assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`);
}); });
await test('nested bold and italic are handled correctly', async() => {
const NESTED_CASES = [
{
name: 'close-side ambiguity, outer=bold (bold *italic***)',
markdown: '**bold *italic***',
outerClass: 'md-bold',
innerClass: 'md-italic',
},
{
name: 'close-side ambiguity, outer=italic (*italic **bold***)',
markdown: '*italic **bold***',
outerClass: 'md-italic',
innerClass: 'md-bold',
},
{
name: 'open-side ambiguity, outer=bold (***italic* bold**)',
markdown: '***italic* bold**',
outerClass: 'md-bold',
innerClass: 'md-italic',
},
{
name: 'open-side ambiguity, outer=italic (***bold** italic*)',
markdown: '***bold** italic*',
outerClass: 'md-italic',
innerClass: 'md-bold',
},
];
for (const testCase of NESTED_CASES) {
await test(testCase.name, async () => {
await resetEditor();
await typeString(testCase.markdown);
const markdown = await getMarkdown();
const html = await getHTML();
assert(
markdown === testCase.markdown,
`Round-trip failed.\nExpected: "${testCase.markdown}"\nGot: "${markdown}"`
);
const outerIndex = html.indexOf(testCase.outerClass);
const innerIndex = html.indexOf(testCase.innerClass);
assert(
outerIndex !== -1 && innerIndex !== -1,
`Missing expected classes (${testCase.outerClass}, ${testCase.innerClass}) in: ${html}`
);
assert(
outerIndex < innerIndex,
`Expected ${testCase.outerClass} to wrap (appear before) ${testCase.innerClass} in: ${html}`
);
});
}
});
await test('`code` produces md-code span', async () => { await test('`code` produces md-code span', async () => {
await resetEditor(); await resetEditor();
await typeString('`code`'); await typeString('`code`');