@editora/template 1.0.1

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,908 @@
1
+ import { Plugin } from '@editora/core';
2
+ import DOMPurify from 'dompurify';
3
+
4
+ /**
5
+ * Template Plugin - Native Implementation
6
+ *
7
+ * Allows insertion of predefined document templates with:
8
+ * - Template categories and search
9
+ * - Preview functionality
10
+ * - Replace or insert options
11
+ * - HTML sanitization
12
+ * - Undo/redo support
13
+ * - Merge tag integration
14
+ *
15
+ * Rules:
16
+ * - Templates are sanitized on insertion
17
+ * - Scripts are always stripped
18
+ * - CSS may be filtered for security
19
+ * - Existing content warning dialog
20
+ * - Undo restores entire document
21
+ */
22
+
23
+ // ============================================================================
24
+ // Module-Level State
25
+ // ============================================================================
26
+ let dialogElement: HTMLDivElement | null = null;
27
+ let overlayElement: HTMLDivElement | null = null;
28
+ let savedRange: Range | null = null;
29
+ let selectedTemplate: Template | null = null;
30
+ let selectedCategory: string = '';
31
+ let searchTerm: string = '';
32
+ let insertMode: 'insert' | 'replace' = 'insert';
33
+
34
+ // ============================================================================
35
+ // Template Data Model
36
+ // ============================================================================
37
+ export interface Template {
38
+ id: string;
39
+ name: string;
40
+ category: string;
41
+ html: string;
42
+ description?: string;
43
+ preview?: string;
44
+ tags?: string[];
45
+ }
46
+
47
+ /**
48
+ * Predefined templates
49
+ */
50
+ export const PREDEFINED_TEMPLATES: Template[] = [
51
+ {
52
+ id: 'formal-letter',
53
+ name: 'Formal Letter',
54
+ category: 'Letters',
55
+ description: 'Professional business letter template',
56
+ html: `<p><strong>{{ Company Name }}</strong></p>
57
+ <p>{{ Today }}</p>
58
+ <p>Dear {{ first_name }} {{ last_name }},</p>
59
+ <p>I hope this letter finds you well. [Your letter content here]</p>
60
+ <p>Thank you for your time and consideration.</p>
61
+ <p>Sincerely,<br>Your Name</p>`
62
+ },
63
+ {
64
+ id: 'meeting-notes',
65
+ name: 'Meeting Notes',
66
+ category: 'Notes',
67
+ description: 'Template for meeting notes with attendees and action items',
68
+ html: `<h2>Meeting Notes - {{ today }}</h2>
69
+ <p><strong>Attendees:</strong> [List attendees]</p>
70
+ <p><strong>Agenda:</strong></p>
71
+ <ul>
72
+ <li>[Item 1]</li>
73
+ <li>[Item 2]</li>
74
+ <li>[Item 3]</li>
75
+ </ul>
76
+ <p><strong>Action Items:</strong></p>
77
+ <ul>
78
+ <li>[Owner]: [Task] - [Due Date]</li>
79
+ </ul>
80
+ <p><strong>Next Meeting:</strong> [Date]</p>`
81
+ },
82
+ {
83
+ id: 'proposal',
84
+ name: 'Project Proposal',
85
+ category: 'Business',
86
+ description: 'Structured project proposal template',
87
+ html: `<h1>Project Proposal</h1>
88
+ <h2>Executive Summary</h2>
89
+ <p>[Summary of the proposal]</p>
90
+ <h2>Objectives</h2>
91
+ <ul>
92
+ <li>[Objective 1]</li>
93
+ <li>[Objective 2]</li>
94
+ </ul>
95
+ <h2>Scope</h2>
96
+ <p>[Project scope details]</p>
97
+ <h2>Timeline</h2>
98
+ <p>[Project timeline]</p>
99
+ <h2>Budget</h2>
100
+ <p>[Budget details]</p>
101
+ <h2>Contact</h2>
102
+ <p>{{ first_name }} {{ last_name }}<br>{{ email }}<br>{{ phone }}</p>`
103
+ },
104
+ {
105
+ id: 'faq',
106
+ name: 'FAQ Template',
107
+ category: 'Documentation',
108
+ description: 'FAQ document structure',
109
+ html: `<h1>Frequently Asked Questions</h1>
110
+ <h2>General Questions</h2>
111
+ <h3>Q: What is this about?</h3>
112
+ <p>A: [Answer here]</p>
113
+ <h3>Q: Who should use this?</h3>
114
+ <p>A: [Answer here]</p>
115
+ <h2>Technical Questions</h2>
116
+ <h3>Q: How do I get started?</h3>
117
+ <p>A: [Answer here]</p>
118
+ <h3>Q: What are the requirements?</h3>
119
+ <p>A: [Answer here]</p>`
120
+ }
121
+ ];
122
+
123
+ /**
124
+ * Template cache
125
+ */
126
+ let templateCache: Template[] = [...PREDEFINED_TEMPLATES];
127
+
128
+ // ============================================================================
129
+ // Template Functions
130
+ // ============================================================================
131
+
132
+ /**
133
+ * Get all available templates
134
+ */
135
+ export const getAllTemplates = (): Template[] => {
136
+ return templateCache;
137
+ };
138
+
139
+ /**
140
+ * Get templates by category
141
+ */
142
+ export const getTemplatesByCategory = (category: string): Template[] => {
143
+ return templateCache.filter(t => t.category === category);
144
+ };
145
+
146
+ /**
147
+ * Get unique template categories
148
+ */
149
+ export const getTemplateCategories = (): string[] => {
150
+ const categories = new Set(templateCache.map(t => t.category));
151
+ return Array.from(categories);
152
+ };
153
+
154
+ /**
155
+ * Search templates
156
+ */
157
+ export const searchTemplates = (query: string): Template[] => {
158
+ const q = query.toLowerCase();
159
+ return templateCache.filter(t =>
160
+ t.name.toLowerCase().includes(q) ||
161
+ t.description?.toLowerCase().includes(q) ||
162
+ t.tags?.some(tag => tag.toLowerCase().includes(q))
163
+ );
164
+ };
165
+
166
+ /**
167
+ * Sanitize template HTML
168
+ * Removes scripts and potentially dangerous content
169
+ */
170
+ export const sanitizeTemplate = (html: string): string => {
171
+ return DOMPurify.sanitize(html, {
172
+ ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'a', 'span'],
173
+ ALLOWED_ATTR: ['href', 'target', 'class', 'data-key', 'data-category']
174
+ });
175
+ };
176
+
177
+ /**
178
+ * Add custom template
179
+ */
180
+ export const addCustomTemplate = (template: Template): boolean => {
181
+ // Check for duplicate ID
182
+ if (templateCache.some(t => t.id === template.id)) {
183
+ console.warn(`Template with ID ${template.id} already exists`);
184
+ return false;
185
+ }
186
+
187
+ templateCache.push(template);
188
+ return true;
189
+ };
190
+
191
+ /**
192
+ * Validate template integrity
193
+ */
194
+ export const validateTemplate = (template: Template): boolean => {
195
+ return !!(
196
+ template.id &&
197
+ template.name &&
198
+ template.category &&
199
+ template.html &&
200
+ template.html.trim().length > 0
201
+ );
202
+ };
203
+
204
+ // ============================================================================
205
+ // Dialog Functions
206
+ // ============================================================================
207
+
208
+ /**
209
+ * Create the template dialog (context-aware, matches emoji/special char plugins)
210
+ */
211
+ function createTemplateDialog(editorContent?: HTMLElement | null): void {
212
+ overlayElement = document.createElement('div');
213
+ overlayElement.className = 'rte-dialog-overlay';
214
+ if (isDarkThemeContext(editorContent)) {
215
+ overlayElement.classList.add('rte-ui-theme-dark');
216
+ }
217
+ overlayElement.addEventListener('click', () => closeDialog());
218
+
219
+ dialogElement = document.createElement('div');
220
+ dialogElement.className = 'rte-dialog rte-template-dialog';
221
+ dialogElement.addEventListener('click', (e) => e.stopPropagation());
222
+
223
+ const categories = getTemplateCategories();
224
+ if (categories.length > 0 && !selectedCategory) {
225
+ selectedCategory = categories[0];
226
+ }
227
+
228
+ renderDialogContent();
229
+
230
+ overlayElement.appendChild(dialogElement);
231
+ document.body.appendChild(overlayElement);
232
+ injectTemplateDialogStyles();
233
+ }
234
+
235
+ // Dark theme detection (scoped to the nearest editor root)
236
+ // Include `.dark` when it's applied inside the editor root (e.g. React)
237
+ const DARK_THEME_SELECTOR = '[data-theme="dark"], .dark, .editora-theme-dark';
238
+ const getEditorContentFromSelection = (): HTMLElement | null => {
239
+ const selection = window.getSelection();
240
+ if (!selection || selection.rangeCount === 0) return null;
241
+ const anchorNode = selection.anchorNode;
242
+ const anchorElement =
243
+ anchorNode instanceof HTMLElement ? anchorNode : anchorNode?.parentElement;
244
+ return (
245
+ (anchorElement?.closest(
246
+ ".rte-content, .editora-content",
247
+ ) as HTMLElement | null) || null
248
+ );
249
+ };
250
+ const isDarkThemeContext = (editorContent?: HTMLElement | null): boolean => {
251
+ const source = editorContent || getEditorContentFromSelection();
252
+ if (!source) return false;
253
+ return Boolean(source.closest(DARK_THEME_SELECTOR));
254
+ }
255
+
256
+ /**
257
+ * Render dialog content (normal mode)
258
+ */
259
+ function renderDialogContent(): void {
260
+ if (!dialogElement) return;
261
+
262
+ const categories = getTemplateCategories();
263
+ const filteredTemplates = getFilteredTemplates();
264
+
265
+ dialogElement.innerHTML = `
266
+ <div class="rte-dialog-header">
267
+ <h2>Insert Template</h2>
268
+ <button class="rte-dialog-close" aria-label="Close">✕</button>
269
+ </div>
270
+
271
+ <div class="rte-dialog-body">
272
+ <!-- Search -->
273
+ <input
274
+ type="text"
275
+ placeholder="Search templates..."
276
+ value="${searchTerm}"
277
+ class="rte-input rte-template-search"
278
+ aria-label="Search templates"
279
+ />
280
+
281
+ <!-- Category Tabs -->
282
+ <div class="rte-tabs">
283
+ ${categories.map(cat => `
284
+ <button class="rte-tab ${selectedCategory === cat ? 'active' : ''}" data-category="${cat}">
285
+ ${cat}
286
+ </button>
287
+ `).join('')}
288
+ </div>
289
+
290
+ <!-- Template List -->
291
+ <div class="rte-template-list">
292
+ ${filteredTemplates.length > 0 ? filteredTemplates.map(template => `
293
+ <div
294
+ class="rte-template-item ${selectedTemplate?.id === template.id ? 'selected' : ''}"
295
+ data-template-id="${template.id}"
296
+ >
297
+ <div class="template-name">${template.name}</div>
298
+ ${template.description ? `<div class="template-description">${template.description}</div>` : ''}
299
+ </div>
300
+ `).join('') : '<div class="rte-empty-state">No templates found</div>'}
301
+ </div>
302
+
303
+ <!-- Preview -->
304
+ ${selectedTemplate ? `
305
+ <div class="rte-template-preview">
306
+ <strong>Preview:</strong>
307
+ <div class="template-preview-content">${selectedTemplate.html}</div>
308
+ </div>
309
+ ` : ''}
310
+
311
+ <!-- Insert Mode Toggle -->
312
+ <div class="rte-insert-mode">
313
+ <label>
314
+ <input type="radio" name="insertMode" value="insert" ${insertMode === 'insert' ? 'checked' : ''} />
315
+ Insert at cursor
316
+ </label>
317
+ <label>
318
+ <input type="radio" name="insertMode" value="replace" ${insertMode === 'replace' ? 'checked' : ''} />
319
+ Replace document
320
+ </label>
321
+ </div>
322
+ </div>
323
+
324
+ <div class="rte-dialog-footer">
325
+ <button class="rte-button-secondary rte-cancel-btn">Cancel</button>
326
+ <button class="rte-button-primary rte-insert-btn" ${!selectedTemplate ? 'disabled' : ''}>
327
+ ${insertMode === 'insert' ? 'Insert' : 'Replace'}
328
+ </button>
329
+ </div>
330
+ `;
331
+
332
+ attachDialogListeners();
333
+ }
334
+
335
+ /**
336
+ * Render warning dialog for replacing content
337
+ */
338
+ function renderWarningDialog(): void {
339
+ if (!dialogElement) return;
340
+
341
+ dialogElement.innerHTML = `
342
+ <div class="rte-dialog-header">
343
+ <h2>Replace Document?</h2>
344
+ </div>
345
+ <div class="rte-dialog-body">
346
+ <p>This will replace your current document content. Continue?</p>
347
+ </div>
348
+ <div class="rte-dialog-footer">
349
+ <button class="rte-button-secondary rte-cancel-warning-btn">Cancel</button>
350
+ <button class="rte-button-primary rte-confirm-replace-btn">Replace</button>
351
+ </div>
352
+ `;
353
+
354
+ // Attach warning listeners
355
+ const cancelBtn = dialogElement.querySelector('.rte-cancel-warning-btn');
356
+ const confirmBtn = dialogElement.querySelector('.rte-confirm-replace-btn');
357
+
358
+ cancelBtn?.addEventListener('click', () => renderDialogContent());
359
+ confirmBtn?.addEventListener('click', () => handleConfirmReplace());
360
+ }
361
+
362
+ /**
363
+ * Get filtered templates based on search and category
364
+ */
365
+ function getFilteredTemplates(): Template[] {
366
+ const allTemplates = getAllTemplates();
367
+
368
+ if (searchTerm.trim()) {
369
+ return searchTemplates(searchTerm);
370
+ } else if (selectedCategory) {
371
+ return allTemplates.filter(t => t.category === selectedCategory);
372
+ }
373
+
374
+ return allTemplates;
375
+ }
376
+
377
+ /**
378
+ * Attach event listeners to dialog elements
379
+ */
380
+ function attachDialogListeners(): void {
381
+ if (!dialogElement) return;
382
+
383
+ // Close button
384
+ const closeBtn = dialogElement.querySelector('.rte-dialog-close');
385
+ closeBtn?.addEventListener('click', () => closeDialog());
386
+
387
+ // Cancel button
388
+ const cancelBtn = dialogElement.querySelector('.rte-cancel-btn');
389
+ cancelBtn?.addEventListener('click', () => closeDialog());
390
+
391
+ // Insert button
392
+ const insertBtn = dialogElement.querySelector('.rte-insert-btn');
393
+ insertBtn?.addEventListener('click', () => handleInsert());
394
+
395
+ // Search input
396
+ const searchInput = dialogElement.querySelector('.rte-template-search') as HTMLInputElement;
397
+ searchInput?.addEventListener('input', (e) => {
398
+ searchTerm = (e.target as HTMLInputElement).value;
399
+ updateTemplateList();
400
+ });
401
+ searchInput?.addEventListener('keydown', (e) => {
402
+ if (e.key === 'Enter' && selectedTemplate) {
403
+ handleInsert();
404
+ } else if (e.key === 'Escape') {
405
+ closeDialog();
406
+ }
407
+ });
408
+
409
+ // Category tabs
410
+ const categoryTabs = dialogElement.querySelectorAll('.rte-tab');
411
+ categoryTabs.forEach(tab => {
412
+ tab.addEventListener('click', () => {
413
+ const category = tab.getAttribute('data-category');
414
+ if (category) {
415
+ selectedCategory = category;
416
+ searchTerm = ''; // Clear search when switching categories
417
+ updateTemplateList();
418
+ }
419
+ });
420
+ });
421
+
422
+ // Template items
423
+ const templateItems = dialogElement.querySelectorAll('.rte-template-item');
424
+ templateItems.forEach(item => {
425
+ item.addEventListener('click', () => {
426
+ const templateId = item.getAttribute('data-template-id');
427
+ if (templateId) {
428
+ const template = getAllTemplates().find(t => t.id === templateId);
429
+ if (template) {
430
+ selectedTemplate = template;
431
+ renderDialogContent();
432
+ }
433
+ }
434
+ });
435
+
436
+ // Double-click to insert
437
+ item.addEventListener('dblclick', () => {
438
+ const templateId = item.getAttribute('data-template-id');
439
+ if (templateId) {
440
+ const template = getAllTemplates().find(t => t.id === templateId);
441
+ if (template) {
442
+ selectedTemplate = template;
443
+ handleInsert();
444
+ }
445
+ }
446
+ });
447
+ });
448
+
449
+ // Insert mode radio buttons
450
+ const insertModeRadios = dialogElement.querySelectorAll('input[name="insertMode"]');
451
+ insertModeRadios.forEach(radio => {
452
+ radio.addEventListener('change', (e) => {
453
+ insertMode = (e.target as HTMLInputElement).value as 'insert' | 'replace';
454
+ renderDialogContent();
455
+ });
456
+ });
457
+ }
458
+
459
+ /**
460
+ * Update template list after search or category change
461
+ */
462
+ function updateTemplateList(): void {
463
+ const filteredTemplates = getFilteredTemplates();
464
+
465
+ // Update selected template if it's not in filtered list
466
+ if (filteredTemplates.length > 0) {
467
+ if (!selectedTemplate || !filteredTemplates.find(t => t.id === selectedTemplate!.id)) {
468
+ selectedTemplate = filteredTemplates[0];
469
+ }
470
+ } else {
471
+ selectedTemplate = null;
472
+ }
473
+
474
+ renderDialogContent();
475
+ }
476
+
477
+ /**
478
+ * Handle insert button click
479
+ */
480
+ function handleInsert(): void {
481
+ if (!selectedTemplate) return;
482
+
483
+ if (insertMode === 'replace') {
484
+ // Find the correct editor based on saved range
485
+ let editor: Element | null = null;
486
+
487
+ if (savedRange) {
488
+ let node: Node | null = savedRange.startContainer;
489
+ while (node && node !== document.body) {
490
+ if (node.nodeType === Node.ELEMENT_NODE) {
491
+ const element = node as Element;
492
+ if (element.getAttribute('contenteditable') === 'true') {
493
+ editor = element;
494
+ break;
495
+ }
496
+ }
497
+ node = node.parentNode;
498
+ }
499
+ }
500
+
501
+ // Fallback to first editor
502
+ if (!editor) {
503
+ editor = document.querySelector('[contenteditable="true"]');
504
+ }
505
+
506
+ if (editor?.innerHTML?.trim()) {
507
+ // Show warning dialog
508
+ renderWarningDialog();
509
+ return;
510
+ }
511
+ replaceDocumentWithTemplate(selectedTemplate);
512
+ closeDialog();
513
+ } else {
514
+ insertTemplateAtCursor(selectedTemplate);
515
+ closeDialog();
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Handle confirm replace in warning dialog
521
+ */
522
+ function handleConfirmReplace(): void {
523
+ if (selectedTemplate) {
524
+ replaceDocumentWithTemplate(selectedTemplate);
525
+ closeDialog();
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Insert template at cursor position
531
+ */
532
+ function insertTemplateAtCursor(template: Template): void {
533
+ // Restore saved selection
534
+ if (savedRange) {
535
+ const selection = window.getSelection();
536
+ if (selection) {
537
+ selection.removeAllRanges();
538
+ selection.addRange(savedRange);
539
+ }
540
+ }
541
+
542
+ const selection = window.getSelection();
543
+ if (!selection || selection.rangeCount === 0) return;
544
+
545
+ const range = selection.getRangeAt(0);
546
+ const fragment = document.createRange().createContextualFragment(sanitizeTemplate(template.html));
547
+
548
+ range.deleteContents();
549
+ range.insertNode(fragment);
550
+
551
+ // Move cursor after inserted template
552
+ const newRange = document.createRange();
553
+ newRange.setStartAfter(range.endContainer);
554
+ newRange.collapse(true);
555
+ selection.removeAllRanges();
556
+ selection.addRange(newRange);
557
+
558
+ // (Optional: Remove debug log)
559
+ }
560
+
561
+ /**
562
+ * Replace entire document with template
563
+ */
564
+ function replaceDocumentWithTemplate(template: Template): void {
565
+ // Find the correct editor based on saved range
566
+ let editor: Element | null = null;
567
+
568
+ if (savedRange) {
569
+ // Find editor containing the saved range
570
+ let node: Node | null = savedRange.startContainer;
571
+ while (node && node !== document.body) {
572
+ if (node.nodeType === Node.ELEMENT_NODE) {
573
+ const element = node as Element;
574
+ if (element.getAttribute('contenteditable') === 'true') {
575
+ editor = element;
576
+ break;
577
+ }
578
+ }
579
+ node = node.parentNode;
580
+ }
581
+ }
582
+
583
+ // Fallback to first editor if we couldn't find the specific one
584
+ if (!editor) {
585
+ editor = document.querySelector('[contenteditable="true"]');
586
+ }
587
+
588
+ if (editor) {
589
+ editor.innerHTML = sanitizeTemplate(template.html);
590
+
591
+ // Trigger input event to notify editor of change
592
+ editor.dispatchEvent(new Event('input', { bubbles: true }));
593
+ }
594
+
595
+ // (Optional: Remove debug log)
596
+ }
597
+
598
+ /**
599
+ * Close dialog
600
+ */
601
+ function closeDialog(): void {
602
+ if (overlayElement) {
603
+ overlayElement.remove();
604
+ overlayElement = null;
605
+ }
606
+ dialogElement = null;
607
+ savedRange = null;
608
+ searchTerm = '';
609
+ }
610
+
611
+ /**
612
+ * Open template dialog
613
+ */
614
+ function openTemplateDialog(context?: any): void {
615
+ // Save current selection
616
+ const selection = window.getSelection();
617
+ if (selection && selection.rangeCount > 0) {
618
+ savedRange = selection.getRangeAt(0).cloneRange();
619
+ } else {
620
+ savedRange = null;
621
+ }
622
+
623
+ // Initialize selected template
624
+ const filteredTemplates = getFilteredTemplates();
625
+ if (filteredTemplates.length > 0 && !selectedTemplate) {
626
+ selectedTemplate = filteredTemplates[0];
627
+ }
628
+
629
+ const editorContent =
630
+ context?.contentElement instanceof HTMLElement
631
+ ? context.contentElement
632
+ : getEditorContentFromSelection();
633
+ createTemplateDialog(editorContent);
634
+ }
635
+
636
+ // ============================================================================
637
+ // Plugin Initialization
638
+ // ============================================================================
639
+
640
+ // Inject dialog styles (with dark theme support)
641
+ function injectTemplateDialogStyles() {
642
+ if (typeof document === 'undefined') return;
643
+ const styleId = 'template-plugin-dialog-styles';
644
+ if (document.getElementById(styleId)) return;
645
+ const style = document.createElement('style');
646
+ style.id = styleId;
647
+ style.textContent = `
648
+ .rte-dialog-overlay {
649
+ --rte-tmpl-overlay-bg: rgba(15, 23, 36, 0.56);
650
+ --rte-tmpl-dialog-bg: #fff;
651
+ --rte-tmpl-dialog-text: #101828;
652
+ --rte-tmpl-border: #d6dbe4;
653
+ --rte-tmpl-subtle-bg: #f7f9fc;
654
+ --rte-tmpl-subtle-hover: #eef2f7;
655
+ --rte-tmpl-muted-text: #5f6b7d;
656
+ --rte-tmpl-accent: #1976d2;
657
+ --rte-tmpl-accent-strong: #1565c0;
658
+ --rte-tmpl-ring: rgba(31, 117, 254, 0.18);
659
+ position: fixed;
660
+ top: 0;
661
+ left: 0;
662
+ right: 0;
663
+ bottom: 0;
664
+ background-color: var(--rte-tmpl-overlay-bg);
665
+ backdrop-filter: blur(2px);
666
+ display: flex;
667
+ align-items: center;
668
+ justify-content: center;
669
+ z-index: 10000;
670
+ padding: 16px;
671
+ box-sizing: border-box;
672
+ }
673
+ .rte-dialog-overlay.rte-ui-theme-dark {
674
+ --rte-tmpl-overlay-bg: rgba(2, 8, 20, 0.72);
675
+ --rte-tmpl-dialog-bg: #202938;
676
+ --rte-tmpl-dialog-text: #e8effc;
677
+ --rte-tmpl-border: #49566c;
678
+ --rte-tmpl-subtle-bg: #2a3444;
679
+ --rte-tmpl-subtle-hover: #344256;
680
+ --rte-tmpl-muted-text: #a5b1c5;
681
+ --rte-tmpl-accent: #58a6ff;
682
+ --rte-tmpl-accent-strong: #4598f4;
683
+ --rte-tmpl-ring: rgba(88, 166, 255, 0.22);
684
+ }
685
+ .rte-template-dialog {
686
+ background: var(--rte-tmpl-dialog-bg);
687
+ color: var(--rte-tmpl-dialog-text);
688
+ border: 1px solid var(--rte-tmpl-border);
689
+ border-radius: 12px;
690
+ box-shadow: 0 24px 48px rgba(10, 15, 24, 0.28);
691
+ width: 600px;
692
+ max-height: 700px;
693
+ display: flex;
694
+ flex-direction: column;
695
+ overflow: hidden;
696
+ }
697
+ .rte-dialog-header {
698
+ display: flex;
699
+ justify-content: space-between;
700
+ align-items: center;
701
+ padding: 16px 20px;
702
+ border-bottom: 1px solid var(--rte-tmpl-border);
703
+ background: linear-gradient(180deg, rgba(127, 154, 195, 0.08) 0%, rgba(127, 154, 195, 0) 100%);
704
+ }
705
+ .rte-dialog-header h2 {
706
+ margin: 0;
707
+ font-size: 18px;
708
+ font-weight: 600;
709
+ color: var(--rte-tmpl-dialog-text);
710
+ }
711
+ .rte-dialog-close {
712
+ background: none;
713
+ border: none;
714
+ font-size: 24px;
715
+ cursor: pointer;
716
+ color: var(--rte-tmpl-muted-text);
717
+ padding: 0;
718
+ width: 32px;
719
+ height: 32px;
720
+ display: flex;
721
+ align-items: center;
722
+ justify-content: center;
723
+ border-radius: 8px;
724
+ transition: background-color 0.16s ease, color 0.16s ease;
725
+ }
726
+ .rte-dialog-close:hover {
727
+ background-color: var(--rte-tmpl-subtle-hover);
728
+ color: var(--rte-tmpl-dialog-text);
729
+ }
730
+ .rte-dialog-body {
731
+ padding: 20px;
732
+ flex: 1;
733
+ overflow-y: auto;
734
+ }
735
+ .rte-input {
736
+ width: 100%;
737
+ padding: 8px 12px;
738
+ border: 1px solid var(--rte-tmpl-border);
739
+ border-radius: 4px;
740
+ font-size: 14px;
741
+ box-sizing: border-box;
742
+ background: var(--rte-tmpl-subtle-bg);
743
+ color: var(--rte-tmpl-dialog-text);
744
+ }
745
+ .rte-input:focus {
746
+ outline: none;
747
+ border-color: var(--rte-tmpl-accent);
748
+ }
749
+ .rte-tabs {
750
+ display: flex;
751
+ gap: 8px;
752
+ margin-top: 12px;
753
+ border-bottom: 1px solid var(--rte-tmpl-border);
754
+ padding-bottom: 8px;
755
+ }
756
+ .rte-tab {
757
+ padding: 6px 12px;
758
+ border: none;
759
+ background: none;
760
+ cursor: pointer;
761
+ font-size: 14px;
762
+ color: var(--rte-tmpl-muted-text);
763
+ border-bottom: 2px solid transparent;
764
+ transition: all 0.2s;
765
+ }
766
+ .rte-tab:hover {
767
+ color: var(--rte-tmpl-dialog-text);
768
+ }
769
+ .rte-tab.active {
770
+ color: var(--rte-tmpl-accent);
771
+ border-bottom-color: var(--rte-tmpl-accent);
772
+ font-weight: 600;
773
+ }
774
+ .rte-template-list {
775
+ border: 1px solid var(--rte-tmpl-border);
776
+ border-radius: 4px;
777
+ max-height: 250px;
778
+ overflow-y: auto;
779
+ margin: 12px 0;
780
+ background: var(--rte-tmpl-subtle-bg);
781
+ }
782
+ .rte-template-item {
783
+ padding: 12px;
784
+ border-bottom: 1px solid var(--rte-tmpl-border);
785
+ cursor: pointer;
786
+ transition: background-color 0.2s;
787
+ background: none;
788
+ }
789
+ .rte-template-item:last-child {
790
+ border-bottom: none;
791
+ }
792
+ .rte-template-item:hover,
793
+ .rte-template-item.selected {
794
+ background-color: var(--rte-tmpl-subtle-hover);
795
+ }
796
+ .template-name {
797
+ font-weight: 600;
798
+ color: var(--rte-tmpl-dialog-text);
799
+ margin-bottom: 4px;
800
+ }
801
+ .template-description {
802
+ font-size: 12px;
803
+ color: var(--rte-tmpl-muted-text);
804
+ }
805
+ .rte-template-preview {
806
+ padding: 12px;
807
+ background-color: var(--rte-tmpl-subtle-bg);
808
+ border: 1px solid var(--rte-tmpl-border);
809
+ border-radius: 4px;
810
+ margin-top: 12px;
811
+ max-height: 200px;
812
+ overflow-y: auto;
813
+ }
814
+ .template-preview-content {
815
+ font-size: 13px;
816
+ line-height: 1.5;
817
+ margin-top: 8px;
818
+ }
819
+ .template-preview-content * {
820
+ margin: 4px 0;
821
+ }
822
+ .rte-insert-mode {
823
+ margin-top: 12px;
824
+ padding: 12px;
825
+ background-color: var(--rte-tmpl-subtle-bg);
826
+ border-radius: 4px;
827
+ display: flex;
828
+ gap: 16px;
829
+ }
830
+ .rte-insert-mode label {
831
+ display: flex;
832
+ align-items: center;
833
+ cursor: pointer;
834
+ font-size: 14px;
835
+ }
836
+ .rte-insert-mode input {
837
+ margin-right: 6px;
838
+ cursor: pointer;
839
+ }
840
+ .rte-empty-state {
841
+ padding: 40px;
842
+ text-align: center;
843
+ color: var(--rte-tmpl-muted-text);
844
+ font-size: 14px;
845
+ }
846
+ .rte-dialog-footer {
847
+ padding: 16px 20px;
848
+ border-top: 1px solid var(--rte-tmpl-border);
849
+ background: var(--rte-tmpl-subtle-bg);
850
+ display: flex;
851
+ justify-content: flex-end;
852
+ gap: 12px;
853
+ }
854
+ .rte-button-primary {
855
+ padding: 8px 16px;
856
+ border: none;
857
+ border-radius: 4px;
858
+ font-size: 14px;
859
+ cursor: pointer;
860
+ background-color: var(--rte-tmpl-accent);
861
+ color: white;
862
+ transition: all 0.2s;
863
+ }
864
+ .rte-button-primary:hover:not([disabled]) {
865
+ background-color: var(--rte-tmpl-accent-strong);
866
+ }
867
+ .rte-button-primary[disabled] {
868
+ opacity: 0.5;
869
+ cursor: not-allowed;
870
+ }
871
+ .rte-button-secondary {
872
+ padding: 8px 16px;
873
+ border: 1px solid var(--rte-tmpl-border);
874
+ border-radius: 4px;
875
+ font-size: 14px;
876
+ cursor: pointer;
877
+ background-color: var(--rte-tmpl-subtle-bg);
878
+ color: var(--rte-tmpl-dialog-text);
879
+ transition: all 0.2s;
880
+ }
881
+ .rte-button-secondary:hover {
882
+ background-color: var(--rte-tmpl-subtle-hover);
883
+ }
884
+ `;
885
+ document.head.appendChild(style);
886
+ }
887
+
888
+ // ============================================================================
889
+ // Plugin Definition
890
+ // ============================================================================
891
+
892
+ export const TemplatePlugin = (): Plugin => ({
893
+ name: 'template',
894
+ toolbar: [
895
+ {
896
+ label: 'Template',
897
+ command: 'insertTemplate',
898
+ icon: '<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path fill-rule="evenodd" clip-rule="evenodd" d="M3 3V9H21V3H3ZM19 5H5V7H19V5Z" fill="#000000"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M3 11V21H11V11H3ZM9 13H5V19H9V13Z" fill="#000000"></path> <path d="M21 11H13V13H21V11Z" fill="#000000"></path> <path d="M13 15H21V17H13V15Z" fill="#000000"></path> <path d="M21 19H13V21H21V19Z" fill="#000000"></path> </g></svg>'
899
+ }
900
+ ],
901
+ commands: {
902
+ insertTemplate: (_args: unknown, context: any) => {
903
+ openTemplateDialog(context);
904
+ return true;
905
+ }
906
+ },
907
+ keymap: {}
908
+ });