@oix1987/yjd 1.0.3 → 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.
Files changed (70) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +146 -142
  3. package/core.js +77 -0
  4. package/dist/core.esm.js +2 -0
  5. package/dist/core.esm.js.map +1 -0
  6. package/dist/rich-editor.esm.js +1 -1
  7. package/dist/rich-editor.esm.js.map +1 -1
  8. package/dist/rich-editor.min.js +1 -1
  9. package/dist/rich-editor.min.js.map +1 -1
  10. package/index.d.ts +134 -103
  11. package/index.js +227 -0
  12. package/lib/core/editor.js +1806 -0
  13. package/lib/core/format.js +540 -0
  14. package/lib/core/module.js +81 -0
  15. package/lib/core/registry.js +158 -0
  16. package/lib/formats/background.js +213 -0
  17. package/lib/formats/bold.js +49 -0
  18. package/lib/formats/capitalization.js +579 -0
  19. package/lib/formats/color.js +183 -0
  20. package/lib/formats/emoji.js +282 -0
  21. package/lib/formats/font-family.js +548 -0
  22. package/lib/formats/heading.js +502 -0
  23. package/lib/formats/image.js +347 -0
  24. package/lib/formats/import.js +385 -0
  25. package/lib/formats/indent.js +297 -0
  26. package/lib/formats/italic.js +27 -0
  27. package/lib/formats/line-height.js +562 -0
  28. package/lib/formats/link.js +251 -0
  29. package/lib/formats/list.js +635 -0
  30. package/lib/formats/strike.js +31 -0
  31. package/lib/formats/subscript.js +40 -0
  32. package/lib/formats/superscript.js +39 -0
  33. package/lib/formats/table.js +293 -0
  34. package/lib/formats/tag.js +304 -0
  35. package/lib/formats/text-align.js +422 -0
  36. package/lib/formats/text-size.js +498 -0
  37. package/lib/formats/underline.js +30 -0
  38. package/lib/formats/video.js +381 -0
  39. package/lib/modules/block-toolbar.js +639 -0
  40. package/lib/modules/code-view.js +447 -0
  41. package/lib/modules/find-replace.js +273 -0
  42. package/lib/modules/history.js +425 -0
  43. package/lib/modules/resize-handles.js +701 -0
  44. package/lib/modules/slash-menu.js +183 -0
  45. package/lib/modules/table-toolbar.js +635 -0
  46. package/lib/modules/toolbar.js +607 -0
  47. package/lib/styles-loader.js +142 -0
  48. package/{dist → lib}/styles.css +1285 -35
  49. package/lib/styles.css.js +2 -0
  50. package/lib/styles.min.css +1 -0
  51. package/lib/ui/color-picker.js +296 -0
  52. package/lib/ui/customselect.js +351 -0
  53. package/lib/ui/emoji-picker.js +196 -0
  54. package/lib/ui/icons.js +145 -0
  55. package/lib/ui/image-popup.js +435 -0
  56. package/lib/ui/import-popup.js +288 -0
  57. package/lib/ui/link-popup.js +139 -0
  58. package/lib/ui/list-picker.js +307 -0
  59. package/lib/ui/select-button.js +68 -0
  60. package/lib/ui/table-popup.js +171 -0
  61. package/lib/ui/tag-popup.js +249 -0
  62. package/lib/ui/text-align-picker.js +278 -0
  63. package/lib/ui/video-popup.js +413 -0
  64. package/lib/utils/exec-command.js +72 -0
  65. package/lib/utils/history-helper.js +50 -0
  66. package/lib/utils/popup-helper.js +219 -0
  67. package/lib/utils/popup-positioning.js +234 -0
  68. package/lib/utils/sanitize.js +164 -0
  69. package/package.json +51 -32
  70. package/umd-entry.js +18 -0
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Video Popup Component - Popup for inserting videos
3
+ */
4
+ import { appendPopup, calculatePopupPosition, setPopupPosition } from '../utils/popup-helper.js';
5
+
6
+ class VideoPopup {
7
+ constructor(options = {}) {
8
+ this.options = {
9
+ onVideoInsert: null,
10
+ editor: null,
11
+ ...options
12
+ };
13
+
14
+ this.popup = null;
15
+ this.isVisible = false;
16
+ this.clickOutsideHandler = null;
17
+ this.selectedVideoSrc = null;
18
+ this.savedSelection = null; // Save editor selection
19
+
20
+ this.createVideoPopup();
21
+ }
22
+
23
+ /**
24
+ * Create video popup
25
+ */
26
+ createVideoPopup() {
27
+ this.popup = document.createElement('div');
28
+ this.popup.className = 'video-popup';
29
+
30
+ const content = document.createElement('div');
31
+ content.className = 'video-popup-content';
32
+
33
+ // Title
34
+ const title = document.createElement('h3');
35
+ title.textContent = 'Insert video';
36
+ title.className = 'yjd-input-title';
37
+ content.appendChild(title);
38
+
39
+ // Container
40
+ const uploadContainer = document.createElement('div');
41
+ uploadContainer.className = 'video-input-container';
42
+
43
+ const textLabel = document.createElement('p');
44
+ textLabel.textContent = 'Your video url';
45
+ textLabel.className = 'yjd-input-label';
46
+
47
+ const inputgroup1 = document.createElement('div');
48
+ inputgroup1.className = 'yjd-input-upload-group';
49
+ this.inputGroup = inputgroup1; // Store reference
50
+
51
+ // input url
52
+ this.urlInput = document.createElement('input');
53
+ this.urlInput.type = 'url';
54
+ this.urlInput.className = 'yjd-input';
55
+ this.urlInput.placeholder = 'Please enter your video URL';
56
+ this.urlInput.addEventListener('input', () => {
57
+ this.updateInsertButton();
58
+ // Show preview if URL is valid
59
+ const url = this.urlInput.value.trim();
60
+ if (url && this.isValidVideoUrl(url)) {
61
+ this.showPreview(url);
62
+ } else {
63
+ this.removePreview();
64
+ }
65
+ if(this.urlInput.value.trim()){
66
+ this.customButton.style.display = 'none';
67
+ }else{
68
+ this.customButton.style.display = 'block';
69
+ }
70
+ });
71
+
72
+ // Hidden file input
73
+ this.fileInput = document.createElement('input');
74
+ this.fileInput.type = 'file';
75
+ this.fileInput.accept = 'video/*';
76
+ this.fileInput.className = 'image-input-hidden'; // ẩn bằng CSS
77
+ this.fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
78
+
79
+ // Custom button
80
+ const customButton = document.createElement('button');
81
+ customButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>`;
82
+ customButton.className = 'yjd-custom-upload-button';
83
+ this.customButton = customButton;
84
+ customButton.addEventListener('click', () => this.fileInput.click());
85
+
86
+ // Create preview container
87
+ this.createPreviewContainer();
88
+
89
+ // Append elements
90
+ inputgroup1.appendChild(this.urlInput);
91
+ inputgroup1.appendChild(this.fileInput);
92
+ inputgroup1.appendChild(customButton);
93
+ uploadContainer.appendChild(textLabel);
94
+ uploadContainer.appendChild(inputgroup1);
95
+ uploadContainer.appendChild(this.previewContainer);
96
+ content.appendChild(uploadContainer);
97
+
98
+ // Buttons
99
+ const buttonContainer = document.createElement('div');
100
+ buttonContainer.className = 'yjd-button-container';
101
+
102
+ const cancelButton = document.createElement('button');
103
+ cancelButton.type = 'button';
104
+ cancelButton.className = 'image-button yjd-button-cancel';
105
+ cancelButton.textContent = 'Cancel';
106
+ cancelButton.addEventListener('click', () => {
107
+ this.hide();
108
+ // Maintain editor focus after popup close
109
+ if (this.options.editor) {
110
+ setTimeout(() => this.options.editor.focus(), 0);
111
+ }
112
+ });
113
+
114
+ this.insertButton = document.createElement('button');
115
+ this.insertButton.type = 'button';
116
+ this.insertButton.className = 'image-button yjd-button-confirm button-disable';
117
+ this.insertButton.textContent = 'Add video';
118
+ this.insertButton.disabled = true;
119
+ this.insertButton.addEventListener('click', () => {
120
+ this.insertVideo();
121
+ // Maintain editor focus after insert
122
+ if (this.options.editor) {
123
+ setTimeout(() => this.options.editor.focus(), 0);
124
+ }
125
+ });
126
+
127
+ buttonContainer.appendChild(cancelButton);
128
+ buttonContainer.appendChild(this.insertButton);
129
+ content.appendChild(buttonContainer);
130
+
131
+ this.popup.appendChild(content);
132
+ appendPopup(this.popup);
133
+
134
+ // Prevent focus loss when clicking on popup
135
+ if (this.options.editor && typeof this.options.editor.preventFocusLoss === 'function') {
136
+ this.options.editor.preventFocusLoss(this.popup);
137
+ }
138
+ }
139
+
140
+ async handleFileSelect(e) {
141
+ const file = e.target.files[0];
142
+ if (!file) return;
143
+
144
+ try {
145
+ const { default: Video } = await import('../formats/video.js');
146
+ this.selectedVideoSrc = await Video.handleFileUpload(file);
147
+ this.urlInput.value = '';
148
+ this.showPreview(this.selectedVideoSrc);
149
+ this.updateInsertButton();
150
+ } catch (error) {
151
+ alert(error.message);
152
+ }
153
+ }
154
+
155
+ updateInsertButton() {
156
+ const hasVideo = this.selectedVideoSrc || this.urlInput.value.trim();
157
+ this.insertButton.disabled = !hasVideo;
158
+ this.insertButton.classList.toggle('button-disable', !hasVideo);
159
+ }
160
+
161
+ /**
162
+ * Show video preview
163
+ */
164
+ showPreview(videoSrc) {
165
+ if (!videoSrc) return;
166
+
167
+ this.videoPreview.src = videoSrc;
168
+ this.previewContainer.style.display = 'block';
169
+ this.selectedVideoSrc = videoSrc;
170
+
171
+ // Hide input group
172
+ this.toggleInputGroup(false);
173
+
174
+ // Recalculate position after preview is shown to ensure buttons remain visible
175
+ this.recalculatePosition();
176
+ }
177
+
178
+ /**
179
+ * Remove video preview and show input again
180
+ */
181
+ removePreview() {
182
+ this.selectedVideoSrc = null;
183
+ this.previewContainer.style.display = 'none';
184
+ this.videoPreview.src = '';
185
+
186
+ // Show input group and reset file input
187
+ this.toggleInputGroup(true);
188
+ if (this.fileInput) {
189
+ this.fileInput.value = '';
190
+ }
191
+
192
+ this.updateInsertButton();
193
+
194
+ // Recalculate position after preview is removed
195
+ this.recalculatePosition();
196
+ }
197
+
198
+ /**
199
+ * Toggle input group visibility
200
+ */
201
+ toggleInputGroup(show) {
202
+ if (!this.inputGroup) return;
203
+
204
+ if (show) {
205
+ this.inputGroup.style.display = 'flex';
206
+ this.inputGroup.style.visibility = 'visible';
207
+ if (this.customButton) {
208
+ this.customButton.style.pointerEvents = 'auto';
209
+ }
210
+ } else {
211
+ this.inputGroup.style.display = 'none';
212
+ this.inputGroup.style.visibility = 'hidden';
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Create preview container with video and remove button
218
+ */
219
+ createPreviewContainer() {
220
+ this.previewContainer = document.createElement('div');
221
+ this.previewContainer.className = 'video-preview-container';
222
+ this.previewContainer.style.cssText = 'display: none; position: relative;';
223
+
224
+ // Video preview
225
+ this.videoPreview = document.createElement('video');
226
+ this.videoPreview.className = 'video-preview';
227
+ this.videoPreview.style.cssText = 'max-width: 100%; max-height: 200px; border-radius: 8px; object-fit: contain;';
228
+ this.videoPreview.controls = true;
229
+ this.videoPreview.muted = true;
230
+
231
+ // Remove button
232
+ this.removeButton = document.createElement('button');
233
+ this.removeButton.className = 'video-remove-button';
234
+ this.removeButton.innerHTML = '×';
235
+ this.removeButton.style.cssText = `
236
+ position: absolute; top: 5px; right: 5px; background: rgba(0,0,0,0.7);
237
+ color: white; border: none; border-radius: 50%; width: 24px; height: 24px;
238
+ cursor: pointer; font-size: 16px; font-weight: bold;
239
+ `;
240
+ this.removeButton.addEventListener('click', () => this.removePreview());
241
+
242
+ this.previewContainer.appendChild(this.videoPreview);
243
+ this.previewContainer.appendChild(this.removeButton);
244
+ }
245
+
246
+ /**
247
+ * Check if URL is a valid video URL
248
+ */
249
+ isValidVideoUrl(url) {
250
+ try {
251
+ const urlObj = new URL(url);
252
+ const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv'];
253
+ const videoHosts = ['youtube.com', 'youtu.be', 'vimeo.com', 'dailymotion.com'];
254
+
255
+ const pathname = urlObj.pathname.toLowerCase();
256
+ const hasVideoExtension = videoExtensions.some(ext => pathname.endsWith(ext));
257
+ const isFromVideoHost = videoHosts.some(host => urlObj.hostname.includes(host));
258
+
259
+ return hasVideoExtension || isFromVideoHost;
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ async insertVideo() {
266
+ let src = this.selectedVideoSrc || this.urlInput.value.trim();
267
+
268
+ if (!src) return;
269
+
270
+ // Always validate URL (both file upload and URL input)
271
+ try {
272
+ const { default: Video } = await import('../formats/video.js');
273
+ const isValid = await Video.validateVideoUrl(src);
274
+ if (!isValid) {
275
+ alert('Invalid video URL. Please check the URL and try again.');
276
+ return;
277
+ }
278
+ } catch (error) {
279
+ alert('Error validating video URL.');
280
+ return;
281
+ }
282
+
283
+ // Restore editor selection before inserting
284
+ this.restoreSelection();
285
+
286
+ if (this.options.onVideoInsert) {
287
+ this.options.onVideoInsert(src);
288
+ }
289
+
290
+ this.hide();
291
+ this.reset();
292
+ }
293
+
294
+ reset() {
295
+ this.fileInput.value = '';
296
+ this.urlInput.value = '';
297
+ this.selectedVideoSrc = null;
298
+
299
+ // Hide preview and show input
300
+ this.previewContainer.style.display = 'none';
301
+ this.videoPreview.src = '';
302
+ this.toggleInputGroup(true);
303
+
304
+ this.updateInsertButton();
305
+ this.customButton.style.display = 'block';
306
+ }
307
+
308
+ /**
309
+ * Save current editor selection
310
+ */
311
+ saveSelection() {
312
+ const selection = window.getSelection();
313
+ if (selection && selection.rangeCount > 0) {
314
+ this.savedSelection = selection.getRangeAt(0).cloneRange();
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Restore editor selection
320
+ */
321
+ restoreSelection() {
322
+ if (this.savedSelection) {
323
+ const selection = window.getSelection();
324
+ selection.removeAllRanges();
325
+ selection.addRange(this.savedSelection);
326
+ }
327
+ }
328
+
329
+ setupClickOutside() {
330
+ if (this.clickOutsideHandler) {
331
+ document.removeEventListener('click', this.clickOutsideHandler);
332
+ }
333
+
334
+ this.clickOutsideHandler = (e) => {
335
+ if (!this.popup.contains(e.target)) {
336
+ this.hide();
337
+ }
338
+ };
339
+
340
+ setTimeout(() => {
341
+ document.addEventListener('click', this.clickOutsideHandler);
342
+ }, 100);
343
+ }
344
+
345
+ removeClickOutside() {
346
+ if (this.clickOutsideHandler) {
347
+ document.removeEventListener('click', this.clickOutsideHandler);
348
+ this.clickOutsideHandler = null;
349
+ }
350
+ }
351
+
352
+ show(anchor) {
353
+ if (!anchor) return;
354
+
355
+ // Save current editor selection before showing popup
356
+ this.saveSelection();
357
+
358
+ // Reset state when showing popup
359
+ this.reset();
360
+
361
+ // Store anchor for recalculation
362
+ this.currentAnchor = anchor;
363
+
364
+ // Calculate and set popup position
365
+ const position = calculatePopupPosition(anchor, this.popup, {
366
+ offsetY: 5,
367
+ offsetX: 0
368
+ });
369
+ setPopupPosition(this.popup, position);
370
+
371
+ this.popup.classList.add('visible');
372
+ this.isVisible = true;
373
+
374
+ this.setupClickOutside();
375
+ }
376
+
377
+ /**
378
+ * Recalculate popup position to ensure it stays within viewport
379
+ */
380
+ recalculatePosition() {
381
+ if (!this.currentAnchor || !this.isVisible) return;
382
+
383
+ // Small delay to ensure DOM updates are complete
384
+ setTimeout(() => {
385
+ const position = calculatePopupPosition(this.currentAnchor, this.popup, {
386
+ offsetY: 5,
387
+ offsetX: 0
388
+ });
389
+ setPopupPosition(this.popup, position);
390
+ }, 10);
391
+ }
392
+
393
+ hide() {
394
+ this.popup.classList.remove('visible');
395
+ this.isVisible = false;
396
+ this.removeClickOutside();
397
+ // Clear saved selection to avoid memory leaks
398
+ this.savedSelection = null;
399
+ }
400
+
401
+ destroy() {
402
+ this.removeClickOutside();
403
+
404
+ if (this.popup && this.popup.parentNode) {
405
+ this.popup.parentNode.removeChild(this.popup);
406
+ }
407
+
408
+ this.popup = null;
409
+ this.isVisible = false;
410
+ }
411
+ }
412
+
413
+ export default VideoPopup;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * execCommand wrapper — single migration point for the deprecated
3
+ * document.execCommand / queryCommand* family.
4
+ *
5
+ * document.execCommand is deprecated. It still works in every current browser
6
+ * and remains the most reliable way to toggle inline formatting across complex
7
+ * selections, so we keep using it for now — but ONLY through this module.
8
+ * Centralizing it here means:
9
+ * - consistent try/catch (these APIs throw in detached/edge cases),
10
+ * - one place to add feature detection or a fallback,
11
+ * - one place to perform a future Range-API migration without touching every
12
+ * format file.
13
+ *
14
+ * Prefer these helpers over calling document.execCommand directly.
15
+ */
16
+
17
+ /**
18
+ * Execute a formatting command.
19
+ * @param {string} command - execCommand command name (e.g. 'bold', 'foreColor')
20
+ * @param {string|null} [value] - command value, when applicable
21
+ * @returns {boolean} true if the command ran without throwing
22
+ */
23
+ export function execFormat(command, value = null) {
24
+ try {
25
+ // Omit the value argument when none is given. Passing null explicitly makes
26
+ // some commands stringify it (e.g. insertHorizontalRule would set id="null").
27
+ return value == null
28
+ ? document.execCommand(command, false)
29
+ : document.execCommand(command, false, value);
30
+ } catch (e) {
31
+ console.warn(`execCommand('${command}') failed:`, e);
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Enable/disable styleWithCSS (so commands emit inline styles instead of
38
+ * deprecated presentational tags like <font>). Safe to call before each
39
+ * styling command.
40
+ * @param {boolean} [enabled=true]
41
+ */
42
+ export function setStyleWithCSS(enabled = true) {
43
+ return execFormat('styleWithCSS', enabled);
44
+ }
45
+
46
+ /**
47
+ * Query whether a command is currently active for the selection.
48
+ * @param {string} command
49
+ * @returns {boolean}
50
+ */
51
+ export function queryFormatState(command) {
52
+ try {
53
+ return document.queryCommandState(command);
54
+ } catch (e) {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Query the current value of a command for the selection.
61
+ * @param {string} command
62
+ * @returns {string} empty string if unsupported/unavailable
63
+ */
64
+ export function queryFormatValue(command) {
65
+ try {
66
+ return document.queryCommandValue(command) || '';
67
+ } catch (e) {
68
+ return '';
69
+ }
70
+ }
71
+
72
+ export default { execFormat, setStyleWithCSS, queryFormatState, queryFormatValue };
@@ -0,0 +1,50 @@
1
+ import Editor from '../core/editor.js';
2
+
3
+ /**
4
+ * Helper function to save history state before applying format
5
+ * This should be called by all format operations to ensure proper undo/redo functionality
6
+ */
7
+ export function saveBeforeFormat() {
8
+ const editor = Editor.getCurrentInstance();
9
+ if (editor) {
10
+ const historyModule = editor.getModule('history');
11
+ if (historyModule && typeof historyModule.saveBeforeFormat === 'function') {
12
+ historyModule.saveBeforeFormat();
13
+ }
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Helper function to trigger content change after format operations
19
+ * This ensures onChange callback is called when formatting is applied
20
+ */
21
+ export function triggerContentChange() {
22
+ const editor = Editor.getCurrentInstance();
23
+ if (editor && typeof editor.onContentChange === 'function') {
24
+ // Use setTimeout to ensure the DOM changes are complete
25
+ setTimeout(() => {
26
+ editor.onContentChange();
27
+ }, 0);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Helper function to save before format and trigger content change
33
+ * This is a convenience function that combines both operations
34
+ */
35
+ export function saveBeforeFormatAndTriggerChange() {
36
+ saveBeforeFormat();
37
+ triggerContentChange();
38
+ }
39
+
40
+ /**
41
+ * Helper function to check if history module is available
42
+ */
43
+ export function hasHistoryModule() {
44
+ const editor = Editor.getCurrentInstance();
45
+ if (editor) {
46
+ const historyModule = editor.getModule('history');
47
+ return historyModule && typeof historyModule.saveBeforeFormat === 'function';
48
+ }
49
+ return false;
50
+ }