wip toolbar

This commit is contained in:
evilchili 2026-06-20 13:47:13 -07:00
parent 0758105e92
commit 9f03721f86
9 changed files with 1341 additions and 5713 deletions

View File

@ -14,16 +14,6 @@
#status { font-size: 12px; color: #666; margin-bottom: 10px; }
#revisions { margin-top: 20px; }
#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>
</head>
<body>

6841
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -32,5 +32,8 @@
"selenium-webdriver": "^4.43.0",
"ts-jest": "^29.4.9",
"typescript": "^6.0.3"
},
"dependencies": {
"bootstrap-icons": "^1.13.1"
}
}

View File

@ -34,7 +34,7 @@
*/
.md-delim {
display:inline;
display:none;
opacity: 0.3;
font-size: 0.85em;
font-weight: normal;
@ -46,20 +46,9 @@
background: #EEE;
}
.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 {
.ribbit-editing > .md-delim {
display: inline;
/*
font-weight: normal;
font-style: normal;
font-family: monospace;
font-size: 0.85em;
*/
opacity: 0.8;
}
/* List prefixes use a separate class so CSS can replace them with

View File

@ -0,0 +1 @@
../../../../node_modules/bootstrap-icons/icons

View File

@ -6,6 +6,11 @@
@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 {
text-decoration: none;
}
@ -50,3 +55,80 @@ code {
background: #EEE;
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;
}

View File

@ -335,6 +335,60 @@ export class RibbitEditor extends Ribbit {
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.
* 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`')
*/
#parseInline(text: string): DocumentFragment {
const classes: Record<string, string> = {
'***': 'md-bold-italic',
'**': 'md-bold',
@ -450,6 +505,8 @@ export class RibbitEditor extends Ribbit {
const fragment = document.createDocumentFragment();
let lastIndex = 0;
let match: RegExpExecArray | null;
text = this.#disambiguateAsteriskRuns(text);
while ((match = INLINE_PATTERN.exec(text)) !== null) {
if (match.index > lastIndex) {

View File

@ -25,7 +25,7 @@ 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 UTILITY_BUTTON_IDS = ['save', 'edit'];
const MAX_HEADING_LEVEL = 6;
@ -217,7 +217,7 @@ export class ToolbarManager {
action: 'custom',
handler: () => this.editor.save(),
});
this.register('toggle', {
this.register('edit', {
label: 'Edit',
shortcut: 'Ctrl+Shift+V',
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,
});
}
slots.push('', 'markdown', 'save', 'toggle');
slots.push('', 'save', 'edit');
return slots;
}
@ -430,7 +418,7 @@ export class ToolbarManager {
const listItem = document.createElement('li');
const buttonElement = document.createElement('button');
buttonElement.className = `ribbit-btn-${button.id}`;
buttonElement.textContent = button.label;
//buttonElement.textContent = button.label;
buttonElement.setAttribute('aria-label', button.label);
buttonElement.title = button.shortcut
? `${button.label} (${button.shortcut})`

View File

@ -4,29 +4,6 @@
<meta charset="utf-8">
<title>Ribbit Integration Test Page</title>
<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>
<body>
<main>