overtype 2.3.9 → 2.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overtype",
3
- "version": "2.3.9",
3
+ "version": "2.4.0",
4
4
  "description": "A lightweight markdown editor library with perfect WYSIWYG alignment using an invisible textarea overlay",
5
5
  "main": "dist/overtype.cjs",
6
6
  "module": "dist/overtype.esm.js",
@@ -13,7 +13,7 @@
13
13
  "types": "./dist/overtype.d.ts",
14
14
  "import": "./dist/overtype.esm.js",
15
15
  "require": "./dist/overtype.cjs",
16
- "browser": "./dist/overtype.iife.min.js"
16
+ "browser": "./dist/overtype.min.js"
17
17
  },
18
18
  "./parser": {
19
19
  "import": "./src/parser.js",
@@ -31,7 +31,7 @@
31
31
  "build:prod": "npm test && npm run build",
32
32
  "dev": "http-server website -p 8080 -c-1",
33
33
  "watch": "node scripts/build.js --watch",
34
- "test": "node test/overtype.test.js && node test/preview-mode.test.js && node test/links.test.js && node test/api-methods.test.js && node test/comprehensive-alignment.test.js && node test/sanctuary-parsing.test.js && node test/mode-switching.test.js && node test/syntax-highlighting.test.js && node test/webcomponent.test.js && node test/custom-syntax.test.js && node test/auto-theme.test.js && npm run test:types",
34
+ "test": "node test/overtype.test.js && node test/preview-mode.test.js && node test/links.test.js && node test/api-methods.test.js && node test/keyboard-accessibility.test.js && node test/comprehensive-alignment.test.js && node test/sanctuary-parsing.test.js && node test/mode-switching.test.js && node test/toolbar.test.js && node test/syntax-highlighting.test.js && node test/webcomponent.test.js && node test/custom-syntax.test.js && node test/auto-theme.test.js && npm run test:types",
35
35
  "test:main": "node test/overtype.test.js",
36
36
  "test:preview": "node test/preview-mode.test.js",
37
37
  "test:links": "node test/links.test.js",
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { computePosition, offset, shift, flip } from '@floating-ui/dom';
7
+ import { MarkdownParser } from './parser.js';
7
8
 
8
9
  export class LinkTooltip {
9
10
  constructor(editor) {
@@ -85,7 +86,10 @@ export class LinkTooltip {
85
86
  e.preventDefault();
86
87
  e.stopPropagation();
87
88
  if (this.currentLink) {
88
- window.open(this.currentLink.url, '_blank');
89
+ const safeUrl = MarkdownParser.sanitizeUrl(this.currentLink.url);
90
+ if (safeUrl !== '#') {
91
+ window.open(safeUrl, '_blank');
92
+ }
89
93
  this.hide();
90
94
  }
91
95
  });
@@ -123,7 +127,7 @@ export class LinkTooltip {
123
127
  if (position >= start && position <= end) {
124
128
  return {
125
129
  text: match[1],
126
- url: match[2],
130
+ url: this.transformUrl(match[2]),
127
131
  index: linkIndex,
128
132
  start: start,
129
133
  end: end
@@ -135,6 +139,18 @@ export class LinkTooltip {
135
139
  return null;
136
140
  }
137
141
 
142
+ transformUrl(url) {
143
+ const transform = this.editor.options.transformLinkUrl;
144
+ if (typeof transform !== 'function') return url;
145
+ try {
146
+ const result = transform(url);
147
+ return typeof result === 'string' ? result : url;
148
+ } catch (e) {
149
+ console.warn('transformLinkUrl threw:', e);
150
+ return url;
151
+ }
152
+ }
153
+
138
154
  async show(linkInfo) {
139
155
  this.currentLink = linkInfo;
140
156
  this.cancelHide();
package/src/overtype.d.ts CHANGED
@@ -84,6 +84,15 @@ export interface ToolbarButton {
84
84
  setValue: (value: string) => void;
85
85
  event: MouseEvent;
86
86
  }) => void | Promise<void>;
87
+
88
+ /** Canonical action identifier used by shortcuts and performAction */
89
+ actionId?: string;
90
+
91
+ /** Return true when this button should be announced as pressed */
92
+ isActive?: (context: {
93
+ editor: OverType;
94
+ activeFormats: string[];
95
+ }) => boolean;
87
96
  }
88
97
 
89
98
  export interface MobileOptions {
@@ -122,6 +131,7 @@ export interface Options {
122
131
  spellcheck?: boolean; // Browser spellcheck (default: false)
123
132
  statsFormatter?: (stats: Stats) => string;
124
133
  codeHighlighter?: ((code: string, language: string) => string) | null; // Per-instance code highlighter
134
+ transformLinkUrl?: ((url: string) => string) | null; // Transform URLs shown/opened in the link tooltip
125
135
 
126
136
  // Theme (deprecated in favor of global theme)
127
137
  theme?: string | Theme;
@@ -135,6 +145,7 @@ export interface Options {
135
145
  mimeTypes?: string[];
136
146
  batch?: boolean;
137
147
  onInsertFile: (file: File | File[]) => Promise<string | string[]>;
148
+ onRemoveFile?: (info: { url: string; filename: string; file: File }) => void;
138
149
  };
139
150
 
140
151
  // Callbacks
@@ -207,6 +218,8 @@ export interface OverTypeInstance {
207
218
  showToolbar(): void;
208
219
  hideToolbar(): void;
209
220
  insertAtCursor(text: string): void;
221
+ indentSelection(): void;
222
+ outdentSelection(): void;
210
223
 
211
224
  // HTML output methods
212
225
  getRenderedHTML(options?: RenderOptions): string;
package/src/overtype.js CHANGED
@@ -12,6 +12,25 @@ import { Toolbar } from './toolbar.js';
12
12
  import { LinkTooltip } from './link-tooltip.js';
13
13
  import { defaultToolbarButtons, toolbarButtons as builtinToolbarButtons } from './toolbar-buttons.js';
14
14
 
15
+ let _isSafariCache;
16
+ /**
17
+ * Detect Safari (desktop, iOS, and iPadOS), excluding Chromium/Firefox-on-iOS.
18
+ * Memoized; guards against non-browser environments.
19
+ * @returns {boolean}
20
+ */
21
+ function isSafariBrowser() {
22
+ if (_isSafariCache !== undefined) return _isSafariCache;
23
+ _isSafariCache = false;
24
+ if (typeof navigator !== 'undefined') {
25
+ const ua = navigator.userAgent || '';
26
+ _isSafariCache =
27
+ /^((?!chrome|android|crios|fxios|edg|opr).)*safari/i.test(ua) ||
28
+ /iPad|iPhone|iPod/.test(ua) ||
29
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1 && /Safari/.test(ua));
30
+ }
31
+ return _isSafariCache;
32
+ }
33
+
15
34
  /**
16
35
  * Build action map from toolbar button configurations
17
36
  * @param {Array} buttons - Array of button config objects
@@ -131,6 +150,8 @@ class OverType {
131
150
  this.options = this._mergeOptions(options);
132
151
  this.instanceId = ++OverType.instanceCount;
133
152
  this.initialized = false;
153
+ this._isSafari = isSafariBrowser();
154
+ this._safariReflowRaf = null;
134
155
 
135
156
  // Inject styles if needed
136
157
  OverType.injectStyles();
@@ -225,7 +246,8 @@ class OverType {
225
246
  statsFormatter: null,
226
247
  smartLists: true, // Enable smart list continuation
227
248
  codeHighlighter: null, // Per-instance code highlighter
228
- spellcheck: false // Browser spellcheck (disabled by default)
249
+ spellcheck: false, // Browser spellcheck (disabled by default)
250
+ transformLinkUrl: null // Transform URLs shown/opened in the link tooltip
229
251
  };
230
252
 
231
253
  // Remove theme and colors from options - these are now global
@@ -296,6 +318,9 @@ class OverType {
296
318
 
297
319
  // Disable autofill, spellcheck, and extensions
298
320
  this._configureTextarea();
321
+ this._ensureTextareaId();
322
+
323
+ this._syncPreviewInteractivity();
299
324
 
300
325
  // Apply any new options
301
326
  this._applyOptions();
@@ -390,6 +415,8 @@ class OverType {
390
415
  });
391
416
  }
392
417
 
418
+ this._ensureTextareaId();
419
+
393
420
  // Create preview div
394
421
  this.preview = document.createElement('div');
395
422
  this.preview.className = 'overtype-preview';
@@ -429,6 +456,8 @@ class OverType {
429
456
  // Ensure auto-resize class is removed if not using auto-resize
430
457
  this.container.classList.remove('overtype-auto-resize');
431
458
  }
459
+
460
+ this._syncPreviewInteractivity();
432
461
  }
433
462
 
434
463
  /**
@@ -445,6 +474,35 @@ class OverType {
445
474
  this.textarea.setAttribute('data-enable-grammarly', 'false');
446
475
  }
447
476
 
477
+ /**
478
+ * Ensure the textarea can be referenced by aria-controls
479
+ * @private
480
+ */
481
+ _ensureTextareaId() {
482
+ if (!this.textarea.id) {
483
+ this.textarea.id = `overtype-${this.instanceId}-input`;
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Keep rendered preview content out of keyboard navigation until Preview mode.
489
+ * @private
490
+ */
491
+ _syncPreviewInteractivity() {
492
+ if (!this.preview || !this.container) return;
493
+
494
+ const isPreviewMode = this.container.dataset.mode === 'preview';
495
+ this.preview.inert = !isPreviewMode;
496
+ this.preview.toggleAttribute('inert', !isPreviewMode);
497
+
498
+ if (isPreviewMode) {
499
+ this.preview.removeAttribute('aria-hidden');
500
+ return;
501
+ }
502
+
503
+ this.preview.setAttribute('aria-hidden', 'true');
504
+ }
505
+
448
506
  /**
449
507
  * Create and setup toolbar
450
508
  * @private
@@ -605,6 +663,7 @@ class OverType {
605
663
  }
606
664
 
607
665
  this._fileUploadCounter = 0;
666
+ this._uploadedFiles = new Map(); // url -> { filename, file }
608
667
  this._boundHandleFilePaste = this._handleFilePaste.bind(this);
609
668
  this._boundHandleFileDrop = this._handleFileDrop.bind(this);
610
669
  this._boundHandleDragOver = this._handleDragOver.bind(this);
@@ -616,6 +675,50 @@ class OverType {
616
675
  this.fileUploadInitialized = true;
617
676
  }
618
677
 
678
+ /**
679
+ * Extract URLs from markdown link syntax: [text](url) or ![text](url).
680
+ * @private
681
+ */
682
+ _extractMarkdownUrls(text) {
683
+ const urls = [];
684
+ const re = /!?\[[^\]]*\]\(([^)\s]+)/g;
685
+ let m;
686
+ while ((m = re.exec(text)) !== null) urls.push(m[1]);
687
+ return urls;
688
+ }
689
+
690
+ /**
691
+ * Track URLs that were just inserted, pairing each with the source File.
692
+ * If multiple URLs appear in one inserted block, all get associated with
693
+ * the same file (rare; happens if onInsertFile returns several links).
694
+ * @private
695
+ */
696
+ _trackInsertedUrls(insertedText, file) {
697
+ if (!this._uploadedFiles || !file || !insertedText) return;
698
+ for (const url of this._extractMarkdownUrls(insertedText)) {
699
+ this._uploadedFiles.set(url, { filename: file.name, file });
700
+ }
701
+ }
702
+
703
+ /**
704
+ * Diff the tracked-URL set against the current value and fire
705
+ * fileUpload.onRemoveFile for any URL no longer present.
706
+ * @private
707
+ */
708
+ _checkForRemovedUploads() {
709
+ if (!this._uploadedFiles || this._uploadedFiles.size === 0) return;
710
+ const cb = this.options.fileUpload?.onRemoveFile;
711
+ const value = this.textarea.value;
712
+ const removed = [];
713
+ for (const [url, info] of this._uploadedFiles) {
714
+ if (!value.includes(url)) removed.push({ url, info });
715
+ }
716
+ for (const { url, info } of removed) {
717
+ this._uploadedFiles.delete(url);
718
+ if (cb) cb({ url, filename: info.filename, file: info.file });
719
+ }
720
+ }
721
+
619
722
  _handleFilePaste(e) {
620
723
  if (!e?.clipboardData?.files?.length) return;
621
724
  e.preventDefault();
@@ -647,6 +750,7 @@ class OverType {
647
750
 
648
751
  this.options.fileUpload.onInsertFile(file).then((text) => {
649
752
  this.textarea.value = this.textarea.value.replace(placeholder, text);
753
+ this._trackInsertedUrls(text, file);
650
754
  this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
651
755
  }, (error) => {
652
756
  console.error('OverType: File upload failed', error);
@@ -660,6 +764,7 @@ class OverType {
660
764
  const texts = Array.isArray(result) ? result : [result];
661
765
  texts.forEach((text, index) => {
662
766
  this.textarea.value = this.textarea.value.replace(files[index].placeholder, text);
767
+ this._trackInsertedUrls(text, files[index].file);
663
768
  });
664
769
  this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
665
770
  }, (error) => {
@@ -683,6 +788,7 @@ class OverType {
683
788
  this._boundHandleFilePaste = null;
684
789
  this._boundHandleFileDrop = null;
685
790
  this._boundHandleDragOver = null;
791
+ this._uploadedFiles = null;
686
792
  this.fileUploadInitialized = false;
687
793
  }
688
794
 
@@ -746,8 +852,11 @@ class OverType {
746
852
  * @private
747
853
  */
748
854
  _notifyChange() {
749
- if (!this.options.onChange || !this.initialized) return;
750
- this.options.onChange(this.textarea.value, this);
855
+ if (!this.initialized) return;
856
+ this._checkForRemovedUploads();
857
+ if (this.options.onChange) {
858
+ this.options.onChange(this.textarea.value, this);
859
+ }
751
860
  }
752
861
 
753
862
  /**
@@ -799,6 +908,28 @@ class OverType {
799
908
  handleInput(event) {
800
909
  this.updatePreview();
801
910
  this._notifyChange();
911
+ this._scheduleSafariReflow();
912
+ }
913
+
914
+ /**
915
+ * Force Safari to re-shape stale textarea text after an edit.
916
+ * Safari can leave a textarea's glyph layout cached after incremental edits,
917
+ * desyncing the caret/wrap from the styled preview overlay. Toggling
918
+ * letter-spacing (with !important to beat the stylesheet rule) and reading
919
+ * offsetHeight forces a synchronous re-shape. Safari-only, coalesced to one
920
+ * run per animation frame.
921
+ * @private
922
+ */
923
+ _scheduleSafariReflow() {
924
+ if (!this._isSafari || this._safariReflowRaf) return;
925
+ this._safariReflowRaf = requestAnimationFrame(() => {
926
+ this._safariReflowRaf = null;
927
+ const ta = this.textarea;
928
+ if (!ta) return;
929
+ ta.style.setProperty('letter-spacing', '-0.001px', 'important');
930
+ void ta.offsetHeight;
931
+ ta.style.removeProperty('letter-spacing');
932
+ });
802
933
  }
803
934
 
804
935
  /**
@@ -826,75 +957,16 @@ class OverType {
826
957
  * @private
827
958
  */
828
959
  handleKeydown(event) {
829
- // Handle Tab key to prevent focus loss and insert spaces
960
+ // Let collapsed Tab/Shift+Tab use native focus traversal.
830
961
  if (event.key === 'Tab') {
831
962
  const start = this.textarea.selectionStart;
832
963
  const end = this.textarea.selectionEnd;
833
- const value = this.textarea.value;
834
964
 
835
- // If Shift+Tab without a selection, allow default behavior (navigate to previous element)
836
- if (event.shiftKey && start === end) {
965
+ if (start !== end && this._canEditTextarea()) {
966
+ event.preventDefault();
967
+ event.shiftKey ? this.outdentSelection() : this.indentSelection();
837
968
  return;
838
969
  }
839
-
840
- event.preventDefault();
841
-
842
- // If there's a selection, indent/outdent based on shift key
843
- if (start !== end && event.shiftKey) {
844
- // Outdent: remove 2 spaces from start of each selected line
845
- const before = value.substring(0, start);
846
- const selection = value.substring(start, end);
847
- const after = value.substring(end);
848
-
849
- const lines = selection.split('\n');
850
- const outdented = lines.map(line => line.replace(/^ /, '')).join('\n');
851
-
852
- // Try to use execCommand first to preserve undo history
853
- if (document.execCommand) {
854
- // Select the text that needs to be replaced
855
- this.textarea.setSelectionRange(start, end);
856
- document.execCommand('insertText', false, outdented);
857
- } else {
858
- // Fallback to direct manipulation
859
- this.textarea.value = before + outdented + after;
860
- this.textarea.selectionStart = start;
861
- this.textarea.selectionEnd = start + outdented.length;
862
- }
863
- } else if (start !== end) {
864
- // Indent: add 2 spaces to start of each selected line
865
- const before = value.substring(0, start);
866
- const selection = value.substring(start, end);
867
- const after = value.substring(end);
868
-
869
- const lines = selection.split('\n');
870
- const indented = lines.map(line => ' ' + line).join('\n');
871
-
872
- // Try to use execCommand first to preserve undo history
873
- if (document.execCommand) {
874
- // Select the text that needs to be replaced
875
- this.textarea.setSelectionRange(start, end);
876
- document.execCommand('insertText', false, indented);
877
- } else {
878
- // Fallback to direct manipulation
879
- this.textarea.value = before + indented + after;
880
- this.textarea.selectionStart = start;
881
- this.textarea.selectionEnd = start + indented.length;
882
- }
883
- } else {
884
- // No selection: just insert 2 spaces
885
- // Use execCommand to preserve undo history
886
- if (document.execCommand) {
887
- document.execCommand('insertText', false, ' ');
888
- } else {
889
- // Fallback to direct manipulation
890
- this.textarea.value = value.substring(0, start) + ' ' + value.substring(end);
891
- this.textarea.selectionStart = this.textarea.selectionEnd = start + 2;
892
- }
893
- }
894
-
895
- // Trigger input event to update preview
896
- this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
897
- return;
898
970
  }
899
971
 
900
972
  // Handle Enter key for smart list continuation
@@ -1083,6 +1155,7 @@ class OverType {
1083
1155
 
1084
1156
  if (didChange) {
1085
1157
  this._notifyChange();
1158
+ this._scheduleSafariReflow();
1086
1159
  }
1087
1160
  }
1088
1161
 
@@ -1154,6 +1227,77 @@ class OverType {
1154
1227
  getPreviewHTML() {
1155
1228
  return this.preview.innerHTML;
1156
1229
  }
1230
+
1231
+ /**
1232
+ * Indent the current line or selected lines by two spaces.
1233
+ */
1234
+ indentSelection() {
1235
+ this._replaceSelectedLines(line => ` ${line}`);
1236
+ }
1237
+
1238
+ /**
1239
+ * Outdent the current line or selected lines by up to two spaces or one tab.
1240
+ */
1241
+ outdentSelection() {
1242
+ this._replaceSelectedLines(line => line.replace(/^( {1,2}|\t)/, ''));
1243
+ }
1244
+
1245
+ /**
1246
+ * Replace full lines touched by the current selection.
1247
+ * @private
1248
+ */
1249
+ _replaceSelectedLines(transformLine) {
1250
+ if (!this._canEditTextarea()) return false;
1251
+
1252
+ const textarea = this.textarea;
1253
+ const { selectionStart, selectionEnd, value } = textarea;
1254
+ const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
1255
+ const effectiveEnd = this._effectiveSelectionEnd(value, selectionStart, selectionEnd);
1256
+ const lineEndOffset = value.indexOf('\n', effectiveEnd);
1257
+ const lineEnd = lineEndOffset === -1 ? value.length : lineEndOffset;
1258
+ const selectedLines = value.slice(lineStart, lineEnd);
1259
+ const replacement = selectedLines
1260
+ .split('\n')
1261
+ .map(transformLine)
1262
+ .join('\n');
1263
+
1264
+ if (replacement === selectedLines) return false;
1265
+
1266
+ // Replace via execCommand to preserve native undo history (matches
1267
+ // insertAtCursor); fall back to setRangeText where execCommand is unavailable.
1268
+ textarea.setSelectionRange(lineStart, lineEnd);
1269
+ let inserted = false;
1270
+ try {
1271
+ inserted = document.execCommand('insertText', false, replacement);
1272
+ } catch (_) {}
1273
+
1274
+ if (!inserted) {
1275
+ textarea.setRangeText(replacement, lineStart, lineEnd, 'preserve');
1276
+ }
1277
+
1278
+ textarea.setSelectionRange(lineStart, lineStart + replacement.length);
1279
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
1280
+
1281
+ return true;
1282
+ }
1283
+
1284
+ /**
1285
+ * @private
1286
+ */
1287
+ _effectiveSelectionEnd(value, selectionStart, selectionEnd) {
1288
+ if (selectionEnd > selectionStart && value[selectionEnd - 1] === '\n') {
1289
+ return selectionEnd - 1;
1290
+ }
1291
+
1292
+ return selectionEnd;
1293
+ }
1294
+
1295
+ /**
1296
+ * @private
1297
+ */
1298
+ _canEditTextarea() {
1299
+ return this.textarea && !this.textarea.disabled && !this.textarea.readOnly;
1300
+ }
1157
1301
 
1158
1302
  /**
1159
1303
  * Get clean HTML without any OverType-specific markup
@@ -1444,6 +1588,7 @@ class OverType {
1444
1588
  */
1445
1589
  showNormalEditMode() {
1446
1590
  this.container.dataset.mode = 'normal';
1591
+ this._syncPreviewInteractivity();
1447
1592
  this.updatePreview(); // Re-render with normal mode (e.g., show syntax markers)
1448
1593
  this._updateAutoHeight();
1449
1594
 
@@ -1462,6 +1607,7 @@ class OverType {
1462
1607
  */
1463
1608
  showPlainTextarea() {
1464
1609
  this.container.dataset.mode = 'plain';
1610
+ this._syncPreviewInteractivity();
1465
1611
  this._updateAutoHeight();
1466
1612
 
1467
1613
  // Update toolbar button if exists
@@ -1482,6 +1628,7 @@ class OverType {
1482
1628
  */
1483
1629
  showPreviewMode() {
1484
1630
  this.container.dataset.mode = 'preview';
1631
+ this._syncPreviewInteractivity();
1485
1632
  this.updatePreview(); // Re-render with preview mode (e.g., checkboxes)
1486
1633
  this._updateAutoHeight();
1487
1634
  return this;
@@ -1507,11 +1654,17 @@ class OverType {
1507
1654
  this.shortcuts.destroy();
1508
1655
  }
1509
1656
 
1657
+ // Cancel any pending Safari reflow nudge
1658
+ if (this._safariReflowRaf) {
1659
+ cancelAnimationFrame(this._safariReflowRaf);
1660
+ this._safariReflowRaf = null;
1661
+ }
1662
+
1510
1663
  // Remove DOM if created by us
1511
1664
  if (this.wrapper) {
1512
1665
  const content = this.getValue();
1513
1666
  this.wrapper.remove();
1514
-
1667
+
1515
1668
  // Restore original content
1516
1669
  this.element.textContent = content;
1517
1670
  }
@@ -1547,11 +1700,19 @@ class OverType {
1547
1700
 
1548
1701
  // Parse data-ot-* attributes (kebab-case to camelCase)
1549
1702
  for (const attr of el.attributes) {
1550
- if (attr.name.startsWith('data-ot-')) {
1551
- const kebab = attr.name.slice(8); // Remove 'data-ot-'
1552
- const key = kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1553
- options[key] = OverType._parseDataValue(attr.value);
1703
+ if (!attr.name.startsWith('data-ot-')) continue;
1704
+ const kebab = attr.name.slice(8); // Remove 'data-ot-'
1705
+
1706
+ // data-ot-textarea-<attr> maps onto textareaProps (e.g. data-ot-textarea-required).
1707
+ // data-ot-textarea-props is the whole-object JSON form, handled generically below.
1708
+ if (kebab.startsWith('textarea-') && kebab !== 'textarea-props') {
1709
+ const propKey = kebab.slice(9).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1710
+ options.textareaProps = { ...(options.textareaProps || {}), [propKey]: OverType._parseDataValue(attr.value) };
1711
+ continue;
1554
1712
  }
1713
+
1714
+ const key = kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1715
+ options[key] = OverType._parseDataValue(attr.value);
1555
1716
  }
1556
1717
 
1557
1718
  return new OverType(el, options)[0];
@@ -1596,6 +1757,14 @@ class OverType {
1596
1757
  if (value === 'false') return false;
1597
1758
  if (value === 'null') return null;
1598
1759
  if (value !== '' && !isNaN(Number(value))) return Number(value);
1760
+ const trimmed = value.trim();
1761
+ if (trimmed[0] === '{' || trimmed[0] === '[') {
1762
+ try {
1763
+ return JSON.parse(trimmed);
1764
+ } catch (e) {
1765
+ return value;
1766
+ }
1767
+ }
1599
1768
  return value;
1600
1769
  }
1601
1770
 
@@ -1804,7 +1973,15 @@ class OverType {
1804
1973
  * Initialize global event listeners
1805
1974
  */
1806
1975
  static initGlobalListeners() {
1807
- if (OverType.globalListenersInitialized) return;
1976
+ const globalScope = typeof window !== 'undefined' ? window : globalThis;
1977
+ const globalListenersKey = '__overtypeGlobalListenersInitialized';
1978
+
1979
+ if (OverType.globalListenersInitialized || globalScope[globalListenersKey]) {
1980
+ OverType.globalListenersInitialized = true;
1981
+ return;
1982
+ }
1983
+
1984
+ globalScope[globalListenersKey] = true;
1808
1985
 
1809
1986
  // Input event
1810
1987
  document.addEventListener('input', (e) => {
package/src/shortcuts.js CHANGED
@@ -22,6 +22,18 @@ export class ShortcutsManager {
22
22
 
23
23
  if (!modKey) return false;
24
24
 
25
+ if (event.key === ']') {
26
+ event.preventDefault();
27
+ this.editor.indentSelection();
28
+ return true;
29
+ }
30
+
31
+ if (event.key === '[') {
32
+ event.preventDefault();
33
+ this.editor.outdentSelection();
34
+ return true;
35
+ }
36
+
25
37
  let actionId = null;
26
38
 
27
39
  switch (event.key.toLowerCase()) {