@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.
- package/dist/index.cjs.js +356 -0
- package/dist/index.esm.js +560 -0
- package/package.json +35 -0
- package/src/TemplatePlugin.native.ts +908 -0
- package/src/index.ts +1 -0
- package/src/types/dompurify.d.ts +1 -0
|
@@ -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
|
+
});
|