overtype 1.0.5 → 1.1.0
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/LICENSE +1 -1
- package/README.md +26 -0
- package/dist/overtype.esm.js +1428 -3
- package/dist/overtype.esm.js.map +4 -4
- package/dist/overtype.js +1428 -3
- package/dist/overtype.js.map +4 -4
- package/dist/overtype.min.js +83 -51
- package/package.json +2 -1
- package/src/link-tooltip.js +269 -0
- package/src/overtype.js +51 -2
- package/src/parser.js +19 -0
- package/src/styles.js +3 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link Tooltip - Gmail/Google Docs style link preview
|
|
3
|
+
* Shows a clickable tooltip when cursor is within a link
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { computePosition, flip, shift, offset } from '@floating-ui/dom';
|
|
7
|
+
|
|
8
|
+
export class LinkTooltip {
|
|
9
|
+
constructor(editor) {
|
|
10
|
+
this.editor = editor;
|
|
11
|
+
this.tooltip = null;
|
|
12
|
+
this.currentLink = null;
|
|
13
|
+
this.hideTimeout = null;
|
|
14
|
+
this.isMouseInTooltip = false;
|
|
15
|
+
this.isMouseInLink = false;
|
|
16
|
+
|
|
17
|
+
this.init();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
init() {
|
|
21
|
+
// Create tooltip element
|
|
22
|
+
this.createTooltip();
|
|
23
|
+
|
|
24
|
+
// Listen for cursor position changes
|
|
25
|
+
this.editor.textarea.addEventListener('selectionchange', () => this.checkCursorPosition());
|
|
26
|
+
this.editor.textarea.addEventListener('input', () => this.checkCursorPosition());
|
|
27
|
+
this.editor.textarea.addEventListener('keyup', (e) => {
|
|
28
|
+
// Arrow keys might move cursor
|
|
29
|
+
if (e.key.includes('Arrow')) {
|
|
30
|
+
this.checkCursorPosition();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Hide tooltip when scrolling
|
|
35
|
+
this.editor.textarea.addEventListener('scroll', () => this.hide());
|
|
36
|
+
|
|
37
|
+
// Mouse events for tooltip persistence
|
|
38
|
+
this.tooltip.addEventListener('mouseenter', () => {
|
|
39
|
+
this.isMouseInTooltip = true;
|
|
40
|
+
this.cancelHide();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.tooltip.addEventListener('mouseleave', () => {
|
|
44
|
+
this.isMouseInTooltip = false;
|
|
45
|
+
this.scheduleHide();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
createTooltip() {
|
|
50
|
+
this.tooltip = document.createElement('div');
|
|
51
|
+
this.tooltip.className = 'overtype-link-tooltip';
|
|
52
|
+
this.tooltip.style.cssText = `
|
|
53
|
+
position: absolute;
|
|
54
|
+
background: #333;
|
|
55
|
+
color: white;
|
|
56
|
+
padding: 6px 10px;
|
|
57
|
+
border-radius: 16px;
|
|
58
|
+
font-size: 12px;
|
|
59
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
60
|
+
display: none;
|
|
61
|
+
z-index: 10000;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
64
|
+
max-width: 300px;
|
|
65
|
+
white-space: nowrap;
|
|
66
|
+
overflow: hidden;
|
|
67
|
+
text-overflow: ellipsis;
|
|
68
|
+
transition: opacity 0.2s;
|
|
69
|
+
opacity: 0;
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
// Add link icon and text container
|
|
73
|
+
this.tooltip.innerHTML = `
|
|
74
|
+
<span style="display: flex; align-items: center; gap: 6px;">
|
|
75
|
+
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" style="flex-shrink: 0;">
|
|
76
|
+
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path>
|
|
77
|
+
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path>
|
|
78
|
+
</svg>
|
|
79
|
+
<span class="overtype-link-tooltip-url"></span>
|
|
80
|
+
</span>
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
// Click handler to open link
|
|
84
|
+
this.tooltip.addEventListener('click', (e) => {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
e.stopPropagation();
|
|
87
|
+
if (this.currentLink) {
|
|
88
|
+
window.open(this.currentLink.url, '_blank');
|
|
89
|
+
this.hide();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Append to document body for proper positioning
|
|
94
|
+
document.body.appendChild(this.tooltip);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
checkCursorPosition() {
|
|
98
|
+
const cursorPos = this.editor.textarea.selectionStart;
|
|
99
|
+
const text = this.editor.textarea.value;
|
|
100
|
+
|
|
101
|
+
// Find if cursor is within a markdown link
|
|
102
|
+
const link = this.findLinkAtPosition(text, cursorPos);
|
|
103
|
+
|
|
104
|
+
if (link) {
|
|
105
|
+
this.isMouseInLink = true;
|
|
106
|
+
if (!this.currentLink ||
|
|
107
|
+
this.currentLink.start !== link.start ||
|
|
108
|
+
this.currentLink.url !== link.url) {
|
|
109
|
+
// New link or different link
|
|
110
|
+
this.show(link);
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
// Not in a link
|
|
114
|
+
this.isMouseInLink = false;
|
|
115
|
+
this.scheduleHide();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
findLinkAtPosition(text, position) {
|
|
120
|
+
// Regex to find markdown links: [text](url)
|
|
121
|
+
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
122
|
+
let match;
|
|
123
|
+
|
|
124
|
+
while ((match = linkRegex.exec(text)) !== null) {
|
|
125
|
+
const start = match.index;
|
|
126
|
+
const end = match.index + match[0].length;
|
|
127
|
+
|
|
128
|
+
if (position >= start && position <= end) {
|
|
129
|
+
return {
|
|
130
|
+
start: start,
|
|
131
|
+
end: end,
|
|
132
|
+
text: match[1],
|
|
133
|
+
url: match[2],
|
|
134
|
+
fullMatch: match[0]
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async show(link) {
|
|
143
|
+
this.currentLink = link;
|
|
144
|
+
this.cancelHide();
|
|
145
|
+
|
|
146
|
+
// Update tooltip content
|
|
147
|
+
const urlSpan = this.tooltip.querySelector('.overtype-link-tooltip-url');
|
|
148
|
+
urlSpan.textContent = link.url;
|
|
149
|
+
|
|
150
|
+
// Get the position of the link in the preview
|
|
151
|
+
const linkElement = this.findLinkElementInPreview(link);
|
|
152
|
+
|
|
153
|
+
if (linkElement) {
|
|
154
|
+
// Use the link element as reference
|
|
155
|
+
await this.positionTooltip(linkElement);
|
|
156
|
+
} else {
|
|
157
|
+
// Fallback: position based on cursor
|
|
158
|
+
await this.positionTooltipAtCursor(link);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Show tooltip with animation
|
|
162
|
+
this.tooltip.style.display = 'block';
|
|
163
|
+
// Force reflow
|
|
164
|
+
this.tooltip.offsetHeight;
|
|
165
|
+
this.tooltip.style.opacity = '1';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
findLinkElementInPreview(link) {
|
|
169
|
+
// Find the corresponding link element in the preview
|
|
170
|
+
const links = this.editor.preview.querySelectorAll('a');
|
|
171
|
+
|
|
172
|
+
for (const linkEl of links) {
|
|
173
|
+
// Check if this link contains our URL
|
|
174
|
+
const urlSpans = linkEl.querySelectorAll('.syntax-marker');
|
|
175
|
+
for (const span of urlSpans) {
|
|
176
|
+
if (span.textContent === link.url) {
|
|
177
|
+
return linkEl;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async positionTooltip(referenceEl) {
|
|
186
|
+
const { x, y } = await computePosition(referenceEl, this.tooltip, {
|
|
187
|
+
placement: 'bottom',
|
|
188
|
+
middleware: [
|
|
189
|
+
offset(6),
|
|
190
|
+
flip(),
|
|
191
|
+
shift({ padding: 10 })
|
|
192
|
+
]
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
Object.assign(this.tooltip.style, {
|
|
196
|
+
left: `${x}px`,
|
|
197
|
+
top: `${y}px`
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async positionTooltipAtCursor(link) {
|
|
202
|
+
// Get cursor position in the textarea
|
|
203
|
+
const textarea = this.editor.textarea;
|
|
204
|
+
|
|
205
|
+
// Create a temporary element to measure text position
|
|
206
|
+
const measurer = document.createElement('div');
|
|
207
|
+
measurer.style.cssText = window.getComputedStyle(textarea).cssText;
|
|
208
|
+
measurer.style.position = 'absolute';
|
|
209
|
+
measurer.style.visibility = 'hidden';
|
|
210
|
+
measurer.style.whiteSpace = 'pre-wrap';
|
|
211
|
+
measurer.style.wordWrap = 'break-word';
|
|
212
|
+
|
|
213
|
+
// Get text up to cursor
|
|
214
|
+
const textBeforeCursor = textarea.value.substring(0, link.start + link.fullMatch.length / 2);
|
|
215
|
+
measurer.textContent = textBeforeCursor;
|
|
216
|
+
|
|
217
|
+
document.body.appendChild(measurer);
|
|
218
|
+
const textHeight = measurer.offsetHeight;
|
|
219
|
+
document.body.removeChild(measurer);
|
|
220
|
+
|
|
221
|
+
// Get textarea position
|
|
222
|
+
const rect = textarea.getBoundingClientRect();
|
|
223
|
+
|
|
224
|
+
// Estimate position (this is approximate)
|
|
225
|
+
const x = rect.left + rect.width / 2;
|
|
226
|
+
const y = rect.top + Math.min(textHeight, rect.height - 50);
|
|
227
|
+
|
|
228
|
+
Object.assign(this.tooltip.style, {
|
|
229
|
+
left: `${x}px`,
|
|
230
|
+
top: `${y}px`,
|
|
231
|
+
transform: 'translateX(-50%)'
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
hide() {
|
|
236
|
+
this.tooltip.style.opacity = '0';
|
|
237
|
+
setTimeout(() => {
|
|
238
|
+
if (this.tooltip.style.opacity === '0') {
|
|
239
|
+
this.tooltip.style.display = 'none';
|
|
240
|
+
this.currentLink = null;
|
|
241
|
+
}
|
|
242
|
+
}, 200);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
scheduleHide() {
|
|
246
|
+
this.cancelHide();
|
|
247
|
+
this.hideTimeout = setTimeout(() => {
|
|
248
|
+
if (!this.isMouseInTooltip && !this.isMouseInLink) {
|
|
249
|
+
this.hide();
|
|
250
|
+
}
|
|
251
|
+
}, 300);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
cancelHide() {
|
|
255
|
+
if (this.hideTimeout) {
|
|
256
|
+
clearTimeout(this.hideTimeout);
|
|
257
|
+
this.hideTimeout = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
destroy() {
|
|
262
|
+
this.cancelHide();
|
|
263
|
+
if (this.tooltip && this.tooltip.parentNode) {
|
|
264
|
+
this.tooltip.parentNode.removeChild(this.tooltip);
|
|
265
|
+
}
|
|
266
|
+
this.tooltip = null;
|
|
267
|
+
this.currentLink = null;
|
|
268
|
+
}
|
|
269
|
+
}
|
package/src/overtype.js
CHANGED
|
@@ -9,6 +9,7 @@ import { ShortcutsManager } from './shortcuts.js';
|
|
|
9
9
|
import { generateStyles } from './styles.js';
|
|
10
10
|
import { getTheme, mergeTheme, solar, themeToCSSVars } from './themes.js';
|
|
11
11
|
import { Toolbar } from './toolbar.js';
|
|
12
|
+
import { LinkTooltip } from './link-tooltip.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* OverType Editor Class
|
|
@@ -97,6 +98,9 @@ class OverType {
|
|
|
97
98
|
|
|
98
99
|
// Setup shortcuts manager
|
|
99
100
|
this.shortcuts = new ShortcutsManager(this);
|
|
101
|
+
|
|
102
|
+
// Setup link tooltip
|
|
103
|
+
this.linkTooltip = new LinkTooltip(this);
|
|
100
104
|
|
|
101
105
|
// Setup toolbar if enabled
|
|
102
106
|
if (this.options.toolbar) {
|
|
@@ -130,7 +134,7 @@ class OverType {
|
|
|
130
134
|
// Typography
|
|
131
135
|
fontSize: '14px',
|
|
132
136
|
lineHeight: 1.6,
|
|
133
|
-
fontFamily: "
|
|
137
|
+
fontFamily: "ui-monospace, 'SFMono-Regular', 'Menlo', 'Consolas', 'Liberation Mono', monospace",
|
|
134
138
|
padding: '16px',
|
|
135
139
|
|
|
136
140
|
// Mobile styles
|
|
@@ -471,7 +475,52 @@ class OverType {
|
|
|
471
475
|
* @private
|
|
472
476
|
*/
|
|
473
477
|
handleKeydown(event) {
|
|
474
|
-
//
|
|
478
|
+
// Handle Tab key to prevent focus loss and insert spaces
|
|
479
|
+
if (event.key === 'Tab') {
|
|
480
|
+
event.preventDefault();
|
|
481
|
+
|
|
482
|
+
// Insert 2 spaces at cursor position
|
|
483
|
+
const start = this.textarea.selectionStart;
|
|
484
|
+
const end = this.textarea.selectionEnd;
|
|
485
|
+
const value = this.textarea.value;
|
|
486
|
+
|
|
487
|
+
// If there's a selection, indent/outdent based on shift key
|
|
488
|
+
if (start !== end && event.shiftKey) {
|
|
489
|
+
// Outdent: remove 2 spaces from start of each selected line
|
|
490
|
+
const before = value.substring(0, start);
|
|
491
|
+
const selection = value.substring(start, end);
|
|
492
|
+
const after = value.substring(end);
|
|
493
|
+
|
|
494
|
+
const lines = selection.split('\n');
|
|
495
|
+
const outdented = lines.map(line => line.replace(/^ /, '')).join('\n');
|
|
496
|
+
|
|
497
|
+
this.textarea.value = before + outdented + after;
|
|
498
|
+
this.textarea.selectionStart = start;
|
|
499
|
+
this.textarea.selectionEnd = start + outdented.length;
|
|
500
|
+
} else if (start !== end) {
|
|
501
|
+
// Indent: add 2 spaces to start of each selected line
|
|
502
|
+
const before = value.substring(0, start);
|
|
503
|
+
const selection = value.substring(start, end);
|
|
504
|
+
const after = value.substring(end);
|
|
505
|
+
|
|
506
|
+
const lines = selection.split('\n');
|
|
507
|
+
const indented = lines.map(line => ' ' + line).join('\n');
|
|
508
|
+
|
|
509
|
+
this.textarea.value = before + indented + after;
|
|
510
|
+
this.textarea.selectionStart = start;
|
|
511
|
+
this.textarea.selectionEnd = start + indented.length;
|
|
512
|
+
} else {
|
|
513
|
+
// No selection: just insert 2 spaces
|
|
514
|
+
this.textarea.value = value.substring(0, start) + ' ' + value.substring(end);
|
|
515
|
+
this.textarea.selectionStart = this.textarea.selectionEnd = start + 2;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Trigger input event to update preview
|
|
519
|
+
this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Let shortcuts manager handle other keys
|
|
475
524
|
const handled = this.shortcuts.handleKeydown(event);
|
|
476
525
|
|
|
477
526
|
// Call user callback if provided
|
package/src/parser.js
CHANGED
|
@@ -155,9 +155,28 @@ export class MarkdownParser {
|
|
|
155
155
|
let html = text;
|
|
156
156
|
// Order matters: parse code first to avoid conflicts
|
|
157
157
|
html = this.parseInlineCode(html);
|
|
158
|
+
// Use placeholders to protect inline code while preserving formatting spans
|
|
159
|
+
// We use Unicode Private Use Area (U+E000-U+F8FF) as placeholders because:
|
|
160
|
+
// 1. These characters are reserved for application-specific use
|
|
161
|
+
// 2. They'll never appear in user text
|
|
162
|
+
// 3. They maintain single-character width (important for alignment)
|
|
163
|
+
// 4. They're invisible if accidentally rendered
|
|
164
|
+
// This allows formatting like *text `code` text* to span across code blocks
|
|
165
|
+
// while preventing formatting inside code like `__init__` from being bolded
|
|
166
|
+
const codeBlocks = new Map();
|
|
167
|
+
html = html.replace(/(<code>.*?<\/code>)/g, (match) => {
|
|
168
|
+
const placeholder = `\uE000${codeBlocks.size}\uE001`;
|
|
169
|
+
codeBlocks.set(placeholder, match);
|
|
170
|
+
return placeholder;
|
|
171
|
+
});
|
|
172
|
+
// Process other inline elements on text with placeholders
|
|
158
173
|
html = this.parseLinks(html);
|
|
159
174
|
html = this.parseBold(html);
|
|
160
175
|
html = this.parseItalic(html);
|
|
176
|
+
// Restore code blocks
|
|
177
|
+
codeBlocks.forEach((codeBlock, placeholder) => {
|
|
178
|
+
html = html.replace(placeholder, codeBlock);
|
|
179
|
+
});
|
|
161
180
|
return html;
|
|
162
181
|
}
|
|
163
182
|
|
package/src/styles.js
CHANGED
|
@@ -14,7 +14,7 @@ export function generateStyles(options = {}) {
|
|
|
14
14
|
const {
|
|
15
15
|
fontSize = '14px',
|
|
16
16
|
lineHeight = 1.6,
|
|
17
|
-
fontFamily = "
|
|
17
|
+
fontFamily = "ui-monospace, 'SFMono-Regular', 'Menlo', 'Consolas', 'Liberation Mono', monospace",
|
|
18
18
|
padding = '20px',
|
|
19
19
|
theme = null,
|
|
20
20
|
mobile = {}
|
|
@@ -235,6 +235,8 @@ export function generateStyles(options = {}) {
|
|
|
235
235
|
padding: 0 !important;
|
|
236
236
|
border-radius: 2px !important;
|
|
237
237
|
font-family: inherit !important;
|
|
238
|
+
font-size: inherit !important;
|
|
239
|
+
line-height: inherit !important;
|
|
238
240
|
font-weight: normal !important;
|
|
239
241
|
}
|
|
240
242
|
|