@0m0g1/griot 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/blocks/BlockSchema.js +20 -90
- package/src/core/Block.js +42 -45
- package/src/core/Document.js +63 -98
- package/src/core/History.js +17 -42
- package/src/editor/Editor.js +405 -138
- package/src/editor/FormatToolbar.js +138 -0
- package/src/editor/Keyboard.js +92 -106
- package/src/inline/InlineLexer.js +69 -72
- package/src/inline/InlineRenderer.js +110 -95
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// ─── FormatToolbar.js ─────────────────────────────────────────────────────────
|
|
2
|
+
// Floating toolbar that appears above a text selection inside the editor.
|
|
3
|
+
// Provides one-click inline formatting (bold, italic, underline, etc.) and
|
|
4
|
+
// link / color-mark insertion.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// const tb = new FormatToolbar(editorContainerEl, {
|
|
8
|
+
// onWrap(syntax) {}, // wrap selection with syntax chars e.g. '**'
|
|
9
|
+
// onLink() {}, // open link insertion prompt
|
|
10
|
+
// onColor() {}, // open color-mark prompt
|
|
11
|
+
// });
|
|
12
|
+
// tb.destroy();
|
|
13
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const FORMATS = [
|
|
16
|
+
{ key: 'bold', label: 'B', title: 'Bold (Ctrl+B)', syntax: '**' },
|
|
17
|
+
{ key: 'italic', label: 'I', title: 'Italic (Ctrl+I)', syntax: '*' },
|
|
18
|
+
{ key: 'underline', label: 'U', title: 'Underline (Ctrl+U)', syntax: '__' },
|
|
19
|
+
{ key: 'strike', label: 'S̶', title: 'Strikethrough', syntax: '~~' },
|
|
20
|
+
{ key: 'code', label: '`', title: 'Inline Code', syntax: '`' },
|
|
21
|
+
{ key: 'highlight', label: '▐', title: 'Highlight', syntax: '==' },
|
|
22
|
+
{ key: 'link', label: '🔗', title: 'Link', action: 'link' },
|
|
23
|
+
{ key: 'color', label: '🎨', title: 'Color', action: 'color' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export class FormatToolbar {
|
|
27
|
+
constructor(container, callbacks = {}) {
|
|
28
|
+
this._container = container;
|
|
29
|
+
this._cb = callbacks;
|
|
30
|
+
this._el = null;
|
|
31
|
+
this._visible = false;
|
|
32
|
+
|
|
33
|
+
this._build();
|
|
34
|
+
this._attach();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Build DOM ────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
_build() {
|
|
40
|
+
const el = document.createElement('div');
|
|
41
|
+
el.className = 'griot-format-toolbar';
|
|
42
|
+
el.setAttribute('role', 'toolbar');
|
|
43
|
+
el.setAttribute('aria-label', 'Inline formatting');
|
|
44
|
+
|
|
45
|
+
for (const fmt of FORMATS) {
|
|
46
|
+
const btn = document.createElement('button');
|
|
47
|
+
btn.type = 'button';
|
|
48
|
+
btn.className = `griot-format-toolbar__btn griot-ftb--${fmt.key}`;
|
|
49
|
+
btn.title = fmt.title;
|
|
50
|
+
btn.textContent = fmt.label;
|
|
51
|
+
|
|
52
|
+
btn.addEventListener('mousedown', (e) => {
|
|
53
|
+
e.preventDefault(); // preserve selection before acting
|
|
54
|
+
if (fmt.syntax) this._cb.onWrap?.(fmt.syntax);
|
|
55
|
+
else if (fmt.action === 'link') this._cb.onLink?.();
|
|
56
|
+
else if (fmt.action === 'color') this._cb.onColor?.();
|
|
57
|
+
this._hide();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
el.appendChild(btn);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
document.body.appendChild(el);
|
|
64
|
+
this._el = el;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Event listeners ──────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
_attach() {
|
|
70
|
+
// Show after mouse release inside editor
|
|
71
|
+
this._onMouseUp = () => setTimeout(() => this._checkAndShow(), 20);
|
|
72
|
+
this._container.addEventListener('mouseup', this._onMouseUp);
|
|
73
|
+
|
|
74
|
+
// Show after Shift+arrow / Ctrl+A keyboard selection
|
|
75
|
+
this._onKeyUp = (e) => {
|
|
76
|
+
if (e.shiftKey || ((e.ctrlKey || e.metaKey) && e.key === 'a')) {
|
|
77
|
+
setTimeout(() => this._checkAndShow(), 20);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
this._container.addEventListener('keyup', this._onKeyUp);
|
|
81
|
+
|
|
82
|
+
// Hide when clicking anywhere outside the toolbar
|
|
83
|
+
this._onDocDown = (e) => {
|
|
84
|
+
if (this._visible && !this._el.contains(e.target)) this._hide();
|
|
85
|
+
};
|
|
86
|
+
document.addEventListener('mousedown', this._onDocDown);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Visibility ───────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
_checkAndShow() {
|
|
92
|
+
const sel = window.getSelection();
|
|
93
|
+
if (!sel || sel.isCollapsed || !sel.rangeCount) { this._hide(); return; }
|
|
94
|
+
|
|
95
|
+
const range = sel.getRangeAt(0);
|
|
96
|
+
if (!this._container.contains(range.commonAncestorContainer)) { this._hide(); return; }
|
|
97
|
+
if (!range.toString().trim()) { this._hide(); return; }
|
|
98
|
+
|
|
99
|
+
this._show(range.getBoundingClientRect());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_show(rect) {
|
|
103
|
+
const el = this._el;
|
|
104
|
+
el.style.display = 'flex';
|
|
105
|
+
|
|
106
|
+
requestAnimationFrame(() => {
|
|
107
|
+
const tbW = el.offsetWidth || 280;
|
|
108
|
+
const tbH = el.offsetHeight || 36;
|
|
109
|
+
|
|
110
|
+
let left = rect.left + rect.width / 2 - tbW / 2 + window.scrollX;
|
|
111
|
+
let top = rect.top - tbH - 10 + window.scrollY;
|
|
112
|
+
|
|
113
|
+
// Clamp horizontally
|
|
114
|
+
left = Math.max(8 + window.scrollX, Math.min(left, window.scrollX + window.innerWidth - tbW - 8));
|
|
115
|
+
// Flip below if too close to top
|
|
116
|
+
if (top < window.scrollY + 8) top = rect.bottom + 8 + window.scrollY;
|
|
117
|
+
|
|
118
|
+
el.style.left = `${left}px`;
|
|
119
|
+
el.style.top = `${top}px`;
|
|
120
|
+
this._visible = true;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_hide() {
|
|
125
|
+
if (this._el) this._el.style.display = 'none';
|
|
126
|
+
this._visible = false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
destroy() {
|
|
132
|
+
this._container.removeEventListener('mouseup', this._onMouseUp);
|
|
133
|
+
this._container.removeEventListener('keyup', this._onKeyUp);
|
|
134
|
+
document.removeEventListener('mousedown', this._onDocDown);
|
|
135
|
+
this._el?.remove();
|
|
136
|
+
this._el = null;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/editor/Keyboard.js
CHANGED
|
@@ -1,169 +1,155 @@
|
|
|
1
|
-
// ─── Keyboard.js
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// the Editor class acts on.
|
|
5
|
-
//
|
|
6
|
-
// Exported: attachKeyboardHandler(el, callbacks)
|
|
7
|
-
// Attaches a keydown listener to a contenteditable element for one block.
|
|
8
|
-
// Calls back into Editor which owns the document state.
|
|
1
|
+
// ─── Keyboard.js ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Keyboard event handling for contenteditable editor blocks.
|
|
3
|
+
// Also exports cursor position helpers shared by Editor and FormatToolbar.
|
|
9
4
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
5
|
|
|
11
6
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
7
|
+
* Attach all editor keyboard shortcuts to a contenteditable element.
|
|
8
|
+
*
|
|
9
|
+
* Callbacks:
|
|
10
|
+
* onEnter(id, offset) — Enter (no shift)
|
|
11
|
+
* onBackspaceAtStart(id) — Backspace at offset 0 with no selection
|
|
12
|
+
* onDeleteAtEnd(id) — Delete at end with no selection
|
|
13
|
+
* onTab(id, isShift) — Tab / Shift+Tab
|
|
14
|
+
* onArrowUp(id) — ↑ on first visual line
|
|
15
|
+
* onArrowDown(id) — ↓ on last visual line
|
|
16
|
+
* onUndo() — Ctrl/Cmd+Z
|
|
17
|
+
* onRedo() — Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z
|
|
18
|
+
* onFormatKey(key) — Ctrl/Cmd+B/I/U
|
|
22
19
|
*/
|
|
23
|
-
export function attachKeyboardHandler(el, blockId, callbacks) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
export function attachKeyboardHandler(el, blockId, callbacks = {}) {
|
|
21
|
+
const {
|
|
22
|
+
onEnter, onBackspaceAtStart, onDeleteAtEnd,
|
|
23
|
+
onTab, onArrowUp, onArrowDown,
|
|
24
|
+
onUndo, onRedo, onFormatKey,
|
|
25
|
+
} = callbacks;
|
|
29
26
|
|
|
27
|
+
el.addEventListener('keydown', (e) => {
|
|
30
28
|
const ctrl = e.ctrlKey || e.metaKey;
|
|
31
29
|
|
|
32
|
-
// ── Undo / Redo
|
|
33
|
-
if (ctrl && e.key === 'z' && !e.shiftKey) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if ((ctrl && e.key === 'z' && e.shiftKey) || (ctrl && e.key === 'y')) {
|
|
30
|
+
// ── Undo / Redo ───────────────────────────────────────────────────────────
|
|
31
|
+
if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); onUndo?.(); return; }
|
|
32
|
+
if (ctrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); onRedo?.(); return; }
|
|
33
|
+
|
|
34
|
+
// ── Inline format shortcuts ───────────────────────────────────────────────
|
|
35
|
+
if (ctrl && ['b', 'i', 'u'].includes(e.key.toLowerCase())) {
|
|
39
36
|
e.preventDefault();
|
|
40
|
-
|
|
37
|
+
onFormatKey?.(e.key.toLowerCase());
|
|
41
38
|
return;
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
// ── Enter
|
|
41
|
+
// ── Enter ─────────────────────────────────────────────────────────────────
|
|
45
42
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
46
43
|
e.preventDefault();
|
|
47
44
|
onEnter?.(blockId, getCursorOffset(el));
|
|
48
45
|
return;
|
|
49
46
|
}
|
|
50
47
|
|
|
51
|
-
// ── Backspace at start
|
|
52
|
-
if (e.key === 'Backspace' && getCursorOffset(el) === 0 &&
|
|
48
|
+
// ── Backspace at start ────────────────────────────────────────────────────
|
|
49
|
+
if (e.key === 'Backspace' && getCursorOffset(el) === 0 && selLen(el) === 0) {
|
|
53
50
|
e.preventDefault();
|
|
54
51
|
onBackspaceAtStart?.(blockId);
|
|
55
52
|
return;
|
|
56
53
|
}
|
|
57
54
|
|
|
58
|
-
// ── Delete at end
|
|
59
|
-
if (e.key === 'Delete' &&
|
|
55
|
+
// ── Delete at end ─────────────────────────────────────────────────────────
|
|
56
|
+
if (e.key === 'Delete' && isAtEnd(el) && selLen(el) === 0) {
|
|
60
57
|
e.preventDefault();
|
|
61
58
|
onDeleteAtEnd?.(blockId);
|
|
62
59
|
return;
|
|
63
60
|
}
|
|
64
61
|
|
|
65
|
-
// ── Tab
|
|
62
|
+
// ── Tab ───────────────────────────────────────────────────────────────────
|
|
66
63
|
if (e.key === 'Tab') {
|
|
67
64
|
e.preventDefault();
|
|
68
65
|
onTab?.(blockId, e.shiftKey);
|
|
69
66
|
return;
|
|
70
67
|
}
|
|
71
68
|
|
|
72
|
-
// ── Arrow navigation
|
|
73
|
-
if (e.key === 'ArrowUp'
|
|
74
|
-
|
|
75
|
-
onArrowUp?.(blockId);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
if (e.key === 'ArrowDown' && isAtLastLine(el)) {
|
|
79
|
-
e.preventDefault();
|
|
80
|
-
onArrowDown?.(blockId);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
69
|
+
// ── Arrow navigation between blocks ──────────────────────────────────────
|
|
70
|
+
if (e.key === 'ArrowUp' && isOnFirstLine(el)) { e.preventDefault(); onArrowUp?.(blockId); }
|
|
71
|
+
if (e.key === 'ArrowDown' && isOnLastLine(el)) { e.preventDefault(); onArrowDown?.(blockId); }
|
|
83
72
|
});
|
|
84
73
|
}
|
|
85
74
|
|
|
86
|
-
//
|
|
75
|
+
// ── Cursor offset helpers ─────────────────────────────────────────────────────
|
|
87
76
|
|
|
88
|
-
/**
|
|
77
|
+
/** Character offset of the caret within `el`. */
|
|
89
78
|
export function getCursorOffset(el) {
|
|
90
79
|
const sel = window.getSelection();
|
|
91
|
-
if (!sel
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
80
|
+
if (!sel?.rangeCount) return 0;
|
|
81
|
+
const r = sel.getRangeAt(0);
|
|
82
|
+
const pre = r.cloneRange();
|
|
83
|
+
pre.selectNodeContents(el);
|
|
84
|
+
pre.setEnd(r.startContainer, r.startOffset);
|
|
85
|
+
return pre.toString().length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** { start, end } character offsets of the current selection within `el`. */
|
|
89
|
+
export function getSelectionOffsets(el) {
|
|
90
|
+
const sel = window.getSelection();
|
|
91
|
+
if (!sel?.rangeCount) return { start: 0, end: 0 };
|
|
92
|
+
const r = sel.getRangeAt(0);
|
|
93
|
+
const pre = r.cloneRange();
|
|
94
|
+
pre.selectNodeContents(el);
|
|
95
|
+
pre.setEnd(r.startContainer, r.startOffset);
|
|
96
|
+
const start = pre.toString().length;
|
|
97
|
+
const end = start + r.toString().length;
|
|
98
|
+
return { start, end };
|
|
96
99
|
}
|
|
97
100
|
|
|
98
|
-
/**
|
|
101
|
+
/** Move the caret to `offset` characters from the start of `el`. */
|
|
99
102
|
export function setCursorOffset(el, offset) {
|
|
100
|
-
const range = document.createRange();
|
|
101
103
|
const sel = window.getSelection();
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
let remaining = offset;
|
|
105
|
-
let found = false;
|
|
104
|
+
const range = document.createRange();
|
|
105
|
+
let rem = offset, found = false;
|
|
106
106
|
|
|
107
|
-
function walk(node) {
|
|
107
|
+
(function walk(node) {
|
|
108
108
|
if (found) return;
|
|
109
109
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
110
|
-
if (
|
|
111
|
-
range.setStart(node,
|
|
112
|
-
range.setEnd(node,
|
|
110
|
+
if (rem <= node.length) {
|
|
111
|
+
range.setStart(node, rem);
|
|
112
|
+
range.setEnd(node, rem);
|
|
113
113
|
found = true;
|
|
114
114
|
} else {
|
|
115
|
-
|
|
115
|
+
rem -= node.length;
|
|
116
116
|
}
|
|
117
117
|
} else {
|
|
118
|
-
|
|
118
|
+
node.childNodes.forEach(walk);
|
|
119
119
|
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
walk(el);
|
|
123
|
-
|
|
124
|
-
if (!found) {
|
|
125
|
-
// Clamp to end
|
|
126
|
-
range.selectNodeContents(el);
|
|
127
|
-
range.collapse(false);
|
|
128
|
-
}
|
|
120
|
+
})(el);
|
|
129
121
|
|
|
122
|
+
if (!found) { range.selectNodeContents(el); range.collapse(false); }
|
|
130
123
|
sel.removeAllRanges();
|
|
131
124
|
sel.addRange(range);
|
|
132
125
|
}
|
|
133
126
|
|
|
134
|
-
|
|
135
|
-
export function
|
|
136
|
-
el.focus();
|
|
137
|
-
setCursorOffset(el, el.textContent.length);
|
|
138
|
-
}
|
|
127
|
+
export function focusAtEnd(el) { el.focus(); setCursorOffset(el, el.textContent?.length ?? 0); }
|
|
128
|
+
export function focusAtStart(el) { el.focus(); setCursorOffset(el, 0); }
|
|
139
129
|
|
|
140
|
-
|
|
141
|
-
export function focusAtStart(el) {
|
|
142
|
-
el.focus();
|
|
143
|
-
setCursorOffset(el, 0);
|
|
144
|
-
}
|
|
130
|
+
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
145
131
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return sel && sel.type === 'Range';
|
|
132
|
+
function selLen(el) {
|
|
133
|
+
try { return window.getSelection()?.getRangeAt(0)?.toString().length ?? 0; }
|
|
134
|
+
catch { return 0; }
|
|
150
135
|
}
|
|
151
136
|
|
|
152
|
-
function
|
|
153
|
-
|
|
154
|
-
if (!sel || sel.rangeCount === 0) return false;
|
|
155
|
-
const range = sel.getRangeAt(0);
|
|
156
|
-
const rect = range.getBoundingClientRect();
|
|
157
|
-
const elRect = el.getBoundingClientRect();
|
|
158
|
-
// Within 1.5x line-height from top
|
|
159
|
-
return Math.abs(rect.top - elRect.top) < 30;
|
|
137
|
+
function isAtEnd(el) {
|
|
138
|
+
return getCursorOffset(el) >= (el.textContent?.length ?? 0);
|
|
160
139
|
}
|
|
161
140
|
|
|
162
|
-
function
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return Math.abs(rect.bottom - elRect.bottom) < 30;
|
|
141
|
+
function isOnFirstLine(el) {
|
|
142
|
+
try {
|
|
143
|
+
const sel = window.getSelection();
|
|
144
|
+
if (!sel?.rangeCount) return true;
|
|
145
|
+
return sel.getRangeAt(0).getBoundingClientRect().top < el.getBoundingClientRect().top + 10;
|
|
146
|
+
} catch { return true; }
|
|
169
147
|
}
|
|
148
|
+
|
|
149
|
+
function isOnLastLine(el) {
|
|
150
|
+
try {
|
|
151
|
+
const sel = window.getSelection();
|
|
152
|
+
if (!sel?.rangeCount) return true;
|
|
153
|
+
return sel.getRangeAt(0).getBoundingClientRect().bottom > el.getBoundingClientRect().bottom - 10;
|
|
154
|
+
} catch { return true; }
|
|
155
|
+
}
|
|
@@ -1,90 +1,87 @@
|
|
|
1
1
|
// ─── InlineLexer.js ───────────────────────────────────────────────────────────
|
|
2
|
-
// Tokenises
|
|
3
|
-
// Runs on a single text string (the content of one block's text field).
|
|
2
|
+
// Tokenises a plain-text string that uses lightweight inline markup.
|
|
4
3
|
//
|
|
5
|
-
// Supported syntax
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
4
|
+
// Supported syntax
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
// **bold** → TOKEN.BOLD { text }
|
|
7
|
+
// *italic* → TOKEN.ITALIC { text }
|
|
8
|
+
// __underline__ → TOKEN.UNDERLINE { text }
|
|
9
|
+
// ~~strikethrough~~ → TOKEN.STRIKE { text }
|
|
10
|
+
// `inline code` → TOKEN.CODE { code }
|
|
11
|
+
// ==highlight== → TOKEN.HIGHLIGHT { text }
|
|
12
|
+
// {#f00:red text} {blue:text} → TOKEN.COLOR_MARK { color, text }
|
|
13
|
+
// [label](url) → TOKEN.LINK { label, href }
|
|
14
|
+
//  → TOKEN.IMAGE { alt, src }
|
|
15
|
+
// [[event:id|label]] → TOKEN.EVENT_REF { eventId, label }
|
|
16
|
+
// [[cite:blockId|label]] → TOKEN.CITE_REF { blockId, label }
|
|
17
|
+
// plain text → TOKEN.TEXT { text }
|
|
18
|
+
//
|
|
19
|
+
// Stateless and re-entrant. Rules are anchored regexes in priority order.
|
|
13
20
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
21
|
|
|
15
22
|
export const TOKEN = Object.freeze({
|
|
16
|
-
TEXT:
|
|
17
|
-
BOLD:
|
|
18
|
-
ITALIC:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
TEXT: 'text',
|
|
24
|
+
BOLD: 'bold',
|
|
25
|
+
ITALIC: 'italic',
|
|
26
|
+
UNDERLINE: 'underline',
|
|
27
|
+
STRIKE: 'strike',
|
|
28
|
+
CODE: 'code',
|
|
29
|
+
LINK: 'link',
|
|
30
|
+
IMAGE: 'image',
|
|
31
|
+
HIGHLIGHT: 'highlight',
|
|
32
|
+
COLOR_MARK: 'color_mark',
|
|
33
|
+
EVENT_REF: 'event_ref',
|
|
34
|
+
CITE_REF: 'cite_ref',
|
|
23
35
|
});
|
|
24
36
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
[
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
const RULES = [
|
|
38
|
+
// Inline image  — must precede link rule
|
|
39
|
+
{ type: TOKEN.IMAGE, re: /^!\[([^\]]*)\]\(([^)\s]+)\)/, build: m => ({ alt: m[1], src: m[2] }) },
|
|
40
|
+
// Link [label](url)
|
|
41
|
+
{ type: TOKEN.LINK, re: /^\[([^\]]+)\]\(([^)\s]+)\)/, build: m => ({ label: m[1], href: m[2] }) },
|
|
42
|
+
// Bold **text** — before italic
|
|
43
|
+
{ type: TOKEN.BOLD, re: /^\*\*((?:[^*]|\*(?!\*))+)\*\*/, build: m => ({ text: m[1] }) },
|
|
44
|
+
// Italic *text*
|
|
45
|
+
{ type: TOKEN.ITALIC, re: /^\*((?:[^*])+)\*/, build: m => ({ text: m[1] }) },
|
|
46
|
+
// Underline __text__
|
|
47
|
+
{ type: TOKEN.UNDERLINE, re: /^__((?:[^_])+)__/, build: m => ({ text: m[1] }) },
|
|
48
|
+
// Strikethrough ~~text~~
|
|
49
|
+
{ type: TOKEN.STRIKE, re: /^~~((?:[^~])+)~~/, build: m => ({ text: m[1] }) },
|
|
50
|
+
// Highlight ==text==
|
|
51
|
+
{ type: TOKEN.HIGHLIGHT, re: /^==((?:[^=])+)==/, build: m => ({ text: m[1] }) },
|
|
52
|
+
// Colour mark {#hex:text} or {colorname:text}
|
|
53
|
+
{ type: TOKEN.COLOR_MARK, re: /^\{(#[0-9a-fA-F]{3,8}|[a-zA-Z][a-zA-Z0-9_-]*):([^}]+)\}/, build: m => ({ color: m[1], text: m[2] }) },
|
|
54
|
+
// Inline code `code`
|
|
55
|
+
{ type: TOKEN.CODE, re: /^`([^`]+)`/, build: m => ({ code: m[1] }) },
|
|
56
|
+
// Event ref [[event:id|label]]
|
|
57
|
+
{ type: TOKEN.EVENT_REF, re: /^\[\[event:([^\]|]+)(?:\|([^\]]*))?\]\]/, build: m => ({ eventId: m[1], label: m[2] || m[1] }) },
|
|
58
|
+
// Cite ref [[cite:id|label]]
|
|
59
|
+
{ type: TOKEN.CITE_REF, re: /^\[\[cite:([^\]|]+)(?:\|([^\]]*))?\]\]/, build: m => ({ blockId: m[1], label: m[2] || m[1] }) },
|
|
60
|
+
];
|
|
37
61
|
|
|
38
62
|
export function tokenizeInline(text = '') {
|
|
39
63
|
if (!text) return [];
|
|
40
|
-
|
|
41
64
|
const tokens = [];
|
|
42
|
-
let
|
|
43
|
-
let m;
|
|
65
|
+
let pos = 0, textStart = 0;
|
|
44
66
|
|
|
45
|
-
|
|
67
|
+
while (pos < text.length) {
|
|
68
|
+
const remaining = text.slice(pos);
|
|
69
|
+
let matched = false;
|
|
46
70
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
tokens.push({ type: TOKEN.TEXT, text: text.slice(
|
|
71
|
+
for (const rule of RULES) {
|
|
72
|
+
const m = remaining.match(rule.re);
|
|
73
|
+
if (!m) continue;
|
|
74
|
+
if (pos > textStart) tokens.push({ type: TOKEN.TEXT, text: text.slice(textStart, pos) });
|
|
75
|
+
tokens.push({ type: rule.type, ...rule.build(m) });
|
|
76
|
+
pos += m[0].length;
|
|
77
|
+
textStart = pos;
|
|
78
|
+
matched = true;
|
|
79
|
+
break;
|
|
51
80
|
}
|
|
52
81
|
|
|
53
|
-
if (
|
|
54
|
-
// bold
|
|
55
|
-
tokens.push({ type: TOKEN.BOLD, text: m[2] });
|
|
56
|
-
} else if (m[3]) {
|
|
57
|
-
// italic
|
|
58
|
-
tokens.push({ type: TOKEN.ITALIC, text: m[4] });
|
|
59
|
-
} else if (m[5]) {
|
|
60
|
-
// inline code
|
|
61
|
-
tokens.push({ type: TOKEN.CODE, text: m[6] });
|
|
62
|
-
} else if (m[7]) {
|
|
63
|
-
// link
|
|
64
|
-
tokens.push({ type: TOKEN.LINK, text: m[8], href: m[9] });
|
|
65
|
-
} else if (m[10]) {
|
|
66
|
-
// event_ref
|
|
67
|
-
tokens.push({
|
|
68
|
-
type: TOKEN.EVENT_REF,
|
|
69
|
-
eventId: m[11],
|
|
70
|
-
label: m[12] || m[11],
|
|
71
|
-
});
|
|
72
|
-
} else if (m[13]) {
|
|
73
|
-
// cite_ref
|
|
74
|
-
tokens.push({
|
|
75
|
-
type: TOKEN.CITE_REF,
|
|
76
|
-
blockId: m[14],
|
|
77
|
-
label: m[15] || m[14],
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
last = INLINE_RE.lastIndex;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Remaining plain text
|
|
85
|
-
if (last < text.length) {
|
|
86
|
-
tokens.push({ type: TOKEN.TEXT, text: text.slice(last) });
|
|
82
|
+
if (!matched) pos++;
|
|
87
83
|
}
|
|
88
84
|
|
|
85
|
+
if (textStart < text.length) tokens.push({ type: TOKEN.TEXT, text: text.slice(textStart) });
|
|
89
86
|
return tokens.length ? tokens : [{ type: TOKEN.TEXT, text }];
|
|
90
|
-
}
|
|
87
|
+
}
|