@liwe3/webcomponents 1.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,708 @@
1
+ /**
2
+ * AITextEditor Web Component
3
+ * A text editor with AI-powered text continuation suggestions
4
+ */
5
+
6
+ const AI_TEXT_EDITOR_API_KEY = 'ai-text-editor-api-key';
7
+
8
+ export interface AITextEditorConfig {
9
+ apiKey?: string;
10
+ suggestionDelay?: number;
11
+ systemPrompt?: string;
12
+ apiEndpoint?: string;
13
+ modelName?: string;
14
+ context?: string;
15
+ }
16
+
17
+ export class AITextEditorElement extends HTMLElement {
18
+ declare shadowRoot: ShadowRoot;
19
+ private editor!: HTMLTextAreaElement;
20
+ private editorBackground!: HTMLElement;
21
+ private loading!: HTMLElement;
22
+ private editorStatus!: HTMLElement;
23
+
24
+ private typingTimer: number | null = null;
25
+ private fullSuggestion: string | null = null;
26
+ private suggestionParagraphs: string[] = [];
27
+ private currentParagraphIndex: number = 0;
28
+ private isShowingSuggestion: boolean = false;
29
+
30
+ private apiKey: string = '';
31
+ private suggestionDelay: number = 1000;
32
+ private systemPrompt: string = "You are a helpful writing assistant. Continue the user's text naturally and coherently. Provide 1-3 sentences that would logically follow their writing. Keep the same tone and style. Do not repeat what they've already written.";
33
+ private apiEndpoint: string = 'https://api.openai.com/v1/chat/completions';
34
+ private modelName: string = 'gpt-3.5-turbo';
35
+ private context: string = '';
36
+
37
+ constructor() {
38
+ super();
39
+ this.attachShadow({ mode: 'open' });
40
+ this.render();
41
+ this.init();
42
+ }
43
+
44
+ /**
45
+ * Renders the component's HTML structure
46
+ */
47
+ private render(): void {
48
+ this.shadowRoot.innerHTML = `
49
+ <style>
50
+ :host {
51
+ display: block;
52
+ width: 100%;
53
+ height: 100%;
54
+ }
55
+
56
+ .editor-container {
57
+ position: relative;
58
+ height: 100%;
59
+ display: flex;
60
+ flex-direction: column;
61
+ }
62
+
63
+ .editor-status {
64
+ position: absolute;
65
+ top: 5px;
66
+ left: 5px;
67
+ width: 10px;
68
+ height: 10px;
69
+ border-radius: 100%;
70
+ background: #777;
71
+ z-index: 10;
72
+ }
73
+
74
+ .editor-wrapper {
75
+ position: relative;
76
+ width: 100%;
77
+ flex: 1;
78
+ display: flex;
79
+ flex-direction: column;
80
+ }
81
+
82
+ .editor {
83
+ width: 100%;
84
+ height: 100%;
85
+ border: 2px solid #e1e5e9;
86
+ border-radius: 12px;
87
+ padding: 20px;
88
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
89
+ font-size: 14px;
90
+ line-height: 1.6;
91
+ resize: none;
92
+ background: #fafbfc;
93
+ transition: all 0.3s ease;
94
+ position: relative;
95
+ z-index: 2;
96
+ background: transparent;
97
+ box-sizing: border-box;
98
+ min-height: auto;
99
+ }
100
+
101
+ .editor:focus {
102
+ outline: none;
103
+ border-color: #4facfe;
104
+ box-shadow: 0 0 0 3px rgba(79, 172, 254, 0.1);
105
+ }
106
+
107
+ .editor-background {
108
+ position: absolute;
109
+ top: 0;
110
+ left: 0;
111
+ width: 100%;
112
+ height: 100%;
113
+ border: 2px solid #e1e5e9;
114
+ border-radius: 12px;
115
+ padding: 20px;
116
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
117
+ font-size: 14px;
118
+ line-height: 1.6;
119
+ background: #fafbfc;
120
+ z-index: 1;
121
+ pointer-events: none;
122
+ white-space: pre-wrap;
123
+ word-wrap: break-word;
124
+ overflow: hidden;
125
+ color: transparent;
126
+ box-sizing: border-box;
127
+ }
128
+
129
+ .editor-wrapper:focus-within .editor-background {
130
+ background: white;
131
+ border-color: #4facfe;
132
+ }
133
+
134
+ .suggestion-text {
135
+ color: #bbb;
136
+ position: relative;
137
+ }
138
+
139
+ .suggestion-text.accepted {
140
+ color: #ddd;
141
+ text-decoration: line-through;
142
+ }
143
+
144
+ .loading {
145
+ position: absolute;
146
+ top: 5px;
147
+ right: 10px;
148
+ z-index: 10;
149
+ display: none;
150
+ }
151
+
152
+ .loading.show {
153
+ display: block;
154
+ }
155
+
156
+ .spinner {
157
+ width: 10px;
158
+ height: 10px;
159
+ border: 2px solid #e1e5e9;
160
+ border-top: 2px solid #4facfe;
161
+ border-radius: 50%;
162
+ animation: spin 1s linear infinite;
163
+ }
164
+
165
+ @keyframes spin {
166
+ 0% { transform: rotate(0deg); }
167
+ 100% { transform: rotate(360deg); }
168
+ }
169
+ </style>
170
+
171
+ <div class="editor-container">
172
+ <div class="editor-status"></div>
173
+ <div class="loading" id="loading">
174
+ <div class="spinner"></div>
175
+ </div>
176
+
177
+ <div class="editor-wrapper">
178
+ <div class="editor-background" id="editorBackground"></div>
179
+ <textarea
180
+ class="editor"
181
+ id="editor"
182
+ placeholder="Start writing your markdown text here..."
183
+ ></textarea>
184
+ </div>
185
+ </div>
186
+ `;
187
+ }
188
+
189
+ /**
190
+ * Initializes the component after rendering
191
+ */
192
+ private init(): void {
193
+ const editor = this.shadowRoot.getElementById('editor') as HTMLTextAreaElement;
194
+ const editorBackground = this.shadowRoot.getElementById('editorBackground') as HTMLElement;
195
+ const loading = this.shadowRoot.getElementById('loading') as HTMLElement;
196
+
197
+ this.editorStatus = this.shadowRoot.querySelector('.editor-status') as HTMLElement;
198
+ this.editor = editor;
199
+ this.editorBackground = editorBackground;
200
+ this.loading = loading;
201
+
202
+ this.editor.addEventListener('input', () => {
203
+ this.handleTextInput();
204
+ this.updateBackground();
205
+ this.dispatchEvent(new CustomEvent('change', { detail: { value: this.editor.value } }));
206
+ });
207
+
208
+ this.editor.addEventListener('keydown', (e) => {
209
+ this.handleKeyDown(e);
210
+ });
211
+
212
+ this.editor.addEventListener('keyup', (e) => {
213
+ if (this.isShowingSuggestion && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) {
214
+ this.hideSuggestion();
215
+ }
216
+ // Update background on cursor movement, after potentially hiding suggestion
217
+ if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) {
218
+ this.updateBackground();
219
+ }
220
+ });
221
+
222
+ this.editor.addEventListener('click', () => {
223
+ if (this.isShowingSuggestion) {
224
+ this.hideSuggestion();
225
+ }
226
+ // Update background on mouse click (cursor position change)
227
+ // setTimeout ensures cursor position is updated before background redraw
228
+ setTimeout(() => this.updateBackground(), 0);
229
+ });
230
+
231
+ this.editor.addEventListener('scroll', () => {
232
+ this.syncScroll();
233
+ });
234
+
235
+ this._loadApiKey();
236
+ this._loadSettings();
237
+ this.updateBackground();
238
+ }
239
+
240
+ /**
241
+ * Updates the background layer with current text and suggestions
242
+ */
243
+ private updateBackground(): void {
244
+ const currentText = this.editor.value;
245
+ const cursorPosition = this.editor.selectionStart;
246
+
247
+ let finalHtmlContent = '';
248
+
249
+ if (this.isShowingSuggestion && this.fullSuggestion) {
250
+ const beforeCursorText = currentText.substring(0, cursorPosition);
251
+ const afterCursorText = currentText.substring(cursorPosition);
252
+
253
+ // Determine the pending (not yet accepted) part of the suggestion
254
+ const pendingParagraphs = Array.isArray(this.suggestionParagraphs)
255
+ ? this.suggestionParagraphs.slice(this.currentParagraphIndex)
256
+ : [];
257
+ const pendingSuggestionText = pendingParagraphs.join(' ');
258
+
259
+ if (pendingSuggestionText.trim().length > 0) {
260
+ const mainSpacer = (beforeCursorText.endsWith(' ') || beforeCursorText === '' || pendingSuggestionText.startsWith(' ') || pendingSuggestionText === '') ? '' : ' ';
261
+ const suggestionBlockHtml = `<span class="suggestion-text">${this.escapeHtml(pendingSuggestionText)}</span>`;
262
+
263
+ finalHtmlContent =
264
+ this.escapeHtml(beforeCursorText).replace(/\n/g, '<br>') +
265
+ mainSpacer +
266
+ suggestionBlockHtml +
267
+ this.escapeHtml(afterCursorText).replace(/\n/g, '<br>');
268
+ } else {
269
+ finalHtmlContent = this.escapeHtml(currentText).replace(/\n/g, '<br>');
270
+ }
271
+ } else {
272
+ finalHtmlContent = this.escapeHtml(currentText).replace(/\n/g, '<br>');
273
+ }
274
+
275
+ this.editorBackground.innerHTML = finalHtmlContent;
276
+ this.syncScroll();
277
+ }
278
+
279
+ /**
280
+ * Synchronizes scroll position between editor and background
281
+ */
282
+ private syncScroll(): void {
283
+ this.editorBackground.scrollTop = this.editor.scrollTop;
284
+ this.editorBackground.scrollLeft = this.editor.scrollLeft;
285
+ }
286
+
287
+ /**
288
+ * Handles text input events
289
+ */
290
+ private handleTextInput(): void {
291
+ this.hideSuggestion();
292
+
293
+ if (this.typingTimer) {
294
+ clearTimeout(this.typingTimer);
295
+ }
296
+
297
+ if (!this.apiKey) return;
298
+
299
+ this.typingTimer = window.setTimeout(() => {
300
+ this.requestSuggestion();
301
+ }, this.suggestionDelay);
302
+ }
303
+
304
+ /**
305
+ * Handles keyboard events
306
+ */
307
+ private handleKeyDown(e: KeyboardEvent): void {
308
+ if (this.isShowingSuggestion) {
309
+ if (e.key === 'Tab') {
310
+ e.preventDefault();
311
+ this.acceptSuggestion();
312
+ } else if (e.key === 'Escape') {
313
+ e.preventDefault();
314
+ this.hideSuggestion();
315
+ }
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Requests an AI suggestion for the current text
321
+ */
322
+ private async requestSuggestion(): Promise<void> {
323
+ if (!this.apiKey) return;
324
+
325
+ const currentText = this.editor.value;
326
+ if (!currentText.trim()) return;
327
+
328
+ // Get text up to cursor position for context
329
+ const cursorPosition = this.editor.selectionStart;
330
+ const textUpToCursor = currentText.substring(0, cursorPosition);
331
+
332
+ // Dispatch an event before starting the AI request, allow listeners to cancel
333
+ const proceed = this.dispatchEvent(new CustomEvent('beforeSuggestion', {
334
+ detail: {
335
+ text: textUpToCursor,
336
+ context: this.context,
337
+ apiEndpoint: this.apiEndpoint,
338
+ modelName: this.modelName,
339
+ systemPrompt: this.systemPrompt
340
+ },
341
+ cancelable: true
342
+ }));
343
+
344
+ if (proceed === false) {
345
+ return; // aborted by listener via event.preventDefault()
346
+ }
347
+
348
+ this.showLoading();
349
+
350
+ try {
351
+ const suggestion = await this.callOpenAI(textUpToCursor);
352
+ this.hideLoading();
353
+
354
+ if (suggestion) {
355
+ this.showSuggestion(suggestion);
356
+ }
357
+ } catch (error) {
358
+ this.hideLoading();
359
+ this.showError('Failed to get AI suggestion: ' + (error as Error).message);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Calls the OpenAI API for text completion
365
+ */
366
+ private async callOpenAI(text: string): Promise<string> {
367
+ const parts: string[] = [];
368
+ if (this.context && this.context.trim()) {
369
+ parts.push(`Context:\n${this.context.trim()}`);
370
+ }
371
+ parts.push(`Please continue this text naturally:\n\n${text}`);
372
+ const userContent = parts.join('\n\n');
373
+
374
+ // Prepare headers - only add Authorization if API key is provided
375
+ const headers: Record<string, string> = {
376
+ 'Content-Type': 'application/json'
377
+ };
378
+
379
+ if (this.apiKey && this.apiKey.trim() !== '') {
380
+ headers['Authorization'] = `Bearer ${this.apiKey}`;
381
+ }
382
+
383
+ const requestBody = {
384
+ model: this.modelName,
385
+ messages: [
386
+ {
387
+ role: 'system',
388
+ content: this.systemPrompt
389
+ },
390
+ {
391
+ role: 'user',
392
+ content: userContent
393
+ }
394
+ ],
395
+ max_tokens: 150,
396
+ temperature: 0.7
397
+ };
398
+
399
+ const response = await fetch(this.apiEndpoint, {
400
+ method: 'POST',
401
+ headers: headers,
402
+ body: JSON.stringify(requestBody)
403
+ });
404
+
405
+ if (!response.ok) {
406
+ let errorMessage = 'API request failed';
407
+ try {
408
+ const errorData = await response.json();
409
+ errorMessage = errorData.error?.message || errorMessage;
410
+ } catch (parseError) {
411
+ console.error('Failed to parse error response:', parseError);
412
+ errorMessage = `HTTP ${response.status}: ${response.statusText}`;
413
+ }
414
+ throw new Error(errorMessage);
415
+ }
416
+
417
+ const data = await response.json();
418
+ return data.choices[0]?.message?.content?.trim();
419
+ }
420
+
421
+ /**
422
+ * Shows an AI suggestion
423
+ */
424
+ private showSuggestion(suggestion: string): void {
425
+ this.fullSuggestion = suggestion;
426
+ this.suggestionParagraphs = this.splitIntoParagraphs(suggestion);
427
+ this.currentParagraphIndex = 0;
428
+ this.isShowingSuggestion = true;
429
+ this.updateBackground();
430
+ }
431
+
432
+ /**
433
+ * Splits text into paragraphs/sentences
434
+ */
435
+ private splitIntoParagraphs(text: string): string[] {
436
+ // Split on periods followed by space and capital letter, or double newlines
437
+ const sentences = text.split(/(?<=\.)\s+(?=[A-Z])|(?:\n\s*\n)/);
438
+ return sentences.filter(sentence => sentence.trim().length > 0);
439
+ }
440
+
441
+ /**
442
+ * Hides the current suggestion
443
+ */
444
+ private hideSuggestion(): void {
445
+ this.isShowingSuggestion = false;
446
+ this.fullSuggestion = null;
447
+ this.suggestionParagraphs = [];
448
+ this.currentParagraphIndex = 0;
449
+ this.updateBackground();
450
+ }
451
+
452
+ /**
453
+ * Accepts the current suggestion paragraph
454
+ */
455
+ private acceptSuggestion(): void {
456
+ if (this.fullSuggestion && this.currentParagraphIndex < this.suggestionParagraphs.length) {
457
+ const currentText = this.editor.value;
458
+ const cursorPosition = this.editor.selectionStart;
459
+ const paragraphToAdd = this.suggestionParagraphs[this.currentParagraphIndex];
460
+
461
+ // Insert at cursor position
462
+ const beforeCursor = currentText.substring(0, cursorPosition);
463
+ const afterCursor = currentText.substring(cursorPosition);
464
+
465
+ // Refined spacer logic
466
+ const spacer = (beforeCursor.endsWith(' ') || beforeCursor === '' || paragraphToAdd.startsWith(' ') || paragraphToAdd === '') ? '' : ' ';
467
+ const newText = beforeCursor + spacer + paragraphToAdd + afterCursor;
468
+
469
+ this.editor.value = newText;
470
+
471
+ // Update cursor position to after the inserted text
472
+ const newCursorPosition = cursorPosition + spacer.length + paragraphToAdd.length;
473
+ this.editor.setSelectionRange(newCursorPosition, newCursorPosition);
474
+ this.dispatchEvent(new CustomEvent('change', { detail: { value: this.editor.value } }));
475
+
476
+ // Move to next paragraph or hide if no more paragraphs
477
+ this.currentParagraphIndex++;
478
+ if (this.currentParagraphIndex >= this.suggestionParagraphs.length) {
479
+ this.hideSuggestion();
480
+ } else {
481
+ this.updateBackground();
482
+ }
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Shows loading indicator
488
+ */
489
+ private showLoading(): void {
490
+ this.loading.classList.add('show');
491
+ }
492
+
493
+ /**
494
+ * Hides loading indicator
495
+ */
496
+ private hideLoading(): void {
497
+ this.loading.classList.remove('show');
498
+ }
499
+
500
+ /**
501
+ * Shows an error message
502
+ */
503
+ private showError(message: string): void {
504
+ console.error('AI Text Editor Error:', message);
505
+ // Try to dispatch a custom error event that can be handled by parent
506
+ this.dispatchEvent(new CustomEvent('error', {
507
+ detail: { message },
508
+ bubbles: true,
509
+ composed: true
510
+ }));
511
+ }
512
+
513
+ /**
514
+ * Escapes HTML special characters
515
+ */
516
+ private escapeHtml(unsafe: any): string {
517
+ if (typeof unsafe !== 'string') {
518
+ if (unsafe === null || typeof unsafe === 'undefined') {
519
+ return '';
520
+ }
521
+ unsafe = String(unsafe);
522
+ }
523
+ return unsafe
524
+ .replace(/&/g, "&amp;")
525
+ .replace(/</g, "&lt;")
526
+ .replace(/>/g, "&gt;")
527
+ .replace(/"/g, "&quot;")
528
+ .replace(/'/g, "&#039;");
529
+ }
530
+
531
+ /**
532
+ * Sets the text content
533
+ */
534
+ setText(text: string): void {
535
+ this.editor.value = text;
536
+ this.dispatchEvent(new CustomEvent('change', { detail: { value: this.editor.value } }));
537
+ this.hideSuggestion();
538
+ this.updateBackground();
539
+ }
540
+
541
+ /**
542
+ * Gets the text content
543
+ */
544
+ getText(): string {
545
+ return this.editor.value;
546
+ }
547
+
548
+ /**
549
+ * Sets the API key
550
+ */
551
+ setApiKey(key: string): void {
552
+ this.apiKey = key;
553
+ this._saveApiKey();
554
+ this.editorStatus.style.backgroundColor = this.apiKey ? '#4caf50' : '#777';
555
+ }
556
+
557
+ /**
558
+ * Saves API key to localStorage
559
+ */
560
+ private _saveApiKey(): void {
561
+ if (!this.apiKey) {
562
+ localStorage.removeItem(AI_TEXT_EDITOR_API_KEY);
563
+ return;
564
+ }
565
+
566
+ // Encrypt the API key in base64 format
567
+ const encryptedKey = btoa(this.apiKey);
568
+ localStorage.setItem(AI_TEXT_EDITOR_API_KEY, encryptedKey);
569
+ }
570
+
571
+ /**
572
+ * Loads API key from localStorage
573
+ */
574
+ private _loadApiKey(): void {
575
+ const savedKey = localStorage.getItem(AI_TEXT_EDITOR_API_KEY);
576
+ if (!savedKey) {
577
+ this.setApiKey('');
578
+ return;
579
+ }
580
+
581
+ const decryptedKey = atob(savedKey);
582
+ this.setApiKey(decryptedKey);
583
+ }
584
+
585
+ /**
586
+ * Gets the API key
587
+ */
588
+ getApiKey(): string {
589
+ return this.apiKey;
590
+ }
591
+
592
+ /**
593
+ * Sets the suggestion delay in seconds
594
+ */
595
+ setSuggestionDelay(seconds: number): void {
596
+ this.suggestionDelay = seconds * 1000;
597
+ }
598
+
599
+ /**
600
+ * Gets the suggestion delay in seconds
601
+ */
602
+ getSuggestionDelay(): number {
603
+ return this.suggestionDelay / 1000;
604
+ }
605
+
606
+ /**
607
+ * Sets the system prompt
608
+ */
609
+ setSystemPrompt(prompt: string): void {
610
+ this.systemPrompt = prompt;
611
+ }
612
+
613
+ /**
614
+ * Gets the system prompt
615
+ */
616
+ getSystemPrompt(): string {
617
+ return this.systemPrompt;
618
+ }
619
+
620
+ /**
621
+ * Sets the API endpoint
622
+ */
623
+ setApiEndpoint(endpoint: string): void {
624
+ this.apiEndpoint = endpoint;
625
+ this._saveSettings();
626
+ }
627
+
628
+ /**
629
+ * Gets the API endpoint
630
+ */
631
+ getApiEndpoint(): string {
632
+ return this.apiEndpoint;
633
+ }
634
+
635
+ /**
636
+ * Sets the model name
637
+ */
638
+ setModelName(modelName: string): void {
639
+ this.modelName = modelName;
640
+ this._saveSettings();
641
+ }
642
+
643
+ /**
644
+ * Gets the model name
645
+ */
646
+ getModelName(): string {
647
+ return this.modelName;
648
+ }
649
+
650
+ /**
651
+ * Sets the context
652
+ */
653
+ setContext(context: string): void {
654
+ this.context = typeof context === 'string' ? context : '';
655
+ }
656
+
657
+ /**
658
+ * Gets the context
659
+ */
660
+ getContext(): string {
661
+ return this.context;
662
+ }
663
+
664
+ /**
665
+ * Saves settings to localStorage
666
+ */
667
+ private _saveSettings(): void {
668
+ const settings = {
669
+ apiEndpoint: this.apiEndpoint,
670
+ modelName: this.modelName
671
+ };
672
+ localStorage.setItem('ai-text-editor-settings', JSON.stringify(settings));
673
+ }
674
+
675
+ /**
676
+ * Loads settings from localStorage
677
+ */
678
+ private _loadSettings(): void {
679
+ const savedSettings = localStorage.getItem('ai-text-editor-settings');
680
+ if (savedSettings) {
681
+ try {
682
+ const settings = JSON.parse(savedSettings);
683
+ if (settings.apiEndpoint) {
684
+ this.apiEndpoint = settings.apiEndpoint;
685
+ }
686
+ if (settings.modelName) {
687
+ this.modelName = settings.modelName;
688
+ }
689
+ } catch (error) {
690
+ console.warn('Failed to load saved settings:', error);
691
+ }
692
+ }
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Conditionally defines the custom element if in a browser environment.
698
+ */
699
+ const defineAITextEditor = (tagName: string = 'liwe3-ai-text-editor'): void => {
700
+ if (typeof window !== 'undefined' && !window.customElements.get(tagName)) {
701
+ customElements.define(tagName, AITextEditorElement);
702
+ }
703
+ };
704
+
705
+ // Auto-register with default tag name
706
+ defineAITextEditor();
707
+
708
+ export { defineAITextEditor };