Compare commits
2 Commits
fe405cb393
...
9f03721f86
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f03721f86 | ||
|
|
0758105e92 |
|
|
@ -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>
|
||||||
|
|
|
||||||
6841
package-lock.json
generated
6841
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.md-delim {
|
.md-delim {
|
||||||
display:inline;
|
display:none;
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
|
@ -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
src/static/themes/ribbit-default/icons
Symbolic link
1
src/static/themes/ribbit-default/icons
Symbolic link
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,60 @@ export class RibbitEditor extends Ribbit {
|
||||||
return fragment;
|
return fragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-pass over a line of markdown text that disambiguates closing
|
||||||
|
* runs of 3 consecutive asterisks (e.g. "**bold *italic***") into two
|
||||||
|
* separate, unambiguous closing tokens, separated by a sentinel.
|
||||||
|
*
|
||||||
|
* Markdown allows *and* ** to nest (one inside the other) but never a
|
||||||
|
* delimiter inside itself, so at most two distinct asterisk-based
|
||||||
|
* delimiters can be open at once. A run of exactly 3 closing asterisks
|
||||||
|
* therefore always means "close the most-recently-opened one (top of
|
||||||
|
* stack), then close the other" — there is no other valid reading.
|
||||||
|
*
|
||||||
|
* disambiguateAsteriskRuns('**bold *italic***')
|
||||||
|
* // '**bold *italic*\u200C**' (sentinel inserted between closers)
|
||||||
|
*/
|
||||||
|
#disambiguateAsteriskRuns(line: string): string {
|
||||||
|
const SENTINEL = '\u200C'; // zero-width non-joiner, distinct from
|
||||||
|
// the \u200B already used for caret placeholders
|
||||||
|
const ASTERISK_RUN = /\*{1,3}/g;
|
||||||
|
const stack: ('*' | '**')[] = [];
|
||||||
|
let result = '';
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = ASTERISK_RUN.exec(line)) !== null) {
|
||||||
|
result += line.slice(lastIndex, match.index);
|
||||||
|
const run = match[0];
|
||||||
|
|
||||||
|
if (run.length === 3 && stack.length === 2) {
|
||||||
|
// Ambiguous: split into [top-of-stack closer][sentinel][remaining closer]
|
||||||
|
const innerCloser = stack.pop()!; // most recently opened
|
||||||
|
const outerCloser = stack.pop()!; // closes after inner
|
||||||
|
result += innerCloser + SENTINEL + outerCloser;
|
||||||
|
} else if (run.length === 3) {
|
||||||
|
// *** as a single atomic bold-italic token (open or close,
|
||||||
|
// not part of an ambiguous nested pair) — pass through.
|
||||||
|
result += run;
|
||||||
|
} else if (stack.length > 0 && stack[stack.length - 1] === run) {
|
||||||
|
// Closing the most recently opened delimiter of this exact length
|
||||||
|
stack.pop();
|
||||||
|
result += run;
|
||||||
|
} else {
|
||||||
|
// Opening a new delimiter
|
||||||
|
stack.push(run as '*' | '**');
|
||||||
|
result += run;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + run.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += line.slice(lastIndex);
|
||||||
|
console.log(`DEBUG:\n input: ${line}\n output: ${result}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a single styled block <div> from one markdown line.
|
* Build a single styled block <div> from one markdown line.
|
||||||
* Classifies the line, wraps the block prefix in a .md-delim span,
|
* Classifies the line, wraps the block prefix in a .md-delim span,
|
||||||
|
|
@ -430,6 +484,7 @@ export class RibbitEditor extends Ribbit {
|
||||||
* this.#parseInline('hello **world** and `code`')
|
* this.#parseInline('hello **world** and `code`')
|
||||||
*/
|
*/
|
||||||
#parseInline(text: string): DocumentFragment {
|
#parseInline(text: string): DocumentFragment {
|
||||||
|
|
||||||
const classes: Record<string, string> = {
|
const classes: Record<string, string> = {
|
||||||
'***': 'md-bold-italic',
|
'***': 'md-bold-italic',
|
||||||
'**': 'md-bold',
|
'**': 'md-bold',
|
||||||
|
|
@ -451,6 +506,8 @@ export class RibbitEditor extends Ribbit {
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
text = this.#disambiguateAsteriskRuns(text);
|
||||||
|
|
||||||
while ((match = INLINE_PATTERN.exec(text)) !== null) {
|
while ((match = INLINE_PATTERN.exec(text)) !== null) {
|
||||||
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)));
|
||||||
|
|
|
||||||
|
|
@ -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})`
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -169,6 +173,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++;
|
||||||
|
|
@ -286,6 +293,60 @@ async function runTests() {
|
||||||
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();
|
||||||
|
assert(
|
||||||
|
markdown === testCase.markdown,
|
||||||
|
`Round-trip failed.\nExpected: "${testCase.markdown}"\nGot: "${markdown}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = await getHTML();
|
||||||
|
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`');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user