overtype 1.2.7 → 2.0.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,676 @@
1
+ /**
2
+ * OverType Web Component
3
+ * A custom element wrapper for the OverType markdown editor with Shadow DOM isolation
4
+ * @version 1.0.0
5
+ * @license MIT
6
+ */
7
+
8
+ import OverType from './overtype.js';
9
+ import { generateStyles } from './styles.js';
10
+ import { getTheme } from './themes.js';
11
+
12
+ // Constants for better maintainability
13
+ const CONTAINER_CLASS = 'overtype-webcomponent-container';
14
+ const DEFAULT_PLACEHOLDER = 'Start typing...';
15
+ const OBSERVED_ATTRIBUTES = [
16
+ 'value', 'theme', 'toolbar', 'height', 'min-height', 'max-height',
17
+ 'placeholder', 'font-size', 'line-height', 'padding', 'auto-resize',
18
+ 'autofocus', 'show-stats', 'smart-lists', 'readonly'
19
+ ];
20
+
21
+ /**
22
+ * OverType Editor Web Component
23
+ * Provides a declarative API with complete style isolation via Shadow DOM
24
+ */
25
+ class OverTypeEditor extends HTMLElement {
26
+ constructor() {
27
+ super();
28
+
29
+ // Create shadow root for style isolation
30
+ this.attachShadow({ mode: 'open' });
31
+
32
+ // Initialize instance variables
33
+ this._editor = null;
34
+ this._initialized = false;
35
+ this._pendingOptions = {};
36
+ this._styleVersion = 0;
37
+ this._baseStyleElement = null; // Track the component's base stylesheet
38
+ this._selectionChangeHandler = null; // Track selectionchange listener for cleanup
39
+
40
+ // Track initialization state
41
+ this._isConnected = false;
42
+
43
+ // Bind methods to maintain context
44
+ this._handleChange = this._handleChange.bind(this);
45
+ this._handleKeydown = this._handleKeydown.bind(this);
46
+ }
47
+
48
+ /**
49
+ * Decode common escape sequences from attribute string values
50
+ * @private
51
+ * @param {string|null|undefined} str
52
+ * @returns {string}
53
+ */
54
+ _decodeValue(str) {
55
+ if (typeof str !== 'string') return '';
56
+ // Replace common escape sequences (keep order: \\ first)
57
+ return str.replace(/\\r/g, '\r').replace(/\\n/g, '\n').replace(/\\t/g, '\t');
58
+ }
59
+
60
+ // Note: _encodeValue removed as it's currently unused
61
+ // Can be re-added if needed for future attribute encoding
62
+
63
+ /**
64
+ * Define observed attributes for reactive updates
65
+ */
66
+ static get observedAttributes() {
67
+ return OBSERVED_ATTRIBUTES;
68
+ }
69
+
70
+ /**
71
+ * Component connected to DOM - initialize editor
72
+ */
73
+ connectedCallback() {
74
+ this._isConnected = true;
75
+ this._initializeEditor();
76
+ }
77
+
78
+ /**
79
+ * Component disconnected from DOM - cleanup
80
+ */
81
+ disconnectedCallback() {
82
+ this._isConnected = false;
83
+ this._cleanup();
84
+ }
85
+
86
+ /**
87
+ * Attribute changed callback - update editor options
88
+ */
89
+ attributeChangedCallback(name, oldValue, newValue) {
90
+ if (oldValue === newValue) return;
91
+ // Prevent recursive updates triggered by internal silent attribute sync
92
+ if (this._silentUpdate) return;
93
+
94
+ // Store pending changes if not initialized yet
95
+ if (!this._initialized) {
96
+ this._pendingOptions[name] = newValue;
97
+ return;
98
+ }
99
+
100
+ // Apply changes to existing editor
101
+ this._updateOption(name, newValue);
102
+ }
103
+
104
+ /**
105
+ * Initialize the OverType editor inside shadow DOM
106
+ * @private
107
+ */
108
+ _initializeEditor() {
109
+ if (this._initialized || !this._isConnected) return;
110
+
111
+ try {
112
+ // Create container inside shadow root
113
+ const container = document.createElement('div');
114
+ container.className = CONTAINER_CLASS;
115
+
116
+ // Set container height from attributes
117
+ const height = this.getAttribute('height');
118
+ const minHeight = this.getAttribute('min-height');
119
+ const maxHeight = this.getAttribute('max-height');
120
+
121
+ if (height) container.style.height = height;
122
+ if (minHeight) container.style.minHeight = minHeight;
123
+ if (maxHeight) container.style.maxHeight = maxHeight;
124
+
125
+ // Create and inject styles into shadow DOM
126
+ this._injectStyles();
127
+
128
+ // Append container to shadow root
129
+ this.shadowRoot.appendChild(container);
130
+
131
+ // Prepare OverType options from attributes
132
+ const options = this._getOptionsFromAttributes();
133
+
134
+ // Initialize OverType editor
135
+ const editorInstances = new OverType(container, options);
136
+ this._editor = editorInstances[0]; // OverType returns an array
137
+
138
+ this._initialized = true;
139
+
140
+ // Set up event listeners for Shadow DOM
141
+ // Global document listeners won't work in Shadow DOM, so we need local ones
142
+ if (this._editor && this._editor.textarea) {
143
+ // Scroll sync
144
+ this._editor.textarea.addEventListener('scroll', () => {
145
+ if (this._editor && this._editor.preview && this._editor.textarea) {
146
+ this._editor.preview.scrollTop = this._editor.textarea.scrollTop;
147
+ this._editor.preview.scrollLeft = this._editor.textarea.scrollLeft;
148
+ }
149
+ });
150
+
151
+ // Input event for preview updates
152
+ this._editor.textarea.addEventListener('input', (e) => {
153
+ if (this._editor && this._editor.handleInput) {
154
+ this._editor.handleInput(e);
155
+ }
156
+ });
157
+
158
+ // Keydown event for keyboard shortcuts and special key handling
159
+ this._editor.textarea.addEventListener('keydown', (e) => {
160
+ if (this._editor && this._editor.handleKeydown) {
161
+ this._editor.handleKeydown(e);
162
+ }
163
+ });
164
+
165
+ // Selection change event for link tooltip and stats updates
166
+ // selectionchange only fires on document, so we need to check if the active element is inside our shadow root
167
+ this._selectionChangeHandler = () => {
168
+ // Check if this web component is the active element (focused)
169
+ if (document.activeElement === this) {
170
+ // The selection is inside our shadow root
171
+ const shadowActiveElement = this.shadowRoot.activeElement;
172
+ if (shadowActiveElement && shadowActiveElement === this._editor.textarea) {
173
+ // Update stats if enabled
174
+ if (this._editor.options.showStats && this._editor.statsBar) {
175
+ this._editor._updateStats();
176
+ }
177
+
178
+ // Trigger link tooltip check
179
+ if (this._editor.linkTooltip && this._editor.linkTooltip.checkCursorPosition) {
180
+ this._editor.linkTooltip.checkCursorPosition();
181
+ }
182
+ }
183
+ }
184
+ };
185
+ document.addEventListener('selectionchange', this._selectionChangeHandler);
186
+ }
187
+
188
+ // Apply any pending option changes
189
+ this._applyPendingOptions();
190
+
191
+ // Dispatch ready event
192
+ this._dispatchEvent('ready', { editor: this._editor });
193
+ } catch (error) {
194
+ const message = error && error.message ? error.message : String(error);
195
+ // Avoid passing the raw Error object to console in jsdom to prevent recursive inspect issues
196
+ console.warn('OverType Web Component initialization failed:', message);
197
+ this._dispatchEvent('error', { error: { message } });
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Inject styles into shadow DOM for complete isolation
203
+ * @private
204
+ */
205
+ _injectStyles() {
206
+ const style = document.createElement('style');
207
+
208
+ // Get theme for style generation
209
+ const themeAttr = this.getAttribute('theme') || 'solar';
210
+ const theme = getTheme(themeAttr);
211
+
212
+ // Generate styles with current options
213
+ const options = this._getOptionsFromAttributes();
214
+ const styles = generateStyles({ ...options, theme });
215
+
216
+ // Add web component specific styles
217
+ const webComponentStyles = `
218
+ /* Web Component Host Styles */
219
+ :host {
220
+ display: block;
221
+ position: relative;
222
+ width: 100%;
223
+ height: 100%;
224
+ contain: layout style;
225
+ }
226
+
227
+ .overtype-webcomponent-container {
228
+ width: 100%;
229
+ height: 100%;
230
+ position: relative;
231
+ }
232
+
233
+ /* Override container grid layout for web component */
234
+ .overtype-container {
235
+ height: 100% !important;
236
+ }
237
+ `;
238
+
239
+ this._styleVersion += 1;
240
+ const versionBanner = `\n/* overtype-webcomponent styles v${this._styleVersion} */\n`;
241
+ style.textContent = versionBanner + styles + webComponentStyles;
242
+
243
+ // Store reference to this base stylesheet so we can remove it specifically later
244
+ this._baseStyleElement = style;
245
+ this.shadowRoot.appendChild(style);
246
+ }
247
+
248
+ /**
249
+ * Extract options from HTML attributes
250
+ * @private
251
+ * @returns {Object} OverType options object
252
+ */
253
+ _getOptionsFromAttributes() {
254
+ const options = {
255
+ // Allow authoring multi-line content via escaped sequences in attributes
256
+ // and fall back to light DOM text content if attribute is absent
257
+ value: this.getAttribute('value') !== null ? this._decodeValue(this.getAttribute('value')) : (this.textContent || '').trim(),
258
+ placeholder: this.getAttribute('placeholder') || DEFAULT_PLACEHOLDER,
259
+ toolbar: this.hasAttribute('toolbar'),
260
+ autofocus: this.hasAttribute('autofocus'),
261
+ autoResize: this.hasAttribute('auto-resize'),
262
+ showStats: this.hasAttribute('show-stats'),
263
+ smartLists: !this.hasAttribute('smart-lists') || this.getAttribute('smart-lists') !== 'false',
264
+ onChange: this._handleChange,
265
+ onKeydown: this._handleKeydown
266
+ };
267
+
268
+ // Font and layout options
269
+ const fontSize = this.getAttribute('font-size');
270
+ if (fontSize) options.fontSize = fontSize;
271
+
272
+ const lineHeight = this.getAttribute('line-height');
273
+ if (lineHeight) options.lineHeight = parseFloat(lineHeight) || 1.6;
274
+
275
+ const padding = this.getAttribute('padding');
276
+ if (padding) options.padding = padding;
277
+
278
+ const minHeight = this.getAttribute('min-height');
279
+ if (minHeight) options.minHeight = minHeight;
280
+
281
+ const maxHeight = this.getAttribute('max-height');
282
+ if (maxHeight) options.maxHeight = maxHeight;
283
+
284
+ return options;
285
+ }
286
+
287
+ /**
288
+ * Apply pending option changes after initialization
289
+ * @private
290
+ */
291
+ _applyPendingOptions() {
292
+ for (const [attr, value] of Object.entries(this._pendingOptions)) {
293
+ this._updateOption(attr, value);
294
+ }
295
+ this._pendingOptions = {};
296
+ }
297
+
298
+ /**
299
+ * Update a single editor option
300
+ * @private
301
+ * @param {string} attribute - Attribute name
302
+ * @param {string} value - New value
303
+ */
304
+ _updateOption(attribute, value) {
305
+ if (!this._editor) return;
306
+
307
+ switch (attribute) {
308
+ case 'value':
309
+ {
310
+ const decoded = this._decodeValue(value);
311
+ if (this._editor.getValue() !== decoded) {
312
+ this._editor.setValue(decoded || '');
313
+ }
314
+ }
315
+ break;
316
+
317
+ case 'theme':
318
+ // Theme changes require re-injecting styles
319
+ this._reinjectStyles();
320
+ break;
321
+
322
+ case 'placeholder':
323
+ if (this._editor.textarea) {
324
+ this._editor.textarea.placeholder = value || '';
325
+ }
326
+ break;
327
+
328
+ case 'readonly':
329
+ if (this._editor.textarea) {
330
+ this._editor.textarea.readOnly = this.hasAttribute('readonly');
331
+ }
332
+ break;
333
+
334
+ case 'height':
335
+ case 'min-height':
336
+ case 'max-height':
337
+ this._updateContainerHeight();
338
+ break;
339
+
340
+ // For other options that require reinitialization
341
+ case 'toolbar':
342
+ // Only reinitialize if value actually changes
343
+ if (!!this.hasAttribute('toolbar') === !!this._editor.options.toolbar) return;
344
+ this._reinitializeEditor();
345
+ break;
346
+ case 'auto-resize':
347
+ if (!!this.hasAttribute('auto-resize') === !!this._editor.options.autoResize) return;
348
+ this._reinitializeEditor();
349
+ break;
350
+ case 'show-stats':
351
+ if (!!this.hasAttribute('show-stats') === !!this._editor.options.showStats) return;
352
+ this._reinitializeEditor();
353
+ break;
354
+
355
+ // Typography/layout style changes
356
+ case 'font-size': {
357
+ if (this._updateFontSize(value)) {
358
+ // Only reinject styles once if direct update succeeded
359
+ this._reinjectStyles();
360
+ }
361
+ break;
362
+ }
363
+ case 'line-height': {
364
+ if (this._updateLineHeight(value)) {
365
+ // Only reinject styles once if direct update succeeded
366
+ this._reinjectStyles();
367
+ }
368
+ break;
369
+ }
370
+ case 'padding':
371
+ this._reinjectStyles();
372
+ break;
373
+
374
+ // Smart-lists affects editing behavior → requires reinitialization
375
+ case 'smart-lists': {
376
+ const newSmartLists = !this.hasAttribute('smart-lists') || this.getAttribute('smart-lists') !== 'false';
377
+ if (!!this._editor.options.smartLists === !!newSmartLists) return;
378
+ this._reinitializeEditor();
379
+ break;
380
+ }
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Update container height from attributes
386
+ * @private
387
+ */
388
+ _updateContainerHeight() {
389
+ const container = this.shadowRoot.querySelector(`.${CONTAINER_CLASS}`);
390
+ if (!container) return;
391
+
392
+ const height = this.getAttribute('height');
393
+ const minHeight = this.getAttribute('min-height');
394
+ const maxHeight = this.getAttribute('max-height');
395
+
396
+ container.style.height = height || '';
397
+ container.style.minHeight = minHeight || '';
398
+ container.style.maxHeight = maxHeight || '';
399
+ }
400
+
401
+ /**
402
+ * Update font size efficiently
403
+ * @private
404
+ * @param {string} value - New font size value
405
+ * @returns {boolean} True if direct update succeeded
406
+ */
407
+ _updateFontSize(value) {
408
+ if (this._editor && this._editor.wrapper) {
409
+ this._editor.options.fontSize = value || '';
410
+ this._editor.wrapper.style.setProperty('--instance-font-size', this._editor.options.fontSize);
411
+ this._editor.updatePreview();
412
+ return true;
413
+ }
414
+ return false;
415
+ }
416
+
417
+ /**
418
+ * Update line height efficiently
419
+ * @private
420
+ * @param {string} value - New line height value
421
+ * @returns {boolean} True if direct update succeeded
422
+ */
423
+ _updateLineHeight(value) {
424
+ if (this._editor && this._editor.wrapper) {
425
+ const numeric = parseFloat(value);
426
+ const lineHeight = Number.isFinite(numeric) ? numeric : this._editor.options.lineHeight;
427
+ this._editor.options.lineHeight = lineHeight;
428
+ this._editor.wrapper.style.setProperty('--instance-line-height', String(lineHeight));
429
+ this._editor.updatePreview();
430
+ return true;
431
+ }
432
+ return false;
433
+ }
434
+
435
+ /**
436
+ * Re-inject styles (useful for theme changes)
437
+ * @private
438
+ */
439
+ _reinjectStyles() {
440
+ // Remove only the base stylesheet, not other style elements (e.g., tooltip styles)
441
+ if (this._baseStyleElement && this._baseStyleElement.parentNode) {
442
+ this._baseStyleElement.remove();
443
+ }
444
+ this._injectStyles();
445
+ }
446
+
447
+ /**
448
+ * Reinitialize the entire editor (for major option changes)
449
+ * @private
450
+ */
451
+ _reinitializeEditor() {
452
+ const currentValue = this._editor ? this._editor.getValue() : '';
453
+ this._cleanup();
454
+ this._initialized = false;
455
+
456
+ // Clear shadow root
457
+ this.shadowRoot.innerHTML = '';
458
+
459
+ // Preserve current value
460
+ if (currentValue && !this.getAttribute('value')) {
461
+ this.setAttribute('value', currentValue);
462
+ }
463
+
464
+ // Reinitialize
465
+ this._initializeEditor();
466
+ }
467
+
468
+ /**
469
+ * Handle content changes from OverType
470
+ * @private
471
+ * @param {string} value - New editor value
472
+ */
473
+ _handleChange(value) {
474
+ // Update value attribute without triggering attribute change
475
+ this._updateValueAttribute(value);
476
+
477
+ // Avoid dispatching change before initialization completes
478
+ if (!this._initialized || !this._editor) {
479
+ return;
480
+ }
481
+
482
+ // Dispatch change event
483
+ this._dispatchEvent('change', {
484
+ value,
485
+ editor: this._editor
486
+ });
487
+ }
488
+
489
+ /**
490
+ * Handle keydown events from OverType
491
+ * @private
492
+ * @param {KeyboardEvent} event - Keyboard event
493
+ */
494
+ _handleKeydown(event) {
495
+ this._dispatchEvent('keydown', {
496
+ event,
497
+ editor: this._editor
498
+ });
499
+ }
500
+
501
+ /**
502
+ * Update value attribute without triggering observer
503
+ * @private
504
+ * @param {string} value - New value
505
+ */
506
+ _updateValueAttribute(value) {
507
+ // Temporarily store the current value to avoid infinite loop
508
+ const currentAttrValue = this.getAttribute('value');
509
+ if (currentAttrValue !== value) {
510
+ // Use a flag to prevent triggering the attribute observer
511
+ this._silentUpdate = true;
512
+ this.setAttribute('value', value);
513
+ this._silentUpdate = false;
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Dispatch custom events
519
+ * @private
520
+ * @param {string} eventName - Event name
521
+ * @param {Object} detail - Event detail
522
+ */
523
+ _dispatchEvent(eventName, detail = {}) {
524
+ const event = new CustomEvent(eventName, {
525
+ detail,
526
+ bubbles: true,
527
+ composed: true
528
+ });
529
+ this.dispatchEvent(event);
530
+ }
531
+
532
+ /**
533
+ * Cleanup editor and remove listeners
534
+ * @private
535
+ */
536
+ _cleanup() {
537
+ // Remove selectionchange listener
538
+ if (this._selectionChangeHandler) {
539
+ document.removeEventListener('selectionchange', this._selectionChangeHandler);
540
+ this._selectionChangeHandler = null;
541
+ }
542
+
543
+ if (this._editor && typeof this._editor.destroy === 'function') {
544
+ this._editor.destroy();
545
+ }
546
+ this._editor = null;
547
+ this._initialized = false;
548
+
549
+ // Clear shadow root to prevent stale containers on remount
550
+ // This is critical for React/Vue/etc. that frequently mount/unmount components
551
+ if (this.shadowRoot) {
552
+ this.shadowRoot.innerHTML = '';
553
+ }
554
+ }
555
+
556
+ // ===== PUBLIC API METHODS =====
557
+
558
+ /**
559
+ * Refresh theme styles (useful when theme object is updated without changing theme name)
560
+ * @public
561
+ */
562
+ refreshTheme() {
563
+ if (this._initialized) {
564
+ this._reinjectStyles();
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Get current editor value
570
+ * @returns {string} Current markdown content
571
+ */
572
+ getValue() {
573
+ return this._editor ? this._editor.getValue() : this.getAttribute('value') || '';
574
+ }
575
+
576
+ /**
577
+ * Set editor value
578
+ * @param {string} value - New markdown content
579
+ */
580
+ setValue(value) {
581
+ if (this._editor) {
582
+ this._editor.setValue(value);
583
+ } else {
584
+ this.setAttribute('value', value);
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Get rendered HTML
590
+ * @returns {string} Rendered HTML
591
+ */
592
+ getHTML() {
593
+ // Bridge to core editor API (getRenderedHTML)
594
+ return this._editor ? this._editor.getRenderedHTML(false) : '';
595
+ }
596
+
597
+ /**
598
+ * Insert text at cursor position
599
+ * @param {string} text - Text to insert
600
+ */
601
+ insertText(text) {
602
+ if (!this._editor || typeof text !== 'string') {
603
+ return;
604
+ }
605
+ this._editor.insertText(text);
606
+ }
607
+
608
+ /**
609
+ * Focus the editor
610
+ */
611
+ focus() {
612
+ if (this._editor && this._editor.textarea) {
613
+ this._editor.textarea.focus();
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Blur the editor
619
+ */
620
+ blur() {
621
+ if (this._editor && this._editor.textarea) {
622
+ this._editor.textarea.blur();
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Get editor statistics
628
+ * @returns {Object} Statistics object
629
+ */
630
+ getStats() {
631
+ if (!this._editor || !this._editor.textarea) return null;
632
+
633
+ const value = this._editor.textarea.value;
634
+ const lines = value.split('\n');
635
+ const chars = value.length;
636
+ const words = value.split(/\s+/).filter(w => w.length > 0).length;
637
+
638
+ // Calculate line and column from cursor position
639
+ const selectionStart = this._editor.textarea.selectionStart;
640
+ const beforeCursor = value.substring(0, selectionStart);
641
+ const linesBefore = beforeCursor.split('\n');
642
+ const currentLine = linesBefore.length;
643
+ const currentColumn = linesBefore[linesBefore.length - 1].length + 1;
644
+
645
+ return {
646
+ characters: chars,
647
+ words: words,
648
+ lines: lines.length,
649
+ line: currentLine,
650
+ column: currentColumn
651
+ };
652
+ }
653
+
654
+ /**
655
+ * Check if editor is ready
656
+ * @returns {boolean} True if editor is initialized
657
+ */
658
+ isReady() {
659
+ return this._initialized && this._editor !== null;
660
+ }
661
+
662
+ /**
663
+ * Get the internal OverType instance
664
+ * @returns {OverType} The OverType editor instance
665
+ */
666
+ getEditor() {
667
+ return this._editor;
668
+ }
669
+ }
670
+
671
+ // Register the custom element
672
+ if (!customElements.get('overtype-editor')) {
673
+ customElements.define('overtype-editor', OverTypeEditor);
674
+ }
675
+
676
+ export default OverTypeEditor;