wip toolbar
This commit is contained in:
@@ -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
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../../node_modules/bootstrap-icons/icons
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+4
-16
@@ -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})`
|
||||
|
||||
Reference in New Issue
Block a user