overtype 1.2.2 → 1.2.4

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/src/parser.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * MarkdownParser - Parses markdown into HTML while preserving character alignment
3
- *
3
+ *
4
4
  * Key principles:
5
5
  * - Every character must occupy the exact same position as in the textarea
6
6
  * - No font-size changes, no padding/margin on inline elements
@@ -9,14 +9,14 @@
9
9
  export class MarkdownParser {
10
10
  // Track link index for anchor naming
11
11
  static linkIndex = 0;
12
-
12
+
13
13
  /**
14
14
  * Reset link index (call before parsing a new document)
15
15
  */
16
16
  static resetLinkIndex() {
17
17
  this.linkIndex = 0;
18
18
  }
19
-
19
+
20
20
  /**
21
21
  * Escape HTML special characters
22
22
  * @param {string} text - Raw text to escape
@@ -139,6 +139,20 @@ export class MarkdownParser {
139
139
  return html;
140
140
  }
141
141
 
142
+ /**
143
+ * Parse strikethrough text
144
+ * Supports both single (~) and double (~~) tildes, but rejects 3+ tildes
145
+ * @param {string} html - HTML with potential strikethrough markdown
146
+ * @returns {string} HTML with strikethrough styling
147
+ */
148
+ static parseStrikethrough(html) {
149
+ // Double tilde strikethrough: ~~text~~ (but not if part of 3+ tildes)
150
+ html = html.replace(/(?<!~)~~(?!~)(.+?)(?<!~)~~(?!~)/g, '<del><span class="syntax-marker">~~</span>$1<span class="syntax-marker">~~</span></del>');
151
+ // Single tilde strikethrough: ~text~ (but not if part of 2+ tildes on either side)
152
+ html = html.replace(/(?<!~)~(?!~)(.+?)(?<!~)~(?!~)/g, '<del><span class="syntax-marker">~</span>$1<span class="syntax-marker">~</span></del>');
153
+ return html;
154
+ }
155
+
142
156
  /**
143
157
  * Parse inline code
144
158
  * @param {string} html - HTML with potential code markdown
@@ -165,7 +179,7 @@ export class MarkdownParser {
165
179
  // Trim whitespace and convert to lowercase for protocol check
166
180
  const trimmed = url.trim();
167
181
  const lower = trimmed.toLowerCase();
168
-
182
+
169
183
  // Allow safe protocols
170
184
  const safeProtocols = [
171
185
  'http://',
@@ -174,22 +188,22 @@ export class MarkdownParser {
174
188
  'ftp://',
175
189
  'ftps://'
176
190
  ];
177
-
191
+
178
192
  // Check if URL starts with a safe protocol
179
193
  const hasSafeProtocol = safeProtocols.some(protocol => lower.startsWith(protocol));
180
-
194
+
181
195
  // Allow relative URLs (starting with / or # or no protocol)
182
- const isRelative = trimmed.startsWith('/') ||
183
- trimmed.startsWith('#') ||
196
+ const isRelative = trimmed.startsWith('/') ||
197
+ trimmed.startsWith('#') ||
184
198
  trimmed.startsWith('?') ||
185
199
  trimmed.startsWith('.') ||
186
200
  (!trimmed.includes(':') && !trimmed.includes('//'));
187
-
201
+
188
202
  // If safe protocol or relative URL, return as-is
189
203
  if (hasSafeProtocol || isRelative) {
190
204
  return url;
191
205
  }
192
-
206
+
193
207
  // Block dangerous protocols (javascript:, data:, vbscript:, etc.)
194
208
  return '#';
195
209
  }
@@ -246,6 +260,7 @@ export class MarkdownParser {
246
260
  });
247
261
 
248
262
  // Process other inline elements on text with placeholders
263
+ html = this.parseStrikethrough(html);
249
264
  html = this.parseBold(html);
250
265
  html = this.parseItalic(html);
251
266
 
@@ -264,33 +279,33 @@ export class MarkdownParser {
264
279
  */
265
280
  static parseLine(line) {
266
281
  let html = this.escapeHtml(line);
267
-
282
+
268
283
  // Preserve indentation
269
284
  html = this.preserveIndentation(html, line);
270
-
285
+
271
286
  // Check for block elements first
272
287
  const horizontalRule = this.parseHorizontalRule(html);
273
288
  if (horizontalRule) return horizontalRule;
274
-
289
+
275
290
  const codeBlock = this.parseCodeBlock(html);
276
291
  if (codeBlock) return codeBlock;
277
-
292
+
278
293
  // Parse block elements
279
294
  html = this.parseHeader(html);
280
295
  html = this.parseBlockquote(html);
281
296
  html = this.parseBulletList(html);
282
297
  html = this.parseNumberedList(html);
283
-
298
+
284
299
  // Parse inline elements
285
300
  html = this.parseInlineElements(html);
286
-
301
+
287
302
  // Wrap in div to maintain line structure
288
303
  if (html.trim() === '') {
289
304
  // Intentionally use &nbsp; for empty lines to maintain vertical spacing
290
305
  // This causes a 0->1 character count difference but preserves visual alignment
291
306
  return '<div>&nbsp;</div>';
292
307
  }
293
-
308
+
294
309
  return `<div>${html}</div>`;
295
310
  }
296
311
 
@@ -304,17 +319,17 @@ export class MarkdownParser {
304
319
  static parse(text, activeLine = -1, showActiveLineRaw = false) {
305
320
  // Reset link counter for each parse
306
321
  this.resetLinkIndex();
307
-
322
+
308
323
  const lines = text.split('\n');
309
324
  let inCodeBlock = false;
310
-
325
+
311
326
  const parsedLines = lines.map((line, index) => {
312
327
  // Show raw markdown on active line if requested
313
328
  if (showActiveLineRaw && index === activeLine) {
314
329
  const content = this.escapeHtml(line) || '&nbsp;';
315
330
  return `<div class="raw-line">${content}</div>`;
316
331
  }
317
-
332
+
318
333
  // Check if this line is a code fence
319
334
  const codeFenceRegex = /^```[^`]*$/;
320
335
  if (codeFenceRegex.test(line)) {
@@ -322,21 +337,21 @@ export class MarkdownParser {
322
337
  // Parse fence markers normally to get styled output
323
338
  return this.parseLine(line);
324
339
  }
325
-
340
+
326
341
  // If we're inside a code block, don't parse as markdown
327
342
  if (inCodeBlock) {
328
343
  const escaped = this.escapeHtml(line);
329
344
  const indented = this.preserveIndentation(escaped, line);
330
345
  return `<div>${indented || '&nbsp;'}</div>`;
331
346
  }
332
-
347
+
333
348
  // Otherwise, parse the markdown normally
334
349
  return this.parseLine(line);
335
350
  });
336
-
351
+
337
352
  // Join without newlines to prevent extra spacing
338
353
  const html = parsedLines.join('');
339
-
354
+
340
355
  // Apply post-processing for list consolidation
341
356
  return this.postProcessHTML(html);
342
357
  }
@@ -352,25 +367,25 @@ export class MarkdownParser {
352
367
  // In Node.js environment - do manual post-processing
353
368
  return this.postProcessHTMLManual(html);
354
369
  }
355
-
370
+
356
371
  // Parse HTML string into DOM
357
372
  const container = document.createElement('div');
358
373
  container.innerHTML = html;
359
-
374
+
360
375
  let currentList = null;
361
376
  let listType = null;
362
377
  let currentCodeBlock = null;
363
378
  let inCodeBlock = false;
364
-
379
+
365
380
  // Process all direct children - need to be careful with live NodeList
366
381
  const children = Array.from(container.children);
367
-
382
+
368
383
  for (let i = 0; i < children.length; i++) {
369
384
  const child = children[i];
370
-
385
+
371
386
  // Skip if child was already processed/removed
372
387
  if (!child.parentNode) continue;
373
-
388
+
374
389
  // Check for code fence start/end
375
390
  const codeFence = child.querySelector('.code-fence');
376
391
  if (codeFence) {
@@ -379,22 +394,22 @@ export class MarkdownParser {
379
394
  if (!inCodeBlock) {
380
395
  // Start of code block - keep fence visible, then add pre/code
381
396
  inCodeBlock = true;
382
-
397
+
383
398
  // Create the code block that will follow the fence
384
399
  currentCodeBlock = document.createElement('pre');
385
400
  const codeElement = document.createElement('code');
386
401
  currentCodeBlock.appendChild(codeElement);
387
402
  currentCodeBlock.className = 'code-block';
388
-
403
+
389
404
  // Extract language if present
390
405
  const lang = fenceText.slice(3).trim();
391
406
  if (lang) {
392
407
  codeElement.className = `language-${lang}`;
393
408
  }
394
-
409
+
395
410
  // Insert code block after the fence div (don't remove the fence)
396
411
  container.insertBefore(currentCodeBlock, child.nextSibling);
397
-
412
+
398
413
  // Store reference to the code element for adding content
399
414
  currentCodeBlock._codeElement = codeElement;
400
415
  continue;
@@ -406,7 +421,7 @@ export class MarkdownParser {
406
421
  }
407
422
  }
408
423
  }
409
-
424
+
410
425
  // Check if we're in a code block - any div that's not a code fence
411
426
  if (inCodeBlock && currentCodeBlock && child.tagName === 'DIV' && !child.querySelector('.code-fence')) {
412
427
  const codeElement = currentCodeBlock._codeElement || currentCodeBlock.querySelector('code');
@@ -422,36 +437,52 @@ export class MarkdownParser {
422
437
  child.remove();
423
438
  continue;
424
439
  }
425
-
440
+
426
441
  // Check if this div contains a list item
427
442
  let listItem = null;
428
443
  if (child.tagName === 'DIV') {
429
444
  // Look for li inside the div
430
445
  listItem = child.querySelector('li');
431
446
  }
432
-
447
+
433
448
  if (listItem) {
434
449
  const isBullet = listItem.classList.contains('bullet-list');
435
450
  const isOrdered = listItem.classList.contains('ordered-list');
436
-
451
+
437
452
  if (!isBullet && !isOrdered) {
438
453
  currentList = null;
439
454
  listType = null;
440
455
  continue;
441
456
  }
442
-
457
+
443
458
  const newType = isBullet ? 'ul' : 'ol';
444
-
459
+
445
460
  // Start new list or continue current
446
461
  if (!currentList || listType !== newType) {
447
462
  currentList = document.createElement(newType);
448
463
  container.insertBefore(currentList, child);
449
464
  listType = newType;
450
465
  }
451
-
466
+
467
+ // Extract and preserve indentation from the div before moving the list item
468
+ const indentationNodes = [];
469
+ for (const node of child.childNodes) {
470
+ if (node.nodeType === 3 && node.textContent.match(/^\u00A0+$/)) {
471
+ // This is a text node containing only non-breaking spaces (indentation)
472
+ indentationNodes.push(node.cloneNode(true));
473
+ } else if (node === listItem) {
474
+ break; // Stop when we reach the list item
475
+ }
476
+ }
477
+
478
+ // Add indentation to the list item
479
+ indentationNodes.forEach(node => {
480
+ listItem.insertBefore(node, listItem.firstChild);
481
+ });
482
+
452
483
  // Move the list item to the current list
453
484
  currentList.appendChild(listItem);
454
-
485
+
455
486
  // Remove the now-empty div wrapper
456
487
  child.remove();
457
488
  } else {
@@ -460,7 +491,7 @@ export class MarkdownParser {
460
491
  listType = null;
461
492
  }
462
493
  }
463
-
494
+
464
495
  return container.innerHTML;
465
496
  }
466
497
 
@@ -471,25 +502,53 @@ export class MarkdownParser {
471
502
  */
472
503
  static postProcessHTMLManual(html) {
473
504
  let processed = html;
474
-
505
+
475
506
  // Process unordered lists
476
507
  processed = processed.replace(/((?:<div>(?:&nbsp;)*<li class="bullet-list">.*?<\/li><\/div>\s*)+)/gs, (match) => {
477
- const items = match.match(/<li class="bullet-list">.*?<\/li>/gs) || [];
478
- if (items.length > 0) {
508
+ const divs = match.match(/<div>(?:&nbsp;)*<li class="bullet-list">.*?<\/li><\/div>/gs) || [];
509
+ if (divs.length > 0) {
510
+ const items = divs.map(div => {
511
+ // Extract indentation and list item
512
+ const indentMatch = div.match(/<div>((?:&nbsp;)*)<li/);
513
+ const listItemMatch = div.match(/<li class="bullet-list">.*?<\/li>/);
514
+
515
+ if (indentMatch && listItemMatch) {
516
+ const indentation = indentMatch[1];
517
+ const listItem = listItemMatch[0];
518
+ // Insert indentation at the start of the list item content
519
+ return listItem.replace(/<li class="bullet-list">/, `<li class="bullet-list">${indentation}`);
520
+ }
521
+ return listItemMatch ? listItemMatch[0] : '';
522
+ }).filter(Boolean);
523
+
479
524
  return '<ul>' + items.join('') + '</ul>';
480
525
  }
481
526
  return match;
482
527
  });
483
-
528
+
484
529
  // Process ordered lists
485
530
  processed = processed.replace(/((?:<div>(?:&nbsp;)*<li class="ordered-list">.*?<\/li><\/div>\s*)+)/gs, (match) => {
486
- const items = match.match(/<li class="ordered-list">.*?<\/li>/gs) || [];
487
- if (items.length > 0) {
531
+ const divs = match.match(/<div>(?:&nbsp;)*<li class="ordered-list">.*?<\/li><\/div>/gs) || [];
532
+ if (divs.length > 0) {
533
+ const items = divs.map(div => {
534
+ // Extract indentation and list item
535
+ const indentMatch = div.match(/<div>((?:&nbsp;)*)<li/);
536
+ const listItemMatch = div.match(/<li class="ordered-list">.*?<\/li>/);
537
+
538
+ if (indentMatch && listItemMatch) {
539
+ const indentation = indentMatch[1];
540
+ const listItem = listItemMatch[0];
541
+ // Insert indentation at the start of the list item content
542
+ return listItem.replace(/<li class="ordered-list">/, `<li class="ordered-list">${indentation}`);
543
+ }
544
+ return listItemMatch ? listItemMatch[0] : '';
545
+ }).filter(Boolean);
546
+
488
547
  return '<ol>' + items.join('') + '</ol>';
489
548
  }
490
549
  return match;
491
550
  });
492
-
551
+
493
552
  // Process code blocks - KEEP the fence markers for alignment AND use semantic pre/code
494
553
  const codeBlockRegex = /<div><span class="code-fence">(```[^<]*)<\/span><\/div>(.*?)<div><span class="code-fence">(```)<\/span><\/div>/gs;
495
554
  processed = processed.replace(codeBlockRegex, (match, openFence, content, closeFence) => {
@@ -501,20 +560,182 @@ export class MarkdownParser {
501
560
  .replace(/&nbsp;/g, ' ');
502
561
  return text;
503
562
  }).join('\n');
504
-
563
+
505
564
  // Extract language from the opening fence
506
565
  const lang = openFence.slice(3).trim();
507
566
  const langClass = lang ? ` class="language-${lang}"` : '';
508
-
567
+
509
568
  // Keep fence markers visible as separate divs, with pre/code block between them
510
569
  let result = `<div><span class="code-fence">${openFence}</span></div>`;
511
570
  // Content is already escaped, don't double-escape
512
571
  result += `<pre class="code-block"><code${langClass}>${codeContent}</code></pre>`;
513
572
  result += `<div><span class="code-fence">${closeFence}</span></div>`;
514
-
573
+
515
574
  return result;
516
575
  });
517
-
576
+
518
577
  return processed;
519
578
  }
520
- }
579
+
580
+ /**
581
+ * List pattern definitions
582
+ */
583
+ static LIST_PATTERNS = {
584
+ bullet: /^(\s*)([-*+])\s+(.*)$/,
585
+ numbered: /^(\s*)(\d+)\.\s+(.*)$/,
586
+ checkbox: /^(\s*)-\s+\[([ x])\]\s+(.*)$/
587
+ };
588
+
589
+ /**
590
+ * Get list context at cursor position
591
+ * @param {string} text - Full text content
592
+ * @param {number} cursorPosition - Current cursor position
593
+ * @returns {Object} List context information
594
+ */
595
+ static getListContext(text, cursorPosition) {
596
+ // Find the line containing the cursor
597
+ const lines = text.split('\n');
598
+ let currentPos = 0;
599
+ let lineIndex = 0;
600
+ let lineStart = 0;
601
+
602
+ for (let i = 0; i < lines.length; i++) {
603
+ const lineLength = lines[i].length;
604
+ if (currentPos + lineLength >= cursorPosition) {
605
+ lineIndex = i;
606
+ lineStart = currentPos;
607
+ break;
608
+ }
609
+ currentPos += lineLength + 1; // +1 for newline
610
+ }
611
+
612
+ const currentLine = lines[lineIndex];
613
+ const lineEnd = lineStart + currentLine.length;
614
+
615
+ // Check for checkbox first (most specific)
616
+ const checkboxMatch = currentLine.match(this.LIST_PATTERNS.checkbox);
617
+ if (checkboxMatch) {
618
+ return {
619
+ inList: true,
620
+ listType: 'checkbox',
621
+ indent: checkboxMatch[1],
622
+ marker: '-',
623
+ checked: checkboxMatch[2] === 'x',
624
+ content: checkboxMatch[3],
625
+ lineStart,
626
+ lineEnd,
627
+ markerEndPos: lineStart + checkboxMatch[1].length + checkboxMatch[2].length + 5 // indent + "- [ ] "
628
+ };
629
+ }
630
+
631
+ // Check for bullet list
632
+ const bulletMatch = currentLine.match(this.LIST_PATTERNS.bullet);
633
+ if (bulletMatch) {
634
+ return {
635
+ inList: true,
636
+ listType: 'bullet',
637
+ indent: bulletMatch[1],
638
+ marker: bulletMatch[2],
639
+ content: bulletMatch[3],
640
+ lineStart,
641
+ lineEnd,
642
+ markerEndPos: lineStart + bulletMatch[1].length + bulletMatch[2].length + 1 // indent + marker + space
643
+ };
644
+ }
645
+
646
+ // Check for numbered list
647
+ const numberedMatch = currentLine.match(this.LIST_PATTERNS.numbered);
648
+ if (numberedMatch) {
649
+ return {
650
+ inList: true,
651
+ listType: 'numbered',
652
+ indent: numberedMatch[1],
653
+ marker: parseInt(numberedMatch[2]),
654
+ content: numberedMatch[3],
655
+ lineStart,
656
+ lineEnd,
657
+ markerEndPos: lineStart + numberedMatch[1].length + numberedMatch[2].length + 2 // indent + number + ". "
658
+ };
659
+ }
660
+
661
+ // Not in a list
662
+ return {
663
+ inList: false,
664
+ listType: null,
665
+ indent: '',
666
+ marker: null,
667
+ content: currentLine,
668
+ lineStart,
669
+ lineEnd,
670
+ markerEndPos: lineStart
671
+ };
672
+ }
673
+
674
+ /**
675
+ * Create a new list item based on context
676
+ * @param {Object} context - List context from getListContext
677
+ * @returns {string} New list item text
678
+ */
679
+ static createNewListItem(context) {
680
+ switch (context.listType) {
681
+ case 'bullet':
682
+ return `${context.indent}${context.marker} `;
683
+ case 'numbered':
684
+ return `${context.indent}${context.marker + 1}. `;
685
+ case 'checkbox':
686
+ return `${context.indent}- [ ] `;
687
+ default:
688
+ return '';
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Renumber all numbered lists in text
694
+ * @param {string} text - Text containing numbered lists
695
+ * @returns {string} Text with renumbered lists
696
+ */
697
+ static renumberLists(text) {
698
+ const lines = text.split('\n');
699
+ const numbersByIndent = new Map();
700
+ let inList = false;
701
+
702
+ const result = lines.map(line => {
703
+ const match = line.match(this.LIST_PATTERNS.numbered);
704
+
705
+ if (match) {
706
+ const indent = match[1];
707
+ const indentLevel = indent.length;
708
+ const content = match[3];
709
+
710
+ // If we weren't in a list or indent changed, reset lower levels
711
+ if (!inList) {
712
+ numbersByIndent.clear();
713
+ }
714
+
715
+ // Get the next number for this indent level
716
+ const currentNumber = (numbersByIndent.get(indentLevel) || 0) + 1;
717
+ numbersByIndent.set(indentLevel, currentNumber);
718
+
719
+ // Clear deeper indent levels
720
+ for (const [level] of numbersByIndent) {
721
+ if (level > indentLevel) {
722
+ numbersByIndent.delete(level);
723
+ }
724
+ }
725
+
726
+ inList = true;
727
+ return `${indent}${currentNumber}. ${content}`;
728
+ } else {
729
+ // Not a numbered list item
730
+ if (line.trim() === '' || !line.match(/^\s/)) {
731
+ // Empty line or non-indented line breaks the list
732
+ inList = false;
733
+ numbersByIndent.clear();
734
+ }
735
+ return line;
736
+ }
737
+ });
738
+
739
+ return result.join('\n');
740
+ }
741
+ }
package/src/styles.js CHANGED
@@ -325,6 +325,14 @@ export function generateStyles(options = {}) {
325
325
  font-style: italic !important;
326
326
  }
327
327
 
328
+ /* Strikethrough text */
329
+ .overtype-wrapper .overtype-preview del {
330
+ color: var(--del, #ee964b) !important;
331
+ text-decoration: line-through !important;
332
+ text-decoration-color: var(--del, #ee964b) !important;
333
+ text-decoration-thickness: 1px !important;
334
+ }
335
+
328
336
  /* Inline code */
329
337
  .overtype-wrapper .overtype-preview code {
330
338
  background: var(--code-bg, rgba(244, 211, 94, 0.4)) !important;
@@ -468,10 +476,10 @@ export function generateStyles(options = {}) {
468
476
  height: 8px !important;
469
477
  background: #4caf50 !important;
470
478
  border-radius: 50% !important;
471
- animation: pulse 2s infinite !important;
479
+ animation: overtype-pulse 2s infinite !important;
472
480
  }
473
481
 
474
- @keyframes pulse {
482
+ @keyframes overtype-pulse {
475
483
  0%, 100% { opacity: 1; transform: scale(1); }
476
484
  50% { opacity: 0.6; transform: scale(1.2); }
477
485
  }
@@ -479,19 +487,19 @@ export function generateStyles(options = {}) {
479
487
 
480
488
  /* Toolbar Styles */
481
489
  .overtype-toolbar {
482
- display: flex;
483
- align-items: center;
484
- gap: 4px;
490
+ display: flex !important;
491
+ align-items: center !important;
492
+ gap: 4px !important;
485
493
  padding: 8px !important; /* Override reset */
486
494
  background: var(--toolbar-bg, var(--bg-primary, #f8f9fa)) !important; /* Override reset */
487
495
  overflow-x: auto !important; /* Allow horizontal scrolling */
488
496
  overflow-y: hidden !important; /* Hide vertical overflow */
489
- -webkit-overflow-scrolling: touch;
490
- flex-shrink: 0;
497
+ -webkit-overflow-scrolling: touch !important;
498
+ flex-shrink: 0 !important;
491
499
  height: auto !important;
492
500
  grid-row: 1 !important; /* Always first row in grid */
493
501
  position: relative !important; /* Override reset */
494
- z-index: 100; /* Ensure toolbar is above wrapper */
502
+ z-index: 100 !important; /* Ensure toolbar is above wrapper */
495
503
  scrollbar-width: thin; /* Thin scrollbar on Firefox */
496
504
  }
497
505