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.
@@ -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: "'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace",
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
- // Let shortcuts manager handle it first
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 = "'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace",
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