Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95f6ffcf86 | ||
|
|
c1dc49f0b3 | ||
|
|
818ee418d5 | ||
|
|
bfc20f56bf | ||
|
|
24560a4d21 |
@@ -0,0 +1,161 @@
|
|||||||
|
# Styled Source Editor — Design Plan
|
||||||
|
|
||||||
|
## Core Concept
|
||||||
|
|
||||||
|
The editor is always a markdown text editor. There is no separate "WYSIWYG mode" —
|
||||||
|
the user edits markdown directly, but the editor applies CSS styling that makes it
|
||||||
|
look like rendered output. Delimiters (`**`, `*`, `` ` ``, etc.) are hidden when the
|
||||||
|
cursor is outside the element and revealed when the cursor enters it.
|
||||||
|
|
||||||
|
## Two CSS States (not modes)
|
||||||
|
|
||||||
|
- **Editing**: `contentEditable="true"`, delimiters revealed on cursor focus
|
||||||
|
- **Viewing**: `contentEditable="false"`, all delimiters hidden
|
||||||
|
|
||||||
|
No content transformation on state switch. The DOM is identical in both states —
|
||||||
|
only CSS changes. This eliminates all conversion-during-editing bugs.
|
||||||
|
|
||||||
|
## DOM Structure
|
||||||
|
|
||||||
|
The editor contains markdown text wrapped in styled spans:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div id="ribbit">
|
||||||
|
<div class="md-heading" data-level="2">
|
||||||
|
<span class="md-delim">## </span>Hello World
|
||||||
|
</div>
|
||||||
|
<div class="md-paragraph">
|
||||||
|
Some <span class="md-bold">
|
||||||
|
<span class="md-delim">**</span>bold<span class="md-delim">**</span>
|
||||||
|
</span> and <span class="md-italic">
|
||||||
|
<span class="md-delim">*</span>italic<span class="md-delim">*</span>
|
||||||
|
</span> text.
|
||||||
|
</div>
|
||||||
|
<div class="md-list-item">
|
||||||
|
<span class="md-delim">- </span>First item
|
||||||
|
</div>
|
||||||
|
<div class="md-blockquote">
|
||||||
|
<span class="md-delim">> </span>Quoted text
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
CSS handles all visual rendering:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.md-delim { display: none; color: #999; font-weight: normal; }
|
||||||
|
.md-bold.editing .md-delim,
|
||||||
|
.md-italic.editing .md-delim { display: inline; }
|
||||||
|
.md-bold { font-weight: bold; }
|
||||||
|
.md-italic { font-style: italic; }
|
||||||
|
.md-heading[data-level="1"] { font-size: 2em; font-weight: bold; }
|
||||||
|
.md-list-item { display: list-item; margin-left: 1.5em; }
|
||||||
|
.md-blockquote { border-left: 3px solid #ccc; padding-left: 1em; }
|
||||||
|
.md-code { font-family: monospace; background: #f5f5f5; }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Per-Keystroke Pipeline
|
||||||
|
|
||||||
|
1. User types a character → browser inserts it into the DOM (contentEditable)
|
||||||
|
2. `input` event fires
|
||||||
|
3. Parser scans the **current line only** (the block element containing the cursor)
|
||||||
|
4. If the span structure needs updating (e.g. user just typed the closing `**`):
|
||||||
|
- Wrap/unwrap the affected text range using targeted DOM operations
|
||||||
|
- No innerHTML rebuild, no full-document re-parse
|
||||||
|
5. If a block pattern is detected (e.g. `# ` at start of line):
|
||||||
|
- Update the block element's class and data attributes
|
||||||
|
- Move the delimiter text into a `.md-delim` span
|
||||||
|
|
||||||
|
## Key Operations
|
||||||
|
|
||||||
|
### Inline formatting detection
|
||||||
|
When the user types a delimiter character, scan backward in the current
|
||||||
|
text node for a matching opener. If found, wrap the range:
|
||||||
|
|
||||||
|
```
|
||||||
|
Before: <span class="md-paragraph">hello **world**</span>
|
||||||
|
After: <span class="md-paragraph">hello <span class="md-bold">
|
||||||
|
<span class="md-delim">**</span>world<span class="md-delim">**</span>
|
||||||
|
</span></span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `Range` and `surroundContents` for the wrap — no innerHTML.
|
||||||
|
|
||||||
|
### Block detection
|
||||||
|
When the user types a space after `#`, `>`, `-`, `1.`, etc. at the start
|
||||||
|
of a line, update the block element:
|
||||||
|
|
||||||
|
```
|
||||||
|
Before: <div class="md-paragraph"># Title</div>
|
||||||
|
After: <div class="md-heading" data-level="1">
|
||||||
|
<span class="md-delim"># </span>Title
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cursor focus tracking
|
||||||
|
On `selectionchange`, find the nearest formatting span and add an
|
||||||
|
`.editing` class so CSS reveals its delimiters. Remove `.editing`
|
||||||
|
from the previous span.
|
||||||
|
|
||||||
|
## getMarkdown()
|
||||||
|
|
||||||
|
Read `textContent` from the editor element. The delimiter spans contain
|
||||||
|
the actual delimiter characters, so `textContent` produces valid markdown.
|
||||||
|
No conversion needed.
|
||||||
|
|
||||||
|
## getHTML()
|
||||||
|
|
||||||
|
Run the existing tokenizer + `toHTML` pipeline on the markdown string
|
||||||
|
from `getMarkdown()`. This is only called on demand (export, save, API),
|
||||||
|
never during editing.
|
||||||
|
|
||||||
|
## Macros
|
||||||
|
|
||||||
|
Macros are rendered as `contentEditable="false"` islands within the
|
||||||
|
editable text. The macro source (`@user`) is stored in a `data-source`
|
||||||
|
attribute. The rendered output is displayed inside the island. On focus,
|
||||||
|
the island could expand to show the source for editing.
|
||||||
|
|
||||||
|
For `toMarkdown`, macro islands emit their `data-source` value.
|
||||||
|
|
||||||
|
## Initial Load
|
||||||
|
|
||||||
|
Markdown → styled source DOM is a one-time conversion on editor init:
|
||||||
|
|
||||||
|
1. Parse markdown using the existing tokenizer (produces token stream)
|
||||||
|
2. Walk the token stream, creating the span structure described above
|
||||||
|
3. Set the editor's innerHTML once
|
||||||
|
|
||||||
|
This replaces the current `toHTML` → innerHTML path.
|
||||||
|
|
||||||
|
## What This Eliminates
|
||||||
|
|
||||||
|
- `transformInline` and its innerHTML rebuild
|
||||||
|
- `blockToMarkdown` / `nodeToMarkdown` (DOM → markdown string → DOM)
|
||||||
|
- The flatten-rebuild pipeline and all its escaping bugs
|
||||||
|
- The `<br>` + ZWS cursor anchor workarounds
|
||||||
|
- The sentinel marker system for preserved HTML elements
|
||||||
|
- Mode switch conversions (WYSIWYG ↔ view ↔ edit)
|
||||||
|
|
||||||
|
## What This Keeps
|
||||||
|
|
||||||
|
- The tokenizer (for initial load and `getHTML()`)
|
||||||
|
- The serializer (for `getHTML()` via `toMarkdown` → `toHTML`)
|
||||||
|
- Tag definitions (for block pattern matching and toolbar buttons)
|
||||||
|
- The `BaseTag` keyboard dispatch system
|
||||||
|
- The collaboration transport layer
|
||||||
|
- The macro system
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Build the markdown → styled DOM renderer (replaces `toHTML` for editor init)
|
||||||
|
2. Build the per-line parser that updates span structure on keystroke
|
||||||
|
3. Build the inline delimiter detection (wrap/unwrap via Range)
|
||||||
|
4. Wire up cursor focus tracking for delimiter reveal
|
||||||
|
5. Implement `getMarkdown()` as `textContent` read
|
||||||
|
6. Remove `transformInline`, `blockToMarkdown`, and the rebuild pipeline
|
||||||
|
7. Update tests
|
||||||
|
|
||||||
|
## Branch
|
||||||
|
|
||||||
|
Work on the `styled-source` branch, branched from current `main`.
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ module.exports = {
|
|||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
roots: ['<rootDir>/test'],
|
roots: ['<rootDir>/test'],
|
||||||
testPathIgnorePatterns: ['/node_modules/', '/test/integration/'],
|
testPathIgnorePatterns: ['/node_modules/', '/test/integration/'],
|
||||||
moduleNameMapper: {
|
|
||||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
|
||||||
},
|
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.tsx?$': ['ts-jest', {
|
'^.+\\.tsx?$': ['ts-jest', {
|
||||||
tsconfig: {
|
tsconfig: {
|
||||||
|
|||||||
Generated
+3247
-3373
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -14,6 +14,7 @@
|
|||||||
"build:core": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit-core.js",
|
"build:core": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit-core.js",
|
||||||
"build:core-min": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit-core.min.js",
|
"build:core-min": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit-core.min.js",
|
||||||
"build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/",
|
"build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/",
|
||||||
|
"dev": "npm run build && node test/integration/dev-server.js",
|
||||||
"test": "npm run build && jest --verbose",
|
"test": "npm run build && jest --verbose",
|
||||||
"test:integration": "npm run build && node test/integration/test.js && node test/integration/test_wysiwyg.js",
|
"test:integration": "npm run build && node test/integration/test.js && node test/integration/test_wysiwyg.js",
|
||||||
"test:coverage": "npm run build && jest --coverage"
|
"test:coverage": "npm run build && jest --coverage"
|
||||||
@@ -23,10 +24,16 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
"happy-dom": "^14.12.3",
|
"happy-dom": "^20.9.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
"live-server": "^1.2.0",
|
||||||
|
"node-watch": "^0.7.4",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './ts';
|
||||||
+128
-46
@@ -1,9 +1,18 @@
|
|||||||
/*
|
/*
|
||||||
* ribbit-core.css — functional editor styles. Always load this.
|
* ribbit-core.css — functional editor styles. Always load this.
|
||||||
* These styles control editor state visibility and behavior.
|
*
|
||||||
* They should not be overridden by themes.
|
* These styles control editor state visibility and the styled-source
|
||||||
|
* rendering. They should not be overridden by themes.
|
||||||
|
*
|
||||||
|
* Two CSS states (not modes):
|
||||||
|
* .wysiwyg — contentEditable, delimiters revealed on cursor focus
|
||||||
|
* .view — read-only, all delimiters hidden, full block styling
|
||||||
|
*
|
||||||
|
* The DOM is identical in both states; only CSS changes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* ── Visibility ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
#ribbit {
|
#ribbit {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -12,54 +21,127 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ribbit.edit {
|
/* ── Delimiter visibility ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Delimiters are always present in the DOM as text nodes inside
|
||||||
|
* .md-delim spans. In view state they are hidden; in wysiwyg state
|
||||||
|
* they are hidden by default and revealed only for the span the
|
||||||
|
* cursor is currently inside (.ribbit-editing).
|
||||||
|
*
|
||||||
|
* This means getMarkdown() = element.textContent at all times —
|
||||||
|
* no conversion is needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.md-delim {
|
||||||
|
display:inline;
|
||||||
|
opacity: 0.3;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ribbit-editing {
|
||||||
|
background: #EEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ribbit-editing > .md-delim {
|
||||||
|
display: inline;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List prefixes use a separate class so CSS can replace them with
|
||||||
|
real list bullets in view state while keeping them in textContent */
|
||||||
|
.md-list-prefix {
|
||||||
|
display: inline;
|
||||||
|
opacity: 0.8;
|
||||||
|
/*
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.view .md-list-prefix {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inline formatting ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.md-bold,
|
||||||
|
.md-bold-italic {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-italic,
|
||||||
|
.md-bold-italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-code {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-link-text {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Block-level styling ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Block divs use .md-{name} classes. In view state they render as
|
||||||
|
* their visual equivalents. In wysiwyg state they use monospace so
|
||||||
|
* the user can see the raw markdown while the formatting is applied.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ribbit.wysiwyg {
|
||||||
|
/* white-space: pre-wrap; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-h1 { font-size: 2em; font-weight: bold; }
|
||||||
|
.md-h2 { font-size: 1.5em; font-weight: bold; }
|
||||||
|
.md-h3 { font-size: 1.17em; font-weight: bold; }
|
||||||
|
.md-h4 { font-size: 1em; font-weight: bold; }
|
||||||
|
.md-h5 { font-size: 0.83em; font-weight: bold; }
|
||||||
|
.md-h6 { font-size: 0.67em; font-weight: bold; }
|
||||||
|
|
||||||
|
.md-blockquote {
|
||||||
|
border-left: 3px solid currentColor;
|
||||||
|
opacity: 0.7;
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* List items: in wysiwyg state the .md-list-prefix span shows the
|
||||||
|
* raw markdown marker ("- " or "1. "). In view state we hide the
|
||||||
|
* prefix and use display:list-item to get a real browser bullet.
|
||||||
|
*/
|
||||||
|
#ribbit.view .md-list-item {
|
||||||
|
display: list-item;
|
||||||
|
margin-left: 1.5em;
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.view .md-ol-list-item {
|
||||||
|
display: list-item;
|
||||||
|
margin-left: 1.5em;
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-pre {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ribbit.wysiwyg .md {
|
/* ── Vim mode indicators ────────────────────────────────────────────────────── */
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ribbit-editing::before,
|
|
||||||
.ribbit-editing::after {
|
|
||||||
opacity: 0.3;
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-speculative]::before,
|
|
||||||
[data-speculative]::after {
|
|
||||||
content: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ribbit.wysiwyg strong.ribbit-editing::before,
|
|
||||||
#ribbit.wysiwyg strong.ribbit-editing::after {
|
|
||||||
content: "**";
|
|
||||||
}
|
|
||||||
|
|
||||||
#ribbit.wysiwyg em.ribbit-editing::before,
|
|
||||||
#ribbit.wysiwyg em.ribbit-editing::after {
|
|
||||||
content: "*";
|
|
||||||
}
|
|
||||||
|
|
||||||
#ribbit.wysiwyg code.ribbit-editing::before,
|
|
||||||
#ribbit.wysiwyg code.ribbit-editing::after {
|
|
||||||
content: "\`";
|
|
||||||
}
|
|
||||||
|
|
||||||
#ribbit.wysiwyg h1.ribbit-editing::before { content: "# "; font-size: 0.5em; }
|
|
||||||
#ribbit.wysiwyg h2.ribbit-editing::before { content: "## "; font-size: 0.5em; }
|
|
||||||
#ribbit.wysiwyg h3.ribbit-editing::before { content: "### "; font-size: 0.5em; }
|
|
||||||
#ribbit.wysiwyg h4.ribbit-editing::before { content: "#### "; font-size: 0.5em; }
|
|
||||||
#ribbit.wysiwyg h5.ribbit-editing::before { content: "##### "; font-size: 0.5em; }
|
|
||||||
#ribbit.wysiwyg h6.ribbit-editing::before { content: "###### "; font-size: 0.5em; }
|
|
||||||
|
|
||||||
#ribbit.wysiwyg blockquote.ribbit-editing::before {
|
|
||||||
content: "> ";
|
|
||||||
}
|
|
||||||
|
|
||||||
#ribbit.vim-normal {
|
#ribbit.vim-normal {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|||||||
+1
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./ribbit";
|
||||||
|
export * from "./hopdown";
|
||||||
+755
-694
File diff suppressed because it is too large
Load Diff
+54
-47
@@ -38,8 +38,6 @@ export class Ribbit {
|
|||||||
api: unknown;
|
api: unknown;
|
||||||
element: HTMLElement;
|
element: HTMLElement;
|
||||||
states: Record<string, string>;
|
states: Record<string, string>;
|
||||||
cachedHTML: string | null;
|
|
||||||
cachedMarkdown: string | null;
|
|
||||||
state: string | null;
|
state: string | null;
|
||||||
theme: RibbitTheme;
|
theme: RibbitTheme;
|
||||||
themes: ThemeManager;
|
themes: ThemeManager;
|
||||||
@@ -51,6 +49,12 @@ export class Ribbit {
|
|||||||
private emitter: RibbitEmitter;
|
private emitter: RibbitEmitter;
|
||||||
private macros: MacroDef[];
|
private macros: MacroDef[];
|
||||||
|
|
||||||
|
// The markdown source as it existed before view() rendered it to HTML.
|
||||||
|
// Set by subclasses (RibbitEditor) before overwriting element.innerHTML.
|
||||||
|
// Allows getMarkdown() in view state to return the original source rather
|
||||||
|
// than textContent of the rendered HTML (which strips delimiters).
|
||||||
|
protected sourceMarkdown: string | null = null;
|
||||||
|
|
||||||
constructor(settings: RibbitSettings) {
|
constructor(settings: RibbitSettings) {
|
||||||
this.api = settings.api || null;
|
this.api = settings.api || null;
|
||||||
this.element = document.getElementById(settings.editorId || 'ribbit')!;
|
this.element = document.getElementById(settings.editorId || 'ribbit')!;
|
||||||
@@ -60,8 +64,6 @@ export class Ribbit {
|
|||||||
this.states = {
|
this.states = {
|
||||||
VIEW: 'view',
|
VIEW: 'view',
|
||||||
};
|
};
|
||||||
this.cachedHTML = null;
|
|
||||||
this.cachedMarkdown = null;
|
|
||||||
this.state = null;
|
this.state = null;
|
||||||
|
|
||||||
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
|
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
|
||||||
@@ -69,7 +71,6 @@ export class Ribbit {
|
|||||||
this.converter = theme.tags
|
this.converter = theme.tags
|
||||||
? new HopDown({ tags: theme.tags, macros: this.macros })
|
? new HopDown({ tags: theme.tags, macros: this.macros })
|
||||||
: new HopDown({ macros: this.macros });
|
: new HopDown({ macros: this.macros });
|
||||||
this.cachedHTML = null;
|
|
||||||
this.emitter.emit('themeChange', {
|
this.emitter.emit('themeChange', {
|
||||||
current: theme,
|
current: theme,
|
||||||
previous,
|
previous,
|
||||||
@@ -112,14 +113,13 @@ export class Ribbit {
|
|||||||
settings.collaboration,
|
settings.collaboration,
|
||||||
{
|
{
|
||||||
onRemoteUpdate: (content) => {
|
onRemoteUpdate: (content) => {
|
||||||
this.cachedMarkdown = content;
|
this.sourceMarkdown = content;
|
||||||
this.cachedHTML = null;
|
|
||||||
if (this.getState() !== this.states.VIEW) {
|
if (this.getState() !== this.states.VIEW) {
|
||||||
this.element.innerHTML = this.getHTML();
|
this.element.innerHTML = this.markdownToHTML(content);
|
||||||
}
|
}
|
||||||
this.emitter.emit('change', {
|
this.emitter.emit('change', {
|
||||||
markdown: content,
|
markdown: content,
|
||||||
html: this.getHTML(),
|
html: this.markdownToHTML(content),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onPeersChange: (peers) => {
|
onPeersChange: (peers) => {
|
||||||
@@ -188,7 +188,7 @@ export class Ribbit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current mode name ('view', 'edit', or 'wysiwyg').
|
* Current mode name ('view' or 'wysiwyg').
|
||||||
*
|
*
|
||||||
* if (editor.getState() === 'wysiwyg') { ... }
|
* if (editor.getState() === 'wysiwyg') { ... }
|
||||||
*/
|
*/
|
||||||
@@ -200,7 +200,7 @@ export class Ribbit {
|
|||||||
* Transition to a new mode. Updates CSS classes on the editor element
|
* Transition to a new mode. Updates CSS classes on the editor element
|
||||||
* so themes can style each mode differently, and fires modeChange.
|
* so themes can style each mode differently, and fires modeChange.
|
||||||
*
|
*
|
||||||
* editor.setState('edit');
|
* editor.setState('wysiwyg');
|
||||||
*/
|
*/
|
||||||
setState(newState: string): void {
|
setState(newState: string): void {
|
||||||
const previous = this.state;
|
const previous = this.state;
|
||||||
@@ -225,28 +225,26 @@ export class Ribbit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rendered HTML of the current content, cached until invalidated.
|
* Rendered HTML of the current content.
|
||||||
*
|
*
|
||||||
* document.getElementById('preview').innerHTML = viewer.getHTML();
|
* document.getElementById('preview').innerHTML = viewer.getHTML();
|
||||||
*/
|
*/
|
||||||
getHTML(): string {
|
getHTML(): string {
|
||||||
if (this.cachedHTML === null) {
|
return this.markdownToHTML(this.getMarkdown());
|
||||||
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
|
|
||||||
}
|
|
||||||
return this.cachedHTML;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw markdown of the current content. In view mode this is the
|
* Raw markdown of the current content. In view state reads from
|
||||||
* original text; in edit/wysiwyg mode it's derived from the DOM.
|
* sourceMarkdown if set (preserved before rendering overwrote the
|
||||||
|
* element), otherwise falls back to element.textContent.
|
||||||
*
|
*
|
||||||
* fetch('/save', { body: editor.getMarkdown() });
|
* fetch('/save', { body: editor.getMarkdown() });
|
||||||
*/
|
*/
|
||||||
getMarkdown(): string {
|
getMarkdown(): string {
|
||||||
if (this.cachedMarkdown === null) {
|
if (this.sourceMarkdown !== null) {
|
||||||
this.cachedMarkdown = this.element.textContent || '';
|
return this.sourceMarkdown;
|
||||||
}
|
}
|
||||||
return this.cachedMarkdown;
|
return this.element.textContent || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -270,25 +268,20 @@ export class Ribbit {
|
|||||||
* editor.view();
|
* editor.view();
|
||||||
*/
|
*/
|
||||||
view(): void {
|
view(): void {
|
||||||
if (this.getState() === this.states.VIEW) return;
|
if (this.getState() === this.states.VIEW) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Capture markdown before overwriting the element with rendered HTML.
|
||||||
|
// getMarkdown() on the base class reads element.textContent when
|
||||||
|
// sourceMarkdown is null — correct for the initial load case where
|
||||||
|
// the element contains raw markdown text.
|
||||||
|
this.sourceMarkdown = this.getMarkdown();
|
||||||
this.collaboration?.disconnect();
|
this.collaboration?.disconnect();
|
||||||
this.element.innerHTML = this.getHTML();
|
this.element.innerHTML = this.markdownToHTML(this.sourceMarkdown);
|
||||||
this.setState(this.states.VIEW);
|
this.setState(this.states.VIEW);
|
||||||
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 {
|
|
||||||
this.cachedMarkdown = null;
|
|
||||||
this.cachedHTML = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request an advisory editing lock. Returns false if another user
|
* Request an advisory editing lock. Returns false if another user
|
||||||
* holds the lock. Requires a collaboration transport.
|
* holds the lock. Requires a collaboration transport.
|
||||||
@@ -296,7 +289,9 @@ export class Ribbit {
|
|||||||
* if (await editor.lockForEditing()) { editor.wysiwyg(); }
|
* 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +312,9 @@ export class Ribbit {
|
|||||||
* await editor.forceLockEditing();
|
* 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +325,9 @@ export class Ribbit {
|
|||||||
* revisions.forEach(r => console.log(r.id, r.timestamp));
|
* 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +338,9 @@ export class Ribbit {
|
|||||||
* if (rev) { console.log(rev.content); }
|
* 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,18 +351,22 @@ export class Ribbit {
|
|||||||
* await editor.restoreRevision('abc-123');
|
* 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);
|
||||||
if (!revision) return;
|
if (!revision) {
|
||||||
this.cachedMarkdown = revision.content;
|
return;
|
||||||
this.cachedHTML = this.markdownToHTML(revision.content);
|
}
|
||||||
|
this.sourceMarkdown = revision.content;
|
||||||
|
const html = this.markdownToHTML(revision.content);
|
||||||
this.collaboration.sendUpdate(revision.content);
|
this.collaboration.sendUpdate(revision.content);
|
||||||
if (this.getState() !== this.states.VIEW) {
|
if (this.getState() !== this.states.VIEW) {
|
||||||
this.element.innerHTML = this.cachedHTML;
|
this.element.innerHTML = html;
|
||||||
}
|
}
|
||||||
this.emitter.emit('change', {
|
this.emitter.emit('change', {
|
||||||
markdown: revision.content,
|
markdown: revision.content,
|
||||||
html: this.cachedHTML,
|
html,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +377,9 @@ export class Ribbit {
|
|||||||
* const rev = await editor.createRevision({ label: 'v1.0' });
|
* 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);
|
||||||
if (revision) {
|
if (revision) {
|
||||||
this.emitter.emit('revisionCreated', { revision });
|
this.emitter.emit('revisionCreated', { revision });
|
||||||
@@ -426,7 +433,7 @@ export function decodeHtmlEntities(html: string): string {
|
|||||||
/**
|
/**
|
||||||
* Encode characters that would be interpreted as HTML into numeric
|
* Encode characters that would be interpreted as HTML into numeric
|
||||||
* entities. Used when displaying raw markdown in contentEditable
|
* entities. Used when displaying raw markdown in contentEditable
|
||||||
* (edit mode) so the browser doesn't parse it as markup.
|
* so the browser doesn't parse it as markup.
|
||||||
*
|
*
|
||||||
* encodeHtmlEntities('<b>hi</b>') // '<b>hi</b>'
|
* encodeHtmlEntities('<b>hi</b>') // '<b>hi</b>'
|
||||||
*/
|
*/
|
||||||
|
|||||||
+505
-10
@@ -132,13 +132,219 @@ export function inlineTag(def: InlineTagDef): Tag {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for block-level tags. Provides keyboard event dispatch
|
||||||
|
* for WYSIWYG mode: subclasses populate `eventHandlers` with named
|
||||||
|
* handlers (e.g. 'onEnter', 'onBackspace'), and `handleKeydown`
|
||||||
|
* routes events to the matching handler.
|
||||||
|
*
|
||||||
|
* class MyTag extends BaseTag implements Tag {
|
||||||
|
* eventHandlers = { 'onEnter': this.onEnter };
|
||||||
|
* private onEnter(element: HTMLElement, selection: Selection): boolean { ... }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
class BaseTag {
|
||||||
|
eventHandlers: Record<string, (element: HTMLElement, selection: Selection, editor: any) => boolean> = {};
|
||||||
|
|
||||||
|
handleKeydown(element: HTMLElement, event: KeyboardEvent, selection: Selection, editor: any): boolean {
|
||||||
|
const handlerName = 'on' + event.key;
|
||||||
|
const handler = this.eventHandlers[handlerName];
|
||||||
|
if (handler) {
|
||||||
|
return handler.call(this, element, selection, editor);
|
||||||
|
}
|
||||||
|
// Default Enter behavior: insert <br> for single Enter,
|
||||||
|
// exit block for double Enter (empty line after <br>)
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
return this.defaultOnEnter(element, selection, editor);
|
||||||
|
}
|
||||||
|
if (event.key === 'Backspace') {
|
||||||
|
return this.defaultOnBackspace(element, selection);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default Enter handler for all block tags. Inserts a <br> for
|
||||||
|
* line continuation. If the cursor is on an empty line (right
|
||||||
|
* after a <br> with no content following), removes the trailing
|
||||||
|
* <br> and creates a new <p> after the current block.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Default Backspace handler. When the cursor is at the start of
|
||||||
|
* a line after a <br> (i.e. on the ZWS cursor anchor), remove
|
||||||
|
* the <br> and ZWS to join the lines. Otherwise let the browser
|
||||||
|
* handle it.
|
||||||
|
*/
|
||||||
|
private defaultOnBackspace(element: HTMLElement, selection: Selection): boolean {
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const container = range.startContainer;
|
||||||
|
const offset = range.startOffset;
|
||||||
|
|
||||||
|
if (container.nodeType !== 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeroWidthSpace = /\u200B/g;
|
||||||
|
const textBefore = (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '');
|
||||||
|
|
||||||
|
// Only intercept if cursor is at the start of the line
|
||||||
|
// (nothing but ZWS before the cursor in this text node)
|
||||||
|
if (textBefore !== '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk back past empty text nodes to find a <br>
|
||||||
|
let previous: Node | null = container.previousSibling;
|
||||||
|
while (previous && previous.nodeType === 3
|
||||||
|
&& (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
|
||||||
|
previous = previous.previousSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previous || previous.nodeType !== 1 || (previous as HTMLElement).tagName !== 'BR') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the <br> and any ZWS/empty text nodes between it
|
||||||
|
// and the cursor's text node
|
||||||
|
let nodeToRemove: Node | null = container.previousSibling;
|
||||||
|
while (nodeToRemove && nodeToRemove !== previous) {
|
||||||
|
const prev = nodeToRemove.previousSibling;
|
||||||
|
nodeToRemove.parentNode?.removeChild(nodeToRemove);
|
||||||
|
nodeToRemove = prev;
|
||||||
|
}
|
||||||
|
previous.parentNode?.removeChild(previous);
|
||||||
|
|
||||||
|
// Remove the ZWS from the cursor's text node
|
||||||
|
if (container.textContent?.replace(zeroWidthSpace, '') === '') {
|
||||||
|
// Text node is only ZWS — remove it entirely
|
||||||
|
const nextSibling = container.nextSibling;
|
||||||
|
const parentNode = container.parentNode;
|
||||||
|
container.parentNode?.removeChild(container);
|
||||||
|
// Place cursor at end of previous text node
|
||||||
|
const prevText = parentNode?.lastChild;
|
||||||
|
if (prevText && prevText.nodeType === 3) {
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStart(prevText, prevText.textContent?.length || 0);
|
||||||
|
newRange.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(newRange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private defaultOnEnter(element: HTMLElement, selection: Selection, _editor: any): boolean {
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const container = range.startContainer;
|
||||||
|
const offset = range.startOffset;
|
||||||
|
|
||||||
|
// Detect empty line: cursor is at a position where the
|
||||||
|
// previous sibling is a <br> and there's no text after it
|
||||||
|
const zeroWidthSpace = /\u200B/g;
|
||||||
|
let lineIsEmpty = false;
|
||||||
|
|
||||||
|
if (container.nodeType === 3) {
|
||||||
|
const textBefore = (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '').trim();
|
||||||
|
const textAfter = (container.textContent || '').slice(offset).replace(zeroWidthSpace, '').trim();
|
||||||
|
if (textBefore === '' && textAfter === '') {
|
||||||
|
// Walk back past empty text nodes to find a <br>
|
||||||
|
let previous: Node | null = container.previousSibling;
|
||||||
|
while (previous && previous.nodeType === 3
|
||||||
|
&& (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
|
||||||
|
previous = previous.previousSibling;
|
||||||
|
}
|
||||||
|
if (previous && previous.nodeType === 1 && (previous as HTMLElement).tagName === 'BR') {
|
||||||
|
lineIsEmpty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (container.nodeType === 1) {
|
||||||
|
const childAtCursor = (container as HTMLElement).childNodes[offset - 1];
|
||||||
|
if (childAtCursor && childAtCursor.nodeType === 1 && (childAtCursor as HTMLElement).tagName === 'BR') {
|
||||||
|
// Only treat as empty line if there's real content before
|
||||||
|
// the <br> — an empty paragraph's placeholder <br> doesn't count
|
||||||
|
const textBefore = Array.from((container as HTMLElement).childNodes)
|
||||||
|
.slice(0, offset - 1)
|
||||||
|
.map(node => node.textContent || '')
|
||||||
|
.join('')
|
||||||
|
.replace(zeroWidthSpace, '')
|
||||||
|
.trim();
|
||||||
|
const textAfter = Array.from((container as HTMLElement).childNodes)
|
||||||
|
.slice(offset)
|
||||||
|
.map(node => node.textContent || '')
|
||||||
|
.join('')
|
||||||
|
.replace(zeroWidthSpace, '')
|
||||||
|
.trim();
|
||||||
|
if (textAfter === '' && textBefore !== '') {
|
||||||
|
lineIsEmpty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineIsEmpty) {
|
||||||
|
// Double Enter: remove the trailing <br>, any empty text
|
||||||
|
// nodes after it, and the cursor's text node
|
||||||
|
let nodeToRemove: Node | null = container.nodeType === 3
|
||||||
|
? container.previousSibling
|
||||||
|
: (container as HTMLElement).childNodes[offset - 1];
|
||||||
|
// Walk past empty text nodes to find the <br>
|
||||||
|
while (nodeToRemove && nodeToRemove.nodeType === 3
|
||||||
|
&& (nodeToRemove.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
|
||||||
|
const previous = nodeToRemove.previousSibling;
|
||||||
|
nodeToRemove.parentNode?.removeChild(nodeToRemove);
|
||||||
|
nodeToRemove = previous;
|
||||||
|
}
|
||||||
|
// Remove the <br> itself
|
||||||
|
if (nodeToRemove && nodeToRemove.nodeType === 1
|
||||||
|
&& (nodeToRemove as HTMLElement).tagName === 'BR') {
|
||||||
|
nodeToRemove.parentNode?.removeChild(nodeToRemove);
|
||||||
|
}
|
||||||
|
// Remove the cursor's empty text node
|
||||||
|
if (container.nodeType === 3
|
||||||
|
&& container.textContent?.replace(zeroWidthSpace, '').trim() === '') {
|
||||||
|
container.parentNode?.removeChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newParagraph = document.createElement('p');
|
||||||
|
newParagraph.innerHTML = '<br>';
|
||||||
|
// Find the top-level block to insert after
|
||||||
|
let block: Node = element;
|
||||||
|
while (block.parentNode && block.parentNode !== _editor.element) {
|
||||||
|
block = block.parentNode;
|
||||||
|
}
|
||||||
|
(block as HTMLElement).after(newParagraph);
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStart(newParagraph, 0);
|
||||||
|
newRange.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(newRange);
|
||||||
|
} else {
|
||||||
|
// Single Enter: insert <br> followed by a zero-width space.
|
||||||
|
// The ZWS gives the browser a text node to place the cursor
|
||||||
|
// in — without it, Firefox removes the <br> when the user
|
||||||
|
// types the next character.
|
||||||
|
const brElement = document.createElement('br');
|
||||||
|
const cursorAnchor = document.createTextNode('\u200B');
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(cursorAnchor);
|
||||||
|
range.insertNode(brElement);
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStart(cursorAnchor, 1);
|
||||||
|
newRange.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(newRange);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fenced code blocks: lines between ``` delimiters become <pre><code>.
|
* Fenced code blocks: lines between ``` delimiters become <pre><code>.
|
||||||
*
|
*
|
||||||
* converter.toHTML('```js\nlet x = 1;\n```')
|
* converter.toHTML('```js\nlet x = 1;\n```')
|
||||||
* // <pre><code class="language-js">let x = 1;</code></pre>
|
* // <pre><code class="language-js">let x = 1;</code></pre>
|
||||||
*/
|
*/
|
||||||
class FencedCodeTag implements Tag {
|
class FencedCodeTag extends BaseTag implements Tag {
|
||||||
name = 'fencedCode';
|
name = 'fencedCode';
|
||||||
selector = 'PRE';
|
selector = 'PRE';
|
||||||
button = {
|
button = {
|
||||||
@@ -192,7 +398,7 @@ class FencedCodeTag implements Tag {
|
|||||||
*
|
*
|
||||||
* converter.toHTML('---') // '<hr>'
|
* converter.toHTML('---') // '<hr>'
|
||||||
*/
|
*/
|
||||||
class HorizontalRuleTag implements Tag {
|
class HorizontalRuleTag extends BaseTag implements Tag {
|
||||||
name = 'hr';
|
name = 'hr';
|
||||||
selector = 'HR';
|
selector = 'HR';
|
||||||
button = {
|
button = {
|
||||||
@@ -229,9 +435,12 @@ class HorizontalRuleTag implements Tag {
|
|||||||
*
|
*
|
||||||
* converter.toHTML('## Hello') // <h2 id='Hello'>Hello</h2>
|
* converter.toHTML('## Hello') // <h2 id='Hello'>Hello</h2>
|
||||||
*/
|
*/
|
||||||
class HeadingTag implements Tag {
|
class HeadingTag extends BaseTag implements Tag {
|
||||||
name = 'heading';
|
name = 'heading';
|
||||||
selector = 'H1,H2,H3,H4,H5,H6';
|
selector = 'H1,H2,H3,H4,H5,H6';
|
||||||
|
eventHandlers = {
|
||||||
|
'onEnter': this.onEnter,
|
||||||
|
};
|
||||||
button = {
|
button = {
|
||||||
show: false,
|
show: false,
|
||||||
label: 'Heading',
|
label: 'Heading',
|
||||||
@@ -287,6 +496,22 @@ class HeadingTag implements Tag {
|
|||||||
* Generate a PascalCase anchor ID from heading text so that
|
* Generate a PascalCase anchor ID from heading text so that
|
||||||
* in-page links like #MyHeading work without manual IDs.
|
* in-page links like #MyHeading work without manual IDs.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Headings always exit on Enter — you don't continue a heading
|
||||||
|
* across multiple lines.
|
||||||
|
*/
|
||||||
|
private onEnter(heading: HTMLElement, selection: Selection, _editor: any): boolean {
|
||||||
|
const newParagraph = document.createElement('p');
|
||||||
|
newParagraph.innerHTML = '<br>';
|
||||||
|
heading.after(newParagraph);
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(newParagraph, 0);
|
||||||
|
range.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private anchorId(text: string): string {
|
private anchorId(text: string): string {
|
||||||
return text.trim().split(/\s+/).map(word =>
|
return text.trim().split(/\s+/).map(word =>
|
||||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||||
@@ -301,7 +526,7 @@ class HeadingTag implements Tag {
|
|||||||
* converter.toHTML('> hello\n> world')
|
* converter.toHTML('> hello\n> world')
|
||||||
* // <blockquote><p>hello\nworld</p></blockquote>
|
* // <blockquote><p>hello\nworld</p></blockquote>
|
||||||
*/
|
*/
|
||||||
class BlockquoteTag implements Tag {
|
class BlockquoteTag extends BaseTag implements Tag {
|
||||||
name = 'blockquote';
|
name = 'blockquote';
|
||||||
selector = 'BLOCKQUOTE';
|
selector = 'BLOCKQUOTE';
|
||||||
button = {
|
button = {
|
||||||
@@ -311,6 +536,9 @@ class BlockquoteTag implements Tag {
|
|||||||
};
|
};
|
||||||
template = '> Quote\n> continues here';
|
template = '> Quote\n> continues here';
|
||||||
replaceSelection = true;
|
replaceSelection = true;
|
||||||
|
eventHandlers = {
|
||||||
|
'onEnter': this.onEnter,
|
||||||
|
};
|
||||||
|
|
||||||
match(context: MatchContext): SourceToken | null {
|
match(context: MatchContext): SourceToken | null {
|
||||||
const quotePrefix = /^>\s?/;
|
const quotePrefix = /^>\s?/;
|
||||||
@@ -330,12 +558,153 @@ class BlockquoteTag implements Tag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toHTML(token: SourceToken, convert: Converter): string {
|
toHTML(token: SourceToken, convert: Converter): string {
|
||||||
return '<blockquote>' + convert.block(token.content) + '</blockquote>';
|
// Within a blockquote, consecutive lines without a blank line
|
||||||
|
// between them should produce <br> line breaks (not merge into
|
||||||
|
// one paragraph). Insert hard break markers before block parsing
|
||||||
|
// so the inline processor creates <br> elements.
|
||||||
|
const withHardBreaks = token.content.replace(
|
||||||
|
/([^\n])\n(?!\n)/g, // \n not followed by another \n
|
||||||
|
'$1 \n' // trailing two spaces = hard break
|
||||||
|
);
|
||||||
|
return '<blockquote>' + convert.block(withHardBreaks) + '</blockquote>';
|
||||||
}
|
}
|
||||||
|
|
||||||
toMarkdown(element: HTMLElement, convert: Converter): string {
|
toMarkdown(element: HTMLElement, convert: Converter): string {
|
||||||
const lines = convert.children(element).trim().split('\n');
|
// Each <p> inside the blockquote is a paragraph group.
|
||||||
return '\n\n' + lines.map(line => '> ' + line).join('\n') + '\n\n';
|
// Within a <p>, <br> elements create line breaks.
|
||||||
|
// Separate paragraphs get a blank > line between them.
|
||||||
|
const paragraphs: string[] = [];
|
||||||
|
for (const child of Array.from(element.childNodes)) {
|
||||||
|
if (child.nodeType === 1 && (child as HTMLElement).tagName === 'P') {
|
||||||
|
const lines = this.paragraphToLines(child as HTMLElement, convert);
|
||||||
|
paragraphs.push(lines.map(line => '> ' + line).join('\n'));
|
||||||
|
} else {
|
||||||
|
const text = convert.node(child).trim();
|
||||||
|
if (text) {
|
||||||
|
paragraphs.push('> ' + text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '\n\n' + paragraphs.join('\n>\n') + '\n\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a paragraph's content into lines at <br> elements.
|
||||||
|
*/
|
||||||
|
private paragraphToLines(paragraph: HTMLElement, convert: Converter): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
let currentLine = '';
|
||||||
|
for (const child of Array.from(paragraph.childNodes)) {
|
||||||
|
if (child.nodeType === 1 && (child as HTMLElement).tagName === 'BR') {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = '';
|
||||||
|
} else {
|
||||||
|
currentLine += convert.node(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentLine.trim()) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter inside a blockquote adds a new paragraph within the quote.
|
||||||
|
* Double-Enter (empty line) exits the blockquote and creates a
|
||||||
|
* new paragraph after it — matching the behavior users expect
|
||||||
|
* from list editing.
|
||||||
|
*/
|
||||||
|
private onEnter(blockquote: HTMLElement, selection: Selection, _editor: any): boolean {
|
||||||
|
// Find the text content after the cursor to determine if
|
||||||
|
// the current line is empty (for double-Enter exit detection)
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const container = range.startContainer;
|
||||||
|
const offset = range.startOffset;
|
||||||
|
|
||||||
|
// Check if the cursor is at an empty line: either the entire
|
||||||
|
// paragraph is empty, or the cursor is right after a <br> with
|
||||||
|
// nothing after it
|
||||||
|
let lineIsEmpty = false;
|
||||||
|
const paragraph = container.nodeType === 1
|
||||||
|
? container as HTMLElement
|
||||||
|
: container.parentElement;
|
||||||
|
|
||||||
|
if (paragraph) {
|
||||||
|
const zeroWidthSpace = /\u200B/g;
|
||||||
|
const textAfterCursor = container.nodeType === 3
|
||||||
|
? (container.textContent || '').slice(offset).replace(zeroWidthSpace, '').trim()
|
||||||
|
: '';
|
||||||
|
const textBeforeCursor = container.nodeType === 3
|
||||||
|
? (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '').trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Empty if: whole paragraph is empty, or cursor is at the
|
||||||
|
// start of an empty text node after a <br>
|
||||||
|
const fullText = paragraph.textContent?.replace(zeroWidthSpace, '').trim() || '';
|
||||||
|
if (fullText === '') {
|
||||||
|
lineIsEmpty = true;
|
||||||
|
} else if (textBeforeCursor === '' && textAfterCursor === '') {
|
||||||
|
// Walk back past empty text nodes to find a <br>
|
||||||
|
let previous: Node | null = container.nodeType === 3
|
||||||
|
? container.previousSibling
|
||||||
|
: (container as HTMLElement).childNodes[offset - 1];
|
||||||
|
while (previous && previous.nodeType === 3
|
||||||
|
&& (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
|
||||||
|
previous = previous.previousSibling;
|
||||||
|
}
|
||||||
|
if (previous && previous.nodeType === 1 && (previous as HTMLElement).tagName === 'BR') {
|
||||||
|
lineIsEmpty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineIsEmpty) {
|
||||||
|
// Double-Enter: clean up the trailing <br> and empty nodes,
|
||||||
|
// then exit the blockquote
|
||||||
|
const lastParagraph = blockquote.querySelector('p:last-child');
|
||||||
|
if (lastParagraph) {
|
||||||
|
// Remove trailing <br> and empty text nodes
|
||||||
|
const zwsPattern = /\u200B/g;
|
||||||
|
while (lastParagraph.lastChild) {
|
||||||
|
const child = lastParagraph.lastChild;
|
||||||
|
if (child.nodeType === 1 && (child as HTMLElement).tagName === 'BR') {
|
||||||
|
child.remove();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (child.nodeType === 3
|
||||||
|
&& (child.textContent || '').replace(zwsPattern, '').trim() === '') {
|
||||||
|
child.remove();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// If the paragraph is now empty, remove it entirely
|
||||||
|
if (lastParagraph.textContent?.replace(zwsPattern, '').trim() === '') {
|
||||||
|
lastParagraph.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newParagraph = document.createElement('p');
|
||||||
|
newParagraph.innerHTML = '<br>';
|
||||||
|
blockquote.after(newParagraph);
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStart(newParagraph, 0);
|
||||||
|
newRange.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(newRange);
|
||||||
|
} else {
|
||||||
|
// Single Enter: insert <br> + ZWS cursor anchor
|
||||||
|
const brElement = document.createElement('br');
|
||||||
|
const cursorAnchor = document.createTextNode('\u200B');
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(cursorAnchor);
|
||||||
|
range.insertNode(brElement);
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStart(cursorAnchor, 1);
|
||||||
|
newRange.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(newRange);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,9 +714,12 @@ class BlockquoteTag implements Tag {
|
|||||||
*
|
*
|
||||||
* converter.toHTML('- one\n- two\n 1. nested')
|
* converter.toHTML('- one\n- two\n 1. nested')
|
||||||
*/
|
*/
|
||||||
class ListTag implements Tag {
|
class ListTag extends BaseTag implements Tag {
|
||||||
name = 'list';
|
name = 'list';
|
||||||
selector = 'UL,OL';
|
selector = 'UL,OL';
|
||||||
|
eventHandlers = {
|
||||||
|
'onEnter': this.onEnter,
|
||||||
|
};
|
||||||
button = {
|
button = {
|
||||||
show: false,
|
show: false,
|
||||||
label: 'List',
|
label: 'List',
|
||||||
@@ -387,6 +759,129 @@ class ListTag implements Tag {
|
|||||||
* Count how many consecutive lines belong to this list, including
|
* Count how many consecutive lines belong to this list, including
|
||||||
* nested sublists (detected by increased indentation).
|
* nested sublists (detected by increased indentation).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* List Enter behavior has three tiers:
|
||||||
|
* 1. Single Enter → <br> within current <li> (line continuation)
|
||||||
|
* 2. Double Enter in non-empty <li> → new <li> sibling
|
||||||
|
* 3. Double Enter in empty <li> → exit list, create <p> after it
|
||||||
|
*/
|
||||||
|
private onEnter(listElement: HTMLElement, selection: Selection, _editor: any): boolean {
|
||||||
|
// Find the <li> containing the cursor
|
||||||
|
let listItem: HTMLElement | null = null;
|
||||||
|
let node: Node | null = selection.anchorNode;
|
||||||
|
while (node && node !== listElement) {
|
||||||
|
if (node.nodeType === 1 && (node as HTMLElement).tagName === 'LI') {
|
||||||
|
listItem = node as HTMLElement;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
node = node.parentNode;
|
||||||
|
}
|
||||||
|
if (!listItem) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeroWidthSpace = /\u200B/g;
|
||||||
|
const itemText = (listItem.textContent || '').replace(zeroWidthSpace, '').trim();
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const container = range.startContainer;
|
||||||
|
const offset = range.startOffset;
|
||||||
|
|
||||||
|
// Detect empty line after <br> (same logic as defaultOnEnter)
|
||||||
|
let lineIsEmpty = false;
|
||||||
|
if (container.nodeType === 3) {
|
||||||
|
const textBefore = (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '').trim();
|
||||||
|
const textAfter = (container.textContent || '').slice(offset).replace(zeroWidthSpace, '').trim();
|
||||||
|
if (textBefore === '' && textAfter === '') {
|
||||||
|
let previous: Node | null = container.previousSibling;
|
||||||
|
while (previous && previous.nodeType === 3
|
||||||
|
&& (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
|
||||||
|
previous = previous.previousSibling;
|
||||||
|
}
|
||||||
|
if (previous && previous.nodeType === 1 && (previous as HTMLElement).tagName === 'BR') {
|
||||||
|
lineIsEmpty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (container.nodeType === 1) {
|
||||||
|
const childAtCursor = (container as HTMLElement).childNodes[offset - 1];
|
||||||
|
if (childAtCursor && childAtCursor.nodeType === 1 && (childAtCursor as HTMLElement).tagName === 'BR') {
|
||||||
|
const textBefore = Array.from((container as HTMLElement).childNodes)
|
||||||
|
.slice(0, offset - 1)
|
||||||
|
.map(child => child.textContent || '')
|
||||||
|
.join('')
|
||||||
|
.replace(zeroWidthSpace, '')
|
||||||
|
.trim();
|
||||||
|
const textAfter = Array.from((container as HTMLElement).childNodes)
|
||||||
|
.slice(offset)
|
||||||
|
.map(child => child.textContent || '')
|
||||||
|
.join('')
|
||||||
|
.replace(zeroWidthSpace, '')
|
||||||
|
.trim();
|
||||||
|
if (textAfter === '' && textBefore !== '') {
|
||||||
|
lineIsEmpty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineIsEmpty && itemText === '') {
|
||||||
|
// Tier 3: empty <li> — exit the list
|
||||||
|
listItem.remove();
|
||||||
|
const newParagraph = document.createElement('p');
|
||||||
|
newParagraph.innerHTML = '<br>';
|
||||||
|
listElement.after(newParagraph);
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStart(newParagraph, 0);
|
||||||
|
newRange.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(newRange);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineIsEmpty) {
|
||||||
|
// Tier 2: non-empty <li> with empty line — create new <li>
|
||||||
|
// Remove the trailing <br> and empty nodes
|
||||||
|
let nodeToRemove: Node | null = container.nodeType === 3
|
||||||
|
? container.previousSibling
|
||||||
|
: (container as HTMLElement).childNodes[offset - 1];
|
||||||
|
while (nodeToRemove && nodeToRemove.nodeType === 3
|
||||||
|
&& (nodeToRemove.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
|
||||||
|
const previous = nodeToRemove.previousSibling;
|
||||||
|
nodeToRemove.parentNode?.removeChild(nodeToRemove);
|
||||||
|
nodeToRemove = previous;
|
||||||
|
}
|
||||||
|
if (nodeToRemove && nodeToRemove.nodeType === 1
|
||||||
|
&& (nodeToRemove as HTMLElement).tagName === 'BR') {
|
||||||
|
nodeToRemove.parentNode?.removeChild(nodeToRemove);
|
||||||
|
}
|
||||||
|
if (container.nodeType === 3
|
||||||
|
&& container.textContent?.replace(zeroWidthSpace, '').trim() === '') {
|
||||||
|
container.parentNode?.removeChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItem = document.createElement('li');
|
||||||
|
newItem.innerHTML = '<br>';
|
||||||
|
listItem.after(newItem);
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStart(newItem, 0);
|
||||||
|
newRange.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(newRange);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 1: single Enter — insert <br> + ZWS cursor anchor
|
||||||
|
const brElement = document.createElement('br');
|
||||||
|
const cursorAnchor = document.createTextNode('\u200B');
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(cursorAnchor);
|
||||||
|
range.insertNode(brElement);
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStart(cursorAnchor, 1);
|
||||||
|
newRange.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(newRange);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private countListLines(lines: string[], start: number): number {
|
private countListLines(lines: string[], start: number): number {
|
||||||
const indentedUnordered = /^(\s*)[*\-+]\s/;
|
const indentedUnordered = /^(\s*)[*\-+]\s/;
|
||||||
const indentedOrdered = /^(\s*)\d+\.\s/;
|
const indentedOrdered = /^(\s*)\d+\.\s/;
|
||||||
@@ -501,7 +996,7 @@ class ListTag implements Tag {
|
|||||||
*
|
*
|
||||||
* converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
|
* converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
|
||||||
*/
|
*/
|
||||||
class TableTag implements Tag {
|
class TableTag extends BaseTag implements Tag {
|
||||||
name = 'table';
|
name = 'table';
|
||||||
selector = 'TABLE';
|
selector = 'TABLE';
|
||||||
button = {
|
button = {
|
||||||
@@ -611,7 +1106,7 @@ class TableTag implements Tag {
|
|||||||
*
|
*
|
||||||
* converter.toHTML('hello world') // '<p>hello world</p>'
|
* converter.toHTML('hello world') // '<p>hello world</p>'
|
||||||
*/
|
*/
|
||||||
class ParagraphTag implements Tag {
|
class ParagraphTag extends BaseTag implements Tag {
|
||||||
name = 'paragraph';
|
name = 'paragraph';
|
||||||
selector = 'P';
|
selector = 'P';
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -50,7 +50,7 @@ export interface DelimiterDef {
|
|||||||
* and followed by non-whitespace. Right-flanking is the reverse.
|
* and followed by non-whitespace. Right-flanking is the reverse.
|
||||||
*/
|
*/
|
||||||
const PUNCTUATION = new Set(
|
const PUNCTUATION = new Set(
|
||||||
' \t\n.,;:!?\'"()[]{}/<>\\-~#@&^|*`_'.split('')
|
' \t\n\u00A0.,;:!?\'"()[]{}/<>\\-~#@&^|*`_'.split('')
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -416,9 +416,9 @@ export class InlineTokenizer {
|
|||||||
const charAfter = source[position + delimiter.length];
|
const charAfter = source[position + delimiter.length];
|
||||||
|
|
||||||
const leftFlanking = (charBefore === undefined || PUNCTUATION.has(charBefore) || charBefore === '\n')
|
const leftFlanking = (charBefore === undefined || PUNCTUATION.has(charBefore) || charBefore === '\n')
|
||||||
&& charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t';
|
&& charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t' && charAfter !== '\u00A0';
|
||||||
|
|
||||||
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t'
|
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' && charBefore !== '\u00A0'
|
||||||
&& (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n');
|
&& (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n');
|
||||||
|
|
||||||
if (leftFlanking) {
|
if (leftFlanking) {
|
||||||
|
|||||||
+4
-16
@@ -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})`
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ export interface Tag {
|
|||||||
template?: string;
|
template?: string;
|
||||||
replaceSelection?: boolean;
|
replaceSelection?: boolean;
|
||||||
button?: ToolbarButton;
|
button?: ToolbarButton;
|
||||||
|
/** Keyboard event handlers for WYSIWYG mode. Keys are event names
|
||||||
|
* like 'onEnter', 'onBackspace', 'onTab'. The handler receives
|
||||||
|
* the tag's element, the current selection, and the editor instance. */
|
||||||
|
eventHandlers?: Record<string, (element: HTMLElement, selection: Selection, editor: any) => boolean>;
|
||||||
|
/** Dispatch a keydown event to the appropriate handler in
|
||||||
|
* eventHandlers. Provided by BaseTag; override for custom logic. */
|
||||||
|
handleKeydown?: (element: HTMLElement, event: KeyboardEvent, selection: Selection, editor: any) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
-337
@@ -1,337 +0,0 @@
|
|||||||
/*
|
|
||||||
* vim.ts — vim keybinding handler for ribbit source edit mode.
|
|
||||||
*
|
|
||||||
* Two modes: normal and insert. Activated in source (edit) mode only.
|
|
||||||
* Esc enters normal mode, i/a/o/O enter insert mode.
|
|
||||||
*
|
|
||||||
* Normal mode commands:
|
|
||||||
* h/j/k/l — cursor movement
|
|
||||||
* w/b — word forward/back
|
|
||||||
* 0/$ — line start/end
|
|
||||||
* gg/G — document start/end
|
|
||||||
* i — insert before cursor
|
|
||||||
* a — insert after cursor
|
|
||||||
* o — new line below, insert
|
|
||||||
* O — new line above, insert
|
|
||||||
* x — delete char under cursor
|
|
||||||
* dd — delete line
|
|
||||||
* u — undo
|
|
||||||
* Ctrl+r — redo
|
|
||||||
*/
|
|
||||||
|
|
||||||
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 {
|
|
||||||
mode: VimMode;
|
|
||||||
private element: HTMLElement | null;
|
|
||||||
private listener: ((event: KeyboardEvent) => void) | null;
|
|
||||||
private pending: string;
|
|
||||||
private count: string;
|
|
||||||
private onModeChange: (mode: VimMode) => void;
|
|
||||||
|
|
||||||
constructor(onModeChange: (mode: VimMode) => void) {
|
|
||||||
this.mode = 'insert';
|
|
||||||
this.element = null;
|
|
||||||
this.listener = null;
|
|
||||||
this.pending = '';
|
|
||||||
this.count = '';
|
|
||||||
this.onModeChange = onModeChange;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bind vim keybindings to a DOM element.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* vim.attach(document.getElementById('editor'));
|
|
||||||
*/
|
|
||||||
attach(element: HTMLElement): void {
|
|
||||||
this.detach();
|
|
||||||
this.element = element;
|
|
||||||
this.pending = '';
|
|
||||||
this.listener = (event: KeyboardEvent) => this.handleKey(event);
|
|
||||||
this.element.addEventListener('keydown', this.listener);
|
|
||||||
this.setMode('insert');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove vim keybindings from the current element.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* vim.detach();
|
|
||||||
*/
|
|
||||||
detach(): void {
|
|
||||||
if (this.element && this.listener) {
|
|
||||||
this.element.removeEventListener('keydown', this.listener);
|
|
||||||
this.element.classList.remove('vim-normal', 'vim-insert');
|
|
||||||
}
|
|
||||||
this.element = null;
|
|
||||||
this.listener = null;
|
|
||||||
this.mode = 'insert';
|
|
||||||
this.pending = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private setMode(mode: VimMode): void {
|
|
||||||
this.mode = mode;
|
|
||||||
this.pending = '';
|
|
||||||
this.count = '';
|
|
||||||
this.onModeChange(mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 (event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.setMode('normal');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suppress default text input in normal mode
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (event.ctrlKey) {
|
|
||||||
if (event.key === 'r') {
|
|
||||||
document.execCommand('redo');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = event.key;
|
|
||||||
|
|
||||||
// Accumulate count prefix — 0 as first char is line-start, not count
|
|
||||||
if (DIGIT_PATTERN.test(key) && (this.count || key !== '0')) {
|
|
||||||
this.count += key;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const repeat = parseInt(this.count || DEFAULT_REPEAT_COUNT, DECIMAL_RADIX);
|
|
||||||
this.count = '';
|
|
||||||
|
|
||||||
if (this.pending) {
|
|
||||||
const combo = this.pending + key;
|
|
||||||
this.pending = '';
|
|
||||||
for (let step = 0; step < repeat; step++) {
|
|
||||||
this.handlePending(combo);
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
case 'i':
|
|
||||||
this.setMode('insert');
|
|
||||||
break;
|
|
||||||
case 'a':
|
|
||||||
this.moveCursor(DIRECTION.RIGHT);
|
|
||||||
this.setMode('insert');
|
|
||||||
break;
|
|
||||||
case 'o':
|
|
||||||
this.endOfLine();
|
|
||||||
this.insertNewline();
|
|
||||||
this.setMode('insert');
|
|
||||||
break;
|
|
||||||
case 'O':
|
|
||||||
this.startOfLine();
|
|
||||||
this.insertNewline();
|
|
||||||
this.moveCursor(DIRECTION.UP);
|
|
||||||
this.setMode('insert');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'h':
|
|
||||||
for (let step = 0; step < repeat; step++) {
|
|
||||||
this.moveCursor(DIRECTION.LEFT);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'j':
|
|
||||||
for (let step = 0; step < repeat; step++) {
|
|
||||||
this.moveCursor(DIRECTION.DOWN);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'k':
|
|
||||||
for (let step = 0; step < repeat; step++) {
|
|
||||||
this.moveCursor(DIRECTION.UP);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'l':
|
|
||||||
for (let step = 0; step < repeat; step++) {
|
|
||||||
this.moveCursor(DIRECTION.RIGHT);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'w':
|
|
||||||
for (let step = 0; step < repeat; step++) {
|
|
||||||
this.wordForward();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'b':
|
|
||||||
for (let step = 0; step < repeat; step++) {
|
|
||||||
this.wordBack();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case '0':
|
|
||||||
this.startOfLine();
|
|
||||||
break;
|
|
||||||
case '$':
|
|
||||||
this.endOfLine();
|
|
||||||
break;
|
|
||||||
case 'G':
|
|
||||||
this.endOfDocument();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'x':
|
|
||||||
for (let step = 0; step < repeat; step++) {
|
|
||||||
this.deleteChar();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'u':
|
|
||||||
for (let step = 0; step < repeat; step++) {
|
|
||||||
document.execCommand('undo');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Two-char commands — preserve count for the second key
|
|
||||||
case 'd':
|
|
||||||
case 'g':
|
|
||||||
this.pending = key;
|
|
||||||
if (repeat > 1) {
|
|
||||||
this.count = String(repeat);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handlePending(combo: string): void {
|
|
||||||
switch (combo) {
|
|
||||||
case 'dd':
|
|
||||||
this.deleteLine();
|
|
||||||
break;
|
|
||||||
case 'gg':
|
|
||||||
this.startOfDocument();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private moveCursor(direction: 'left' | 'right' | 'up' | 'down'): void {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (!selection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.WORD);
|
|
||||||
}
|
|
||||||
|
|
||||||
private wordBack(): void {
|
|
||||||
window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.WORD);
|
|
||||||
}
|
|
||||||
|
|
||||||
private startOfLine(): void {
|
|
||||||
window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
|
|
||||||
}
|
|
||||||
|
|
||||||
private endOfLine(): void {
|
|
||||||
window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
|
|
||||||
}
|
|
||||||
|
|
||||||
private startOfDocument(): void {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (!selection || !this.element) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const range = document.createRange();
|
|
||||||
range.setStart(this.element, 0);
|
|
||||||
range.collapse(true);
|
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
}
|
|
||||||
|
|
||||||
private endOfDocument(): void {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (!selection || !this.element) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const range = document.createRange();
|
|
||||||
range.selectNodeContents(this.element);
|
|
||||||
range.collapse(false);
|
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
}
|
|
||||||
|
|
||||||
private deleteChar(): void {
|
|
||||||
document.execCommand('forwardDelete');
|
|
||||||
}
|
|
||||||
|
|
||||||
private deleteLine(): void {
|
|
||||||
this.startOfLine();
|
|
||||||
window.getSelection()?.modify('extend', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
|
|
||||||
document.execCommand('delete');
|
|
||||||
// Remove the trailing newline left after deleting line content
|
|
||||||
document.execCommand('forwardDelete');
|
|
||||||
}
|
|
||||||
|
|
||||||
private insertNewline(): void {
|
|
||||||
document.execCommand('insertLineBreak');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
import { ribbit, resetDOM } from './setup';
|
|
||||||
|
|
||||||
const lib = ribbit();
|
|
||||||
|
|
||||||
function mockTransport() {
|
|
||||||
const receiveListeners: Array<(update: Uint8Array) => void> = [];
|
|
||||||
const lockListeners: Array<(holder: any) => void> = [];
|
|
||||||
return {
|
|
||||||
connected: false,
|
|
||||||
sent: [] as Uint8Array[],
|
|
||||||
locked: false,
|
|
||||||
connect() {
|
|
||||||
this.connected = true;
|
|
||||||
},
|
|
||||||
disconnect() {
|
|
||||||
this.connected = false;
|
|
||||||
},
|
|
||||||
send(update: Uint8Array) {
|
|
||||||
this.sent.push(update);
|
|
||||||
},
|
|
||||||
onReceive(cb: (update: Uint8Array) => void) {
|
|
||||||
receiveListeners.push(cb);
|
|
||||||
},
|
|
||||||
simulateRemote(content: string) {
|
|
||||||
const encoded = new TextEncoder().encode(content);
|
|
||||||
receiveListeners.forEach(cb => cb(encoded));
|
|
||||||
},
|
|
||||||
lock: async function() {
|
|
||||||
this.locked = true;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
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));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockPresence() {
|
|
||||||
const listeners: Array<(peers: any[]) => void> = [];
|
|
||||||
return {
|
|
||||||
lastSent: null as any,
|
|
||||||
send(info: any) {
|
|
||||||
this.lastSent = info;
|
|
||||||
},
|
|
||||||
onUpdate(cb: (peers: any[]) => void) {
|
|
||||||
listeners.push(cb);
|
|
||||||
},
|
|
||||||
simulatePeers(peers: any[]) {
|
|
||||||
listeners.forEach(cb => cb(peers));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockRevisions() {
|
|
||||||
const store: any[] = [];
|
|
||||||
return {
|
|
||||||
store,
|
|
||||||
list: async () => store,
|
|
||||||
get: async (id: string) => store.find((rev: any) => rev.id === id),
|
|
||||||
create: async (content: string, meta?: any) => {
|
|
||||||
const rev = {
|
|
||||||
id: String(store.length + 1),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
content,
|
|
||||||
...meta,
|
|
||||||
};
|
|
||||||
store.push(rev);
|
|
||||||
return rev;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('CollaborationManager', () => {
|
|
||||||
beforeEach(() => resetDOM('initial'));
|
|
||||||
|
|
||||||
it('does not create manager without settings', () => {
|
|
||||||
const editor = new lib.Editor({});
|
|
||||||
editor.run();
|
|
||||||
expect(editor.collaboration).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates manager with settings', () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
expect(editor.collaboration).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('connection lifecycle', () => {
|
|
||||||
it('connects on wysiwyg', () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.wysiwyg();
|
|
||||||
expect(transport.connected).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('connects on edit', () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.edit();
|
|
||||||
expect(transport.connected).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disconnects on view', () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.wysiwyg();
|
|
||||||
editor.view();
|
|
||||||
expect(transport.connected).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('source mode pausing', () => {
|
|
||||||
it('pauses on entering source mode', () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.edit();
|
|
||||||
expect(editor.collaboration!.isPaused()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('counts remote changes while paused', () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.edit();
|
|
||||||
transport.simulateRemote('change 1');
|
|
||||||
transport.simulateRemote('change 2');
|
|
||||||
expect(editor.collaboration!.getRemoteChangeCount()).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fires remoteActivity event while paused', (done) => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
on: {
|
|
||||||
remoteActivity: ({ count }: any) => {
|
|
||||||
if (count === 1) {
|
|
||||||
done();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.edit();
|
|
||||||
transport.simulateRemote('change');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resumes on switching to wysiwyg', () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.edit();
|
|
||||||
editor.wysiwyg();
|
|
||||||
expect(editor.collaboration!.isPaused()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('locking', () => {
|
|
||||||
it('lock returns true', async () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
expect(await editor.lockForEditing()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forceLock returns true', async () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
expect(await editor.forceLockEditing()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fires lockChange event', (done) => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
on: {
|
|
||||||
lockChange: ({ holder }: any) => {
|
|
||||||
if (holder?.userId === 'alice') {
|
|
||||||
done();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
transport.simulateLock({
|
|
||||||
userId: 'alice',
|
|
||||||
displayName: 'Alice',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('presence', () => {
|
|
||||||
it('sends cursor with status', () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const presence = mockPresence();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
presence,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
color: '#f00',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.wysiwyg();
|
|
||||||
editor.collaboration!.sendCursor(42);
|
|
||||||
expect(presence.lastSent.status).toBe('active');
|
|
||||||
expect(presence.lastSent.cursor).toBe(42);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends editing status when paused', () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const presence = mockPresence();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
presence,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.edit();
|
|
||||||
editor.collaboration!.sendCursor(10);
|
|
||||||
expect(presence.lastSent.status).toBe('editing');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies idle status to peers', () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const presence = mockPresence();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
presence,
|
|
||||||
idleTimeout: 100,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
presence.simulatePeers([
|
|
||||||
{
|
|
||||||
userId: 'a',
|
|
||||||
displayName: 'A',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now() - 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
userId: 'b',
|
|
||||||
displayName: 'B',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const peers = editor.collaboration!.getPeers();
|
|
||||||
expect(peers[0].status).toBe('idle');
|
|
||||||
expect(peers[1].status).toBe('active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('revisions', () => {
|
|
||||||
it('lists revisions', async () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const revisions = mockRevisions();
|
|
||||||
await revisions.create('v1', { author: 'test' });
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
revisions,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
const list = await editor.listRevisions();
|
|
||||||
expect(list).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates revision', async () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const revisions = mockRevisions();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
revisions,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
const rev = await editor.createRevision({
|
|
||||||
author: 'test',
|
|
||||||
summary: 'test rev',
|
|
||||||
});
|
|
||||||
expect(rev).toBeDefined();
|
|
||||||
expect(revisions.store).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('restores revision', async () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const revisions = mockRevisions();
|
|
||||||
await revisions.create('old content', { author: 'test' });
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
revisions,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.wysiwyg();
|
|
||||||
await editor.restoreRevision('1');
|
|
||||||
expect(editor.getMarkdown()).toBe('old content');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fires revisionCreated event', async () => {
|
|
||||||
const transport = mockTransport();
|
|
||||||
const revisions = mockRevisions();
|
|
||||||
let fired = false;
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
collaboration: {
|
|
||||||
transport,
|
|
||||||
revisions,
|
|
||||||
user: {
|
|
||||||
userId: 'test',
|
|
||||||
displayName: 'Test',
|
|
||||||
status: 'active',
|
|
||||||
lastActive: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
on: {
|
|
||||||
revisionCreated: () => {
|
|
||||||
fired = true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
await editor.createRevision({ author: 'test' });
|
|
||||||
expect(fired).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ribbit, resetDOM } from './setup';
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
import { HopDown } from '../src';
|
||||||
|
|
||||||
const lib = ribbit();
|
const lib = ribbit();
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ describe('Custom block tags', () => {
|
|||||||
selector: 'DETAILS',
|
selector: 'DETAILS',
|
||||||
toMarkdown: (element: any, convert: any) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
|
toMarkdown: (element: any, convert: any) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
|
||||||
};
|
};
|
||||||
const converter = new lib.HopDown({
|
const converter = new HopDown({
|
||||||
tags: {
|
tags: {
|
||||||
'DETAILS': spoiler,
|
'DETAILS': spoiler,
|
||||||
...lib.defaultTags,
|
...lib.defaultTags,
|
||||||
@@ -38,15 +39,15 @@ describe('Custom block tags', () => {
|
|||||||
|
|
||||||
describe('HopDown({ exclude })', () => {
|
describe('HopDown({ exclude })', () => {
|
||||||
it('excludes table', () => {
|
it('excludes table', () => {
|
||||||
const converter = new lib.HopDown({ exclude: ['table'] });
|
const converter = new HopDown({ exclude: ['table'] });
|
||||||
expect(converter.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 converter = new lib.HopDown({ exclude: ['code'] });
|
const converter = new HopDown({ exclude: ['code'] });
|
||||||
expect(converter.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 converter = new lib.HopDown({ exclude: ['table'] });
|
const converter = new HopDown({ exclude: ['table'] });
|
||||||
expect(converter.toHTML('**bold**')).toContain('<strong>bold</strong>');
|
expect(converter.toHTML('**bold**')).toContain('<strong>bold</strong>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -59,7 +60,7 @@ describe('Collision detection', () => {
|
|||||||
htmlTag: 'span',
|
htmlTag: 'span',
|
||||||
precedence: 10,
|
precedence: 10,
|
||||||
});
|
});
|
||||||
expect(() => new lib.HopDown({
|
expect(() => new HopDown({
|
||||||
tags: {
|
tags: {
|
||||||
...lib.defaultTags,
|
...lib.defaultTags,
|
||||||
'SPAN': bad,
|
'SPAN': bad,
|
||||||
@@ -75,7 +76,7 @@ describe('Collision detection', () => {
|
|||||||
selector: 'STRONG',
|
selector: 'STRONG',
|
||||||
toMarkdown: () => '',
|
toMarkdown: () => '',
|
||||||
};
|
};
|
||||||
expect(() => new lib.HopDown({
|
expect(() => new HopDown({
|
||||||
tags: {
|
tags: {
|
||||||
...lib.defaultTags,
|
...lib.defaultTags,
|
||||||
'STRONG': dup,
|
'STRONG': dup,
|
||||||
@@ -98,7 +99,7 @@ describe('Collision detection', () => {
|
|||||||
});
|
});
|
||||||
// Remove default strikethrough to avoid collision with the custom S/DEL tags
|
// Remove default strikethrough to avoid collision with the custom S/DEL tags
|
||||||
const { 'DEL,S,STRIKE': _, ...tagsWithoutStrikethrough } = lib.defaultTags;
|
const { 'DEL,S,STRIKE': _, ...tagsWithoutStrikethrough } = lib.defaultTags;
|
||||||
expect(() => new lib.HopDown({
|
expect(() => new HopDown({
|
||||||
tags: {
|
tags: {
|
||||||
...tagsWithoutStrikethrough,
|
...tagsWithoutStrikethrough,
|
||||||
'S': short,
|
'S': short,
|
||||||
|
|||||||
@@ -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();
|
||||||
+16
-31
@@ -107,14 +107,6 @@ describe('RibbitEditor modes', () => {
|
|||||||
expect(editor.element.contentEditable).toBe('true');
|
expect(editor.element.contentEditable).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switches to edit', () => {
|
|
||||||
const editor = new lib.Editor({});
|
|
||||||
editor.run();
|
|
||||||
editor.wysiwyg();
|
|
||||||
editor.edit();
|
|
||||||
expect(editor.getState()).toBe('edit');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('switches back to view', () => {
|
it('switches back to view', () => {
|
||||||
const editor = new lib.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
@@ -135,25 +127,10 @@ describe('RibbitEditor modes', () => {
|
|||||||
});
|
});
|
||||||
editor.run();
|
editor.run();
|
||||||
editor.wysiwyg();
|
editor.wysiwyg();
|
||||||
editor.edit();
|
|
||||||
editor.view();
|
editor.view();
|
||||||
expect(modes).toEqual(['view', 'wysiwyg', 'edit', 'view']);
|
expect(modes).toEqual(['view', 'wysiwyg', 'view']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sourceMode disabled blocks edit', () => {
|
|
||||||
resetDOM();
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
currentTheme: 'no-source',
|
|
||||||
themes: [{
|
|
||||||
name: 'no-source',
|
|
||||||
features: { sourceMode: false },
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.wysiwyg();
|
|
||||||
editor.edit();
|
|
||||||
expect(editor.getState()).toBe('wysiwyg');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ThemeManager', () => {
|
describe('ThemeManager', () => {
|
||||||
@@ -229,7 +206,6 @@ describe('defaultTheme', () => {
|
|||||||
it('has correct shape', () => {
|
it('has correct shape', () => {
|
||||||
expect(lib.defaultTheme.name).toBe('ribbit-default');
|
expect(lib.defaultTheme.name).toBe('ribbit-default');
|
||||||
expect(lib.defaultTheme.tags).toBeDefined();
|
expect(lib.defaultTheme.tags).toBeDefined();
|
||||||
expect(lib.defaultTheme.features.sourceMode).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -252,17 +228,26 @@ describe('Utility functions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Editor htmlToMarkdown', () => {
|
describe('Editor htmlToMarkdown', () => {
|
||||||
beforeEach(() => resetDOM());
|
it('returns markdown in view state', () => {
|
||||||
|
resetDOM('**bold**');
|
||||||
it('converts strong', () => {
|
|
||||||
const editor = new lib.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**');
|
expect(editor.getMarkdown()).toBe('**bold**');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts em', () => {
|
it('returns markdown in wysiwyg state', () => {
|
||||||
|
resetDOM('**bold**');
|
||||||
const editor = new lib.Editor({});
|
const editor = new lib.Editor({});
|
||||||
editor.run();
|
editor.run();
|
||||||
expect(editor.htmlToMarkdown('<em>italic</em>')).toBe('*italic*');
|
editor.wysiwyg();
|
||||||
|
expect(editor.getMarkdown()).toBe('**bold**');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips inline formatting', () => {
|
||||||
|
resetDOM('hello **world** and *italic*');
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
expect(editor.getMarkdown()).toBe('hello **world** and *italic*');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-1
@@ -1,11 +1,14 @@
|
|||||||
import { ribbit } from './setup';
|
import { ribbit } from './setup';
|
||||||
|
|
||||||
const lib = ribbit();
|
const lib = ribbit();
|
||||||
const hopdown = new lib.HopDown();
|
const editor = new lib.Editor({});
|
||||||
|
const hopdown = editor.converter;
|
||||||
|
|
||||||
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));
|
||||||
|
|
||||||
|
|
||||||
describe('Markdown → HTML', () => {
|
describe('Markdown → HTML', () => {
|
||||||
describe('inline formatting', () => {
|
describe('inline formatting', () => {
|
||||||
it('bold', () => expect(H('**bold**')).toBe('<p><strong>bold</strong></p>'));
|
it('bold', () => expect(H('**bold**')).toBe('<p><strong>bold</strong></p>'));
|
||||||
@@ -534,3 +537,18 @@ describe('Backslash-escaped HTML tags', () => {
|
|||||||
expect(rehtml).toBe(rehtml2);
|
expect(rehtml).toBe(rehtml2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Table cell round-trip', () => {
|
||||||
|
it('inline formatting in cells survives round-trip', () => {
|
||||||
|
const html = '<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td><strong>bold</strong></td><td><em>italic</em></td></tr></tbody></table>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
it('code in cells survives round-trip', () => {
|
||||||
|
const html = '<table><thead><tr><th>A</th></tr></thead><tbody><tr><td><code>x</code></td></tr></tbody></table>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
it('literal * in cells survives round-trip', () => {
|
||||||
|
const html = '<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>2 * 3</td></tr></tbody></table>';
|
||||||
|
expect(H(M(html))).toBe(html);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
var liveServer = require("live-server");
|
||||||
|
|
||||||
|
var params = {
|
||||||
|
port: 5023,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
open: true,
|
||||||
|
root: "test/integration",
|
||||||
|
mount: [
|
||||||
|
['/static', 'dist/ribbit'],
|
||||||
|
['/test', 'test/integration'],
|
||||||
|
],
|
||||||
|
logLevel: 2, // 0 = errors only, 1 = some, 2 = lots
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
console.log(`\n🐸 Ribbit dev server running on http://localhost:${params['port']}`);
|
||||||
|
liveServer.start(params);
|
||||||
+11
-24
@@ -3,36 +3,23 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Ribbit Integration Test Page</title>
|
<title>Ribbit Integration Test Page</title>
|
||||||
<link rel="stylesheet" href="/ribbit/themes/ribbit-default/theme.css">
|
<link rel="stylesheet" href="/static/themes/ribbit-default/theme.css">
|
||||||
<style>
|
|
||||||
body { font-family: sans-serif; margin: 20px; }
|
|
||||||
#ribbit { border: 1px solid #ccc; padding: 20px; min-height: 200px; }
|
|
||||||
.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>
|
||||||
<article id="ribbit">**bold** and *italic* and `code`
|
<main>
|
||||||
|
<article id="ribbit">
|
||||||
|
|
||||||
## Heading
|
| Type | To Get |
|
||||||
|
|------|--------|
|
||||||
|
| `*emphasis*` | *emphasis* |
|
||||||
|
| `**bold**` | **bold** |
|
||||||
|
| `abel](/link/address)` | [link label](/link/address) |
|
||||||
|
| ``inline`` | `inline` |
|
||||||
|
|
||||||
- list item 1
|
|
||||||
- list item 2
|
|
||||||
|
|
||||||
> a blockquote
|
|
||||||
|
|
||||||
| A | B |
|
|
||||||
|---|---|
|
|
||||||
| 1 | 2 |
|
|
||||||
</article>
|
</article>
|
||||||
|
</main>
|
||||||
|
|
||||||
<script src="/ribbit/ribbit.js"></script>
|
<script src="/static/ribbit.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const editor = new ribbit.Editor({
|
const editor = new ribbit.Editor({
|
||||||
on: {
|
on: {
|
||||||
|
|||||||
+633
-387
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -1,9 +1,7 @@
|
|||||||
import { ribbit } from './setup';
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
const lib = ribbit();
|
const lib = ribbit();
|
||||||
|
|
||||||
const spacePattern = / /g;
|
|
||||||
|
|
||||||
const macros = [
|
const macros = [
|
||||||
{
|
{
|
||||||
name: 'user',
|
name: 'user',
|
||||||
@@ -13,7 +11,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(spacePattern, '') + '">' + name + '</a>';
|
return '<a href="/NPC/' + name.replace(/ /g, '') + '">' + name + '</a>';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -26,7 +24,9 @@ const macros = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const converter = new lib.HopDown({ macros });
|
const editor = new lib.Editor({macros: macros});
|
||||||
|
const converter = editor.converter;
|
||||||
|
|
||||||
const H = (md: string) => converter.toHTML(md);
|
const H = (md: string) => converter.toHTML(md);
|
||||||
const M = (html: string) => converter.toMarkdown(html);
|
const M = (html: string) => converter.toMarkdown(html);
|
||||||
|
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import { ribbit, resetDOM } from './setup';
|
|
||||||
|
|
||||||
const lib = ribbit();
|
|
||||||
|
|
||||||
describe('VimHandler', () => {
|
|
||||||
beforeEach(() => resetDOM('hello world'));
|
|
||||||
|
|
||||||
it('starts in insert mode', () => {
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
currentTheme: 'vim',
|
|
||||||
themes: [{
|
|
||||||
name: 'vim',
|
|
||||||
features: {
|
|
||||||
sourceMode: true,
|
|
||||||
vim: true,
|
|
||||||
},
|
|
||||||
tags: lib.defaultTags,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.edit();
|
|
||||||
expect(editor.element.classList.contains('vim-insert')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Esc enters normal mode', () => {
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
currentTheme: 'vim',
|
|
||||||
themes: [{
|
|
||||||
name: 'vim',
|
|
||||||
features: {
|
|
||||||
sourceMode: true,
|
|
||||||
vim: true,
|
|
||||||
},
|
|
||||||
tags: lib.defaultTags,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.edit();
|
|
||||||
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-insert')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('i returns to insert mode', () => {
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
currentTheme: 'vim',
|
|
||||||
themes: [{
|
|
||||||
name: 'vim',
|
|
||||||
features: {
|
|
||||||
sourceMode: true,
|
|
||||||
vim: true,
|
|
||||||
},
|
|
||||||
tags: lib.defaultTags,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.edit();
|
|
||||||
// Enter normal mode
|
|
||||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
|
||||||
// Back to insert
|
|
||||||
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-normal')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables toolbar in normal mode', () => {
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
autoToolbar: false,
|
|
||||||
currentTheme: 'vim',
|
|
||||||
themes: [{
|
|
||||||
name: 'vim',
|
|
||||||
features: {
|
|
||||||
sourceMode: true,
|
|
||||||
vim: true,
|
|
||||||
},
|
|
||||||
tags: lib.defaultTags,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.toolbar.render();
|
|
||||||
editor.edit();
|
|
||||||
editor.toolbar.enable();
|
|
||||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
|
||||||
const bold = editor.toolbar.buttons.get('bold');
|
|
||||||
expect(bold?.element?.classList.contains('disabled')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('re-enables toolbar in insert mode', () => {
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
autoToolbar: false,
|
|
||||||
currentTheme: 'vim',
|
|
||||||
themes: [{
|
|
||||||
name: 'vim',
|
|
||||||
features: {
|
|
||||||
sourceMode: true,
|
|
||||||
vim: true,
|
|
||||||
},
|
|
||||||
tags: lib.defaultTags,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.toolbar.render();
|
|
||||||
editor.edit();
|
|
||||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
|
||||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
|
|
||||||
const bold = editor.toolbar.buttons.get('bold');
|
|
||||||
expect(bold?.element?.classList.contains('disabled')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('detaches when leaving edit mode', () => {
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
currentTheme: 'vim',
|
|
||||||
themes: [{
|
|
||||||
name: 'vim',
|
|
||||||
features: {
|
|
||||||
sourceMode: true,
|
|
||||||
vim: true,
|
|
||||||
},
|
|
||||||
tags: lib.defaultTags,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.edit();
|
|
||||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
|
||||||
expect(editor.element.classList.contains('vim-normal')).toBe(true);
|
|
||||||
editor.wysiwyg();
|
|
||||||
// vim classes should be gone after mode switch
|
|
||||||
expect(editor.element.classList.contains('vim-normal')).toBe(false);
|
|
||||||
expect(editor.element.classList.contains('vim-insert')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only activates in edit mode', () => {
|
|
||||||
const editor = new lib.Editor({
|
|
||||||
currentTheme: 'vim',
|
|
||||||
themes: [{
|
|
||||||
name: 'vim',
|
|
||||||
features: {
|
|
||||||
sourceMode: true,
|
|
||||||
vim: true,
|
|
||||||
},
|
|
||||||
tags: lib.defaultTags,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
editor.run();
|
|
||||||
editor.wysiwyg();
|
|
||||||
// Esc in wysiwyg should not add vim classes
|
|
||||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
|
||||||
expect(editor.element.classList.contains('vim-normal')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user