@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.
- package/LICENSE +15 -0
- package/README.md +146 -142
- package/core.js +77 -0
- package/dist/core.esm.js +2 -0
- package/dist/core.esm.js.map +1 -0
- package/dist/rich-editor.esm.js +1 -1
- package/dist/rich-editor.esm.js.map +1 -1
- package/dist/rich-editor.min.js +1 -1
- package/dist/rich-editor.min.js.map +1 -1
- package/index.d.ts +134 -103
- package/index.js +227 -0
- package/lib/core/editor.js +1806 -0
- package/lib/core/format.js +540 -0
- package/lib/core/module.js +81 -0
- package/lib/core/registry.js +158 -0
- package/lib/formats/background.js +213 -0
- package/lib/formats/bold.js +49 -0
- package/lib/formats/capitalization.js +579 -0
- package/lib/formats/color.js +183 -0
- package/lib/formats/emoji.js +282 -0
- package/lib/formats/font-family.js +548 -0
- package/lib/formats/heading.js +502 -0
- package/lib/formats/image.js +347 -0
- package/lib/formats/import.js +385 -0
- package/lib/formats/indent.js +297 -0
- package/lib/formats/italic.js +27 -0
- package/lib/formats/line-height.js +562 -0
- package/lib/formats/link.js +251 -0
- package/lib/formats/list.js +635 -0
- package/lib/formats/strike.js +31 -0
- package/lib/formats/subscript.js +40 -0
- package/lib/formats/superscript.js +39 -0
- package/lib/formats/table.js +293 -0
- package/lib/formats/tag.js +304 -0
- package/lib/formats/text-align.js +422 -0
- package/lib/formats/text-size.js +498 -0
- package/lib/formats/underline.js +30 -0
- package/lib/formats/video.js +381 -0
- package/lib/modules/block-toolbar.js +639 -0
- package/lib/modules/code-view.js +447 -0
- package/lib/modules/find-replace.js +273 -0
- package/lib/modules/history.js +425 -0
- package/lib/modules/resize-handles.js +701 -0
- package/lib/modules/slash-menu.js +183 -0
- package/lib/modules/table-toolbar.js +635 -0
- package/lib/modules/toolbar.js +607 -0
- package/lib/styles-loader.js +142 -0
- package/{dist → lib}/styles.css +1285 -35
- package/lib/styles.css.js +2 -0
- package/lib/styles.min.css +1 -0
- package/lib/ui/color-picker.js +296 -0
- package/lib/ui/customselect.js +351 -0
- package/lib/ui/emoji-picker.js +196 -0
- package/lib/ui/icons.js +145 -0
- package/lib/ui/image-popup.js +435 -0
- package/lib/ui/import-popup.js +288 -0
- package/lib/ui/link-popup.js +139 -0
- package/lib/ui/list-picker.js +307 -0
- package/lib/ui/select-button.js +68 -0
- package/lib/ui/table-popup.js +171 -0
- package/lib/ui/tag-popup.js +249 -0
- package/lib/ui/text-align-picker.js +278 -0
- package/lib/ui/video-popup.js +413 -0
- package/lib/utils/exec-command.js +72 -0
- package/lib/utils/history-helper.js +50 -0
- package/lib/utils/popup-helper.js +219 -0
- package/lib/utils/popup-positioning.js +234 -0
- package/lib/utils/sanitize.js +164 -0
- package/package.json +51 -32
- package/umd-entry.js +18 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Popup Helper Utility
|
|
3
|
+
* Helps popups append to the yjd-rich-editor instead of document.body
|
|
4
|
+
* Now supports multiple editor instances with separate popup containers
|
|
5
|
+
*/
|
|
6
|
+
import Editor from '../core/editor.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get the appropriate container for popups
|
|
10
|
+
* @param {string} editorId - Optional editor instance ID
|
|
11
|
+
* @returns {HTMLElement} Container element for popups
|
|
12
|
+
*/
|
|
13
|
+
export function getPopupContainer(editorId = null) {
|
|
14
|
+
let editor;
|
|
15
|
+
|
|
16
|
+
if (editorId) {
|
|
17
|
+
// Get specific editor instance
|
|
18
|
+
editor = Editor.getInstanceById(editorId);
|
|
19
|
+
} else {
|
|
20
|
+
// Try to get current editor instance
|
|
21
|
+
editor = Editor.getCurrentInstance();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (editor) {
|
|
25
|
+
return editor.getPopupContainer();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Fallback to document.body if no editor instance
|
|
29
|
+
return document.body;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Append popup to the appropriate container
|
|
34
|
+
* @param {HTMLElement} popup - Popup element to append
|
|
35
|
+
* @param {string} editorId - Optional editor instance ID
|
|
36
|
+
*/
|
|
37
|
+
export function appendPopup(popup, editorId = null) {
|
|
38
|
+
const container = getPopupContainer(editorId);
|
|
39
|
+
|
|
40
|
+
// Remove from current parent if exists
|
|
41
|
+
if (popup.parentNode) {
|
|
42
|
+
popup.parentNode.removeChild(popup);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
container.appendChild(popup);
|
|
46
|
+
|
|
47
|
+
// Note: pointer-events are now controlled by CSS rules
|
|
48
|
+
// Popup containers have pointer-events: none by default
|
|
49
|
+
// Interactive elements inside popups have pointer-events: auto
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get popup dimensions by temporarily showing it if needed
|
|
54
|
+
* @param {HTMLElement} popup - Popup element
|
|
55
|
+
* @returns {Object} Object with width and height
|
|
56
|
+
*/
|
|
57
|
+
function getPopupDimensions(popup) {
|
|
58
|
+
if (!popup) return { width: 300, height: 200 };
|
|
59
|
+
|
|
60
|
+
// Try getBoundingClientRect first
|
|
61
|
+
const rect = popup.getBoundingClientRect();
|
|
62
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
63
|
+
return { width: rect.width, height: rect.height };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Try offsetWidth/offsetHeight
|
|
67
|
+
if (popup.offsetWidth > 0 && popup.offsetHeight > 0) {
|
|
68
|
+
return { width: popup.offsetWidth, height: popup.offsetHeight };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check if popup is hidden
|
|
72
|
+
const computedStyle = window.getComputedStyle(popup);
|
|
73
|
+
const isHidden = computedStyle.display === 'none' || computedStyle.visibility === 'hidden';
|
|
74
|
+
|
|
75
|
+
if (isHidden) {
|
|
76
|
+
// Temporarily show popup to get dimensions
|
|
77
|
+
const originalDisplay = popup.style.display;
|
|
78
|
+
const originalVisibility = popup.style.visibility;
|
|
79
|
+
const originalPosition = popup.style.position;
|
|
80
|
+
const originalTop = popup.style.top;
|
|
81
|
+
const originalLeft = popup.style.left;
|
|
82
|
+
const originalZIndex = popup.style.zIndex;
|
|
83
|
+
|
|
84
|
+
// Make popup visible but off-screen
|
|
85
|
+
popup.style.display = 'block';
|
|
86
|
+
popup.style.visibility = 'visible';
|
|
87
|
+
popup.style.position = 'absolute';
|
|
88
|
+
popup.style.top = '-9999px';
|
|
89
|
+
popup.style.left = '-9999px';
|
|
90
|
+
popup.style.zIndex = '-1';
|
|
91
|
+
|
|
92
|
+
// Force reflow
|
|
93
|
+
popup.offsetHeight;
|
|
94
|
+
|
|
95
|
+
// Get dimensions
|
|
96
|
+
const tempRect = popup.getBoundingClientRect();
|
|
97
|
+
const width = tempRect.width > 0 ? tempRect.width : 300;
|
|
98
|
+
const height = tempRect.height > 0 ? tempRect.height : 200;
|
|
99
|
+
|
|
100
|
+
// Restore original styles
|
|
101
|
+
popup.style.display = originalDisplay;
|
|
102
|
+
popup.style.visibility = originalVisibility;
|
|
103
|
+
popup.style.position = originalPosition;
|
|
104
|
+
popup.style.top = originalTop;
|
|
105
|
+
popup.style.left = originalLeft;
|
|
106
|
+
popup.style.zIndex = originalZIndex;
|
|
107
|
+
|
|
108
|
+
return { width, height };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Last resort: try computed styles
|
|
112
|
+
const computedWidth = parseInt(computedStyle.width);
|
|
113
|
+
const computedHeight = parseInt(computedStyle.height);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
width: computedWidth > 0 ? computedWidth : 300,
|
|
117
|
+
height: computedHeight > 0 ? computedHeight : 200
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Calculate position for popup relative to anchor element
|
|
123
|
+
* @param {HTMLElement} anchor - Anchor element
|
|
124
|
+
* @param {HTMLElement} popup - Popup element
|
|
125
|
+
* @param {Object} options - Positioning options
|
|
126
|
+
* @returns {Object} Position object with top and left values
|
|
127
|
+
*/
|
|
128
|
+
export function calculatePopupPosition(anchor, popup, options = {}) {
|
|
129
|
+
const {
|
|
130
|
+
offsetX = 0,
|
|
131
|
+
offsetY = 5,
|
|
132
|
+
preferTop = false,
|
|
133
|
+
preferLeft = false
|
|
134
|
+
} = options;
|
|
135
|
+
|
|
136
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
137
|
+
const container = getPopupContainer();
|
|
138
|
+
const isInWrapper = container.classList.contains('rich-editor-popup-container');
|
|
139
|
+
|
|
140
|
+
let top, left;
|
|
141
|
+
|
|
142
|
+
if (isInWrapper) {
|
|
143
|
+
// Position relative to wrapper
|
|
144
|
+
const wrapperRect = container.getBoundingClientRect();
|
|
145
|
+
|
|
146
|
+
// Calculate position relative to wrapper
|
|
147
|
+
top = anchorRect.top - wrapperRect.top + anchorRect.height + offsetY;
|
|
148
|
+
left = anchorRect.left - wrapperRect.left + offsetX;
|
|
149
|
+
|
|
150
|
+
// Get popup dimensions using the helper function
|
|
151
|
+
const { width: popupWidth, height: popupHeight } = getPopupDimensions(popup);
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
// Check if popup would overflow bottom of wrapper
|
|
155
|
+
if (top + popupHeight > wrapperRect.height && !preferTop) {
|
|
156
|
+
// Try to position above the anchor
|
|
157
|
+
const topPosition = anchorRect.top - wrapperRect.top - popupHeight - offsetY;
|
|
158
|
+
if (topPosition >= 0) {
|
|
159
|
+
top = topPosition;
|
|
160
|
+
} else {
|
|
161
|
+
// If still doesn't fit, try to center it vertically within the wrapper
|
|
162
|
+
top = Math.max(offsetY, (wrapperRect.height - popupHeight) / 2);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Check if popup would overflow right of wrapper
|
|
167
|
+
if (left + popupWidth + 5 > wrapperRect.width && !preferLeft) {
|
|
168
|
+
left = wrapperRect.width - popupWidth - offsetX -15;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Ensure popup doesn't go off-screen
|
|
172
|
+
if (left < 0) left = offsetX;
|
|
173
|
+
if (top < 0) top = offsetY;
|
|
174
|
+
|
|
175
|
+
} else {
|
|
176
|
+
// Fallback to document.body positioning
|
|
177
|
+
top = anchorRect.bottom + window.scrollY + offsetY;
|
|
178
|
+
left = anchorRect.left + window.scrollX + offsetX;
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
// Get popup dimensions using the helper function
|
|
182
|
+
const { width: popupWidth, height: popupHeight } = getPopupDimensions(popup);
|
|
183
|
+
|
|
184
|
+
// Check if popup would overflow right edge
|
|
185
|
+
if (left + popupWidth > window.innerWidth && !preferLeft) {
|
|
186
|
+
left = window.innerWidth - popupWidth - offsetX;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check if popup would overflow bottom edge
|
|
190
|
+
if (top + popupHeight > window.innerHeight + window.scrollY && !preferTop) {
|
|
191
|
+
// Try to position above the anchor
|
|
192
|
+
const topPosition = anchorRect.top + window.scrollY - popupHeight - offsetY;
|
|
193
|
+
if (topPosition >= window.scrollY) {
|
|
194
|
+
top = topPosition;
|
|
195
|
+
} else {
|
|
196
|
+
// If still doesn't fit, try to center it vertically within the viewport
|
|
197
|
+
top = Math.max(window.scrollY + offsetY, window.scrollY + (window.innerHeight - popupHeight) / 2);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Ensure popup doesn't go off-screen
|
|
202
|
+
if (left < 0) left = offsetX;
|
|
203
|
+
if (top < 0) top = offsetY;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { top, left };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Set popup position
|
|
211
|
+
* @param {HTMLElement} popup - Popup element
|
|
212
|
+
* @param {Object} position - Position object with top and left values
|
|
213
|
+
*/
|
|
214
|
+
export function setPopupPosition(popup, position) {
|
|
215
|
+
popup.style.position = 'absolute';
|
|
216
|
+
popup.style.top = `${position.top}px`;
|
|
217
|
+
popup.style.left = `${position.left}px`;
|
|
218
|
+
popup.style.zIndex = '1000';
|
|
219
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responsive Popup Positioning Utility
|
|
3
|
+
* Handles positioning of popups to ensure they stay within viewport on mobile devices
|
|
4
|
+
*/
|
|
5
|
+
export class PopupPositioning {
|
|
6
|
+
/**
|
|
7
|
+
* Calculate optimal position for popup to stay within viewport
|
|
8
|
+
* @param {HTMLElement} anchor - The anchor element
|
|
9
|
+
* @param {HTMLElement} popup - The popup element
|
|
10
|
+
* @param {Object} options - Positioning options
|
|
11
|
+
* @returns {Object} - Calculated position {top, left, transform}
|
|
12
|
+
*/
|
|
13
|
+
static calculatePosition(anchor, popup, options = {}) {
|
|
14
|
+
const {
|
|
15
|
+
offsetX = 0,
|
|
16
|
+
offsetY = 5,
|
|
17
|
+
preferredPosition = 'bottom-right', // 'bottom-right', 'bottom-left', 'top-right', 'top-left'
|
|
18
|
+
maxWidth = null,
|
|
19
|
+
maxHeight = null
|
|
20
|
+
} = options;
|
|
21
|
+
|
|
22
|
+
// Get viewport dimensions
|
|
23
|
+
const viewportWidth = window.innerWidth;
|
|
24
|
+
const viewportHeight = window.innerHeight;
|
|
25
|
+
const scrollX = window.scrollX;
|
|
26
|
+
const scrollY = window.scrollY;
|
|
27
|
+
|
|
28
|
+
// Get anchor dimensions
|
|
29
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
30
|
+
|
|
31
|
+
// Get popup dimensions (measure if not already rendered)
|
|
32
|
+
let popupWidth = popup.offsetWidth;
|
|
33
|
+
let popupHeight = popup.offsetHeight;
|
|
34
|
+
|
|
35
|
+
// If popup is not yet visible, measure it off-screen WITHOUT adding the
|
|
36
|
+
// `.visible` class — adding that class would trigger the entrance animation
|
|
37
|
+
// during measurement, making the real show flicker / feel laggy.
|
|
38
|
+
let wasVisible = popup.classList.contains('visible');
|
|
39
|
+
let prevDisplay = '';
|
|
40
|
+
let prevVisibility = '';
|
|
41
|
+
if (!wasVisible) {
|
|
42
|
+
prevDisplay = popup.style.display;
|
|
43
|
+
prevVisibility = popup.style.visibility;
|
|
44
|
+
popup.style.visibility = 'hidden';
|
|
45
|
+
popup.style.display = 'block';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Measure popup dimensions
|
|
49
|
+
popupWidth = popup.offsetWidth;
|
|
50
|
+
popupHeight = popup.offsetHeight;
|
|
51
|
+
|
|
52
|
+
// Apply max constraints
|
|
53
|
+
if (maxWidth && popupWidth > maxWidth) {
|
|
54
|
+
popupWidth = maxWidth;
|
|
55
|
+
}
|
|
56
|
+
if (maxHeight && popupHeight > maxHeight) {
|
|
57
|
+
popupHeight = maxHeight;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Restore hidden state
|
|
61
|
+
if (!wasVisible) {
|
|
62
|
+
popup.style.display = prevDisplay;
|
|
63
|
+
popup.style.visibility = prevVisibility;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Calculate base positions for different orientations
|
|
67
|
+
const positions = {
|
|
68
|
+
'bottom-right': {
|
|
69
|
+
top: anchorRect.bottom + scrollY + offsetY,
|
|
70
|
+
left: anchorRect.left + scrollX + offsetX
|
|
71
|
+
},
|
|
72
|
+
'bottom-left': {
|
|
73
|
+
top: anchorRect.bottom + scrollY + offsetY,
|
|
74
|
+
left: anchorRect.right + scrollX - popupWidth - offsetX
|
|
75
|
+
},
|
|
76
|
+
'top-right': {
|
|
77
|
+
top: anchorRect.top + scrollY - popupHeight - offsetY,
|
|
78
|
+
left: anchorRect.left + scrollX + offsetX
|
|
79
|
+
},
|
|
80
|
+
'top-left': {
|
|
81
|
+
top: anchorRect.top + scrollY - popupHeight - offsetY,
|
|
82
|
+
left: anchorRect.right + scrollX - popupWidth - offsetX
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Start with preferred position
|
|
87
|
+
let position = positions[preferredPosition];
|
|
88
|
+
let transform = '';
|
|
89
|
+
|
|
90
|
+
// Check if popup fits in preferred position
|
|
91
|
+
const fitsInPreferred = this.checkFitsInViewport(position, popupWidth, popupHeight, viewportWidth, viewportHeight, scrollX, scrollY);
|
|
92
|
+
|
|
93
|
+
if (!fitsInPreferred) {
|
|
94
|
+
// Try alternative positions
|
|
95
|
+
const alternativePositions = this.getAlternativePositions(preferredPosition);
|
|
96
|
+
|
|
97
|
+
for (const altPosition of alternativePositions) {
|
|
98
|
+
const testPosition = positions[altPosition];
|
|
99
|
+
if (this.checkFitsInViewport(testPosition, popupWidth, popupHeight, viewportWidth, viewportHeight, scrollX, scrollY)) {
|
|
100
|
+
position = testPosition;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// If no position fits, use the best available with adjustments
|
|
106
|
+
if (!this.checkFitsInViewport(position, popupWidth, popupHeight, viewportWidth, viewportHeight, scrollX, scrollY)) {
|
|
107
|
+
position = this.adjustToFitViewport(position, popupWidth, popupHeight, viewportWidth, viewportHeight, scrollX, scrollY);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// For mobile devices, add additional constraints
|
|
112
|
+
if (viewportWidth <= 768) {
|
|
113
|
+
position = this.applyMobileConstraints(position, popupWidth, popupHeight, viewportWidth, viewportHeight, scrollX, scrollY);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
top: position.top,
|
|
118
|
+
left: position.left,
|
|
119
|
+
transform: transform
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if position fits within viewport
|
|
125
|
+
*/
|
|
126
|
+
static checkFitsInViewport(position, width, height, viewportWidth, viewportHeight, scrollX, scrollY) {
|
|
127
|
+
const right = position.left + width;
|
|
128
|
+
const bottom = position.top + height;
|
|
129
|
+
|
|
130
|
+
return position.left >= scrollX &&
|
|
131
|
+
position.top >= scrollY &&
|
|
132
|
+
right <= scrollX + viewportWidth &&
|
|
133
|
+
bottom <= scrollY + viewportHeight;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get alternative positions to try
|
|
138
|
+
*/
|
|
139
|
+
static getAlternativePositions(preferredPosition) {
|
|
140
|
+
const alternatives = {
|
|
141
|
+
'bottom-right': ['bottom-left', 'top-right', 'top-left'],
|
|
142
|
+
'bottom-left': ['bottom-right', 'top-left', 'top-right'],
|
|
143
|
+
'top-right': ['top-left', 'bottom-right', 'bottom-left'],
|
|
144
|
+
'top-left': ['top-right', 'bottom-left', 'bottom-right']
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return alternatives[preferredPosition] || ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Adjust position to fit within viewport
|
|
152
|
+
*/
|
|
153
|
+
static adjustToFitViewport(position, width, height, viewportWidth, viewportHeight, scrollX, scrollY) {
|
|
154
|
+
let { top, left } = position;
|
|
155
|
+
|
|
156
|
+
// Adjust horizontal position
|
|
157
|
+
if (left < scrollX) {
|
|
158
|
+
left = scrollX + 10;
|
|
159
|
+
} else if (left + width > scrollX + viewportWidth) {
|
|
160
|
+
left = scrollX + viewportWidth - width - 10;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Adjust vertical position
|
|
164
|
+
if (top < scrollY) {
|
|
165
|
+
top = scrollY + 10;
|
|
166
|
+
} else if (top + height > scrollY + viewportHeight) {
|
|
167
|
+
top = scrollY + viewportHeight - height - 10;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { top, left };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Apply mobile-specific constraints
|
|
175
|
+
*/
|
|
176
|
+
static applyMobileConstraints(position, width, height, viewportWidth, viewportHeight, scrollX, scrollY) {
|
|
177
|
+
let { top, left } = position;
|
|
178
|
+
|
|
179
|
+
// On mobile, prefer center positioning if popup is too large
|
|
180
|
+
if (width > viewportWidth * 0.9) {
|
|
181
|
+
left = scrollX + (viewportWidth - width) / 2;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (height > viewportHeight * 0.8) {
|
|
185
|
+
top = scrollY + (viewportHeight - height) / 2;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Ensure minimum margins
|
|
189
|
+
const minMargin = 10;
|
|
190
|
+
if (left < scrollX + minMargin) left = scrollX + minMargin;
|
|
191
|
+
if (top < scrollY + minMargin) top = scrollY + minMargin;
|
|
192
|
+
if (left + width > scrollX + viewportWidth - minMargin) {
|
|
193
|
+
left = scrollX + viewportWidth - width - minMargin;
|
|
194
|
+
}
|
|
195
|
+
if (top + height > scrollY + viewportHeight - minMargin) {
|
|
196
|
+
top = scrollY + viewportHeight - height - minMargin;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { top, left };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Apply calculated position to popup element
|
|
204
|
+
*/
|
|
205
|
+
static applyPosition(popup, position) {
|
|
206
|
+
popup.style.position = 'absolute';
|
|
207
|
+
popup.style.top = `${position.top}px`;
|
|
208
|
+
popup.style.left = `${position.left}px`;
|
|
209
|
+
|
|
210
|
+
if (position.transform) {
|
|
211
|
+
popup.style.transform = position.transform;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Check if device is mobile
|
|
217
|
+
*/
|
|
218
|
+
static isMobile() {
|
|
219
|
+
return window.innerWidth <= 768;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get recommended max dimensions for mobile
|
|
224
|
+
*/
|
|
225
|
+
static getMobileMaxDimensions() {
|
|
226
|
+
const viewportWidth = window.innerWidth;
|
|
227
|
+
const viewportHeight = window.innerHeight;
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
maxWidth: Math.min(viewportWidth * 0.95, 350),
|
|
231
|
+
maxHeight: Math.min(viewportHeight * 0.8, 400)
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize utilities - dependency-free XSS protection helpers.
|
|
3
|
+
*
|
|
4
|
+
* These functions provide a defense-in-depth layer for the few places where
|
|
5
|
+
* the editor turns user-supplied strings into live DOM (links, images, video,
|
|
6
|
+
* HTML import, code view). They are intentionally conservative: anything that
|
|
7
|
+
* is not provably safe is rejected.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// URL schemes that are safe to use as navigable links / resource sources.
|
|
11
|
+
const SAFE_URL_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:', 'ftp:'];
|
|
12
|
+
|
|
13
|
+
// Trusted iframe embed prefixes (used by the video feature).
|
|
14
|
+
const TRUSTED_IFRAME_PREFIXES = [
|
|
15
|
+
'https://www.youtube.com/embed/',
|
|
16
|
+
'https://www.youtube-nocookie.com/embed/',
|
|
17
|
+
'https://player.vimeo.com/video/'
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Determine whether a URL is safe to assign to href/src.
|
|
22
|
+
*
|
|
23
|
+
* Relative URLs, anchors and path-only references (no scheme) are considered
|
|
24
|
+
* safe. URLs with an explicit scheme are only allowed if the scheme is in the
|
|
25
|
+
* whitelist. `javascript:`, `vbscript:`, `data:text/html`, etc. are rejected.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} url - URL to validate
|
|
28
|
+
* @param {object} [options]
|
|
29
|
+
* @param {boolean} [options.allowDataImage] - allow `data:image/*` (except SVG)
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
export function isSafeUrl(url, { allowDataImage = false } = {}) {
|
|
33
|
+
if (typeof url !== 'string') return false;
|
|
34
|
+
|
|
35
|
+
const trimmed = url.trim();
|
|
36
|
+
if (trimmed === '') return false;
|
|
37
|
+
|
|
38
|
+
// Strip control/whitespace characters that are commonly used to smuggle
|
|
39
|
+
// schemes past naive validators (e.g. "java\tscript:alert(1)").
|
|
40
|
+
const stripped = trimmed.replace(/[\u0000-\u0020\u007F-\u009F]/g, '');
|
|
41
|
+
|
|
42
|
+
// Detect a leading scheme. If there is none it is a relative URL → safe.
|
|
43
|
+
const schemeMatch = stripped.match(/^([a-z][a-z0-9+.-]*):/i);
|
|
44
|
+
if (!schemeMatch) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const scheme = schemeMatch[1].toLowerCase() + ':';
|
|
49
|
+
|
|
50
|
+
if (scheme === 'data:') {
|
|
51
|
+
if (!allowDataImage) return false;
|
|
52
|
+
// Allow raster image data URIs only. SVG can carry script, so it is denied.
|
|
53
|
+
return /^data:image\//i.test(stripped) && !/^data:image\/svg/i.test(stripped);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return SAFE_URL_SCHEMES.includes(scheme);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Return the URL if it is safe, otherwise an empty string.
|
|
61
|
+
* @param {string} url
|
|
62
|
+
* @param {object} [options] - same options as isSafeUrl
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function sanitizeUrl(url, options) {
|
|
66
|
+
return isSafeUrl(url, options) ? url.trim() : '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Tags that are never allowed in sanitized HTML.
|
|
70
|
+
const FORBIDDEN_TAGS = new Set([
|
|
71
|
+
'SCRIPT', 'STYLE', 'OBJECT', 'EMBED', 'LINK', 'META', 'BASE',
|
|
72
|
+
'FORM', 'INPUT', 'BUTTON', 'TEXTAREA', 'SELECT', 'OPTION', 'NOSCRIPT'
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Clean a single element node in place: drop event-handler attributes,
|
|
77
|
+
* unsafe href/src URLs and dangerous inline styles.
|
|
78
|
+
* @param {Element} el
|
|
79
|
+
*/
|
|
80
|
+
function cleanElement(el) {
|
|
81
|
+
const attrs = Array.from(el.attributes);
|
|
82
|
+
for (const attr of attrs) {
|
|
83
|
+
const name = attr.name.toLowerCase();
|
|
84
|
+
const value = attr.value;
|
|
85
|
+
|
|
86
|
+
// Strip all inline event handlers (onclick, onerror, onload, ...).
|
|
87
|
+
if (name.startsWith('on')) {
|
|
88
|
+
el.removeAttribute(attr.name);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Validate URL-bearing attributes.
|
|
93
|
+
if (name === 'href' || name === 'src' || name === 'xlink:href') {
|
|
94
|
+
const allowDataImage = el.tagName === 'IMG';
|
|
95
|
+
if (!isSafeUrl(value, { allowDataImage })) {
|
|
96
|
+
el.removeAttribute(attr.name);
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reject styles that can execute script (legacy IE expression / url(javascript:)).
|
|
102
|
+
if (name === 'style' && /expression\s*\(|javascript:/i.test(value)) {
|
|
103
|
+
el.removeAttribute(attr.name);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Sanitize an HTML string and return safe HTML.
|
|
110
|
+
*
|
|
111
|
+
* Parsing is done with DOMParser so that no scripts execute and no network
|
|
112
|
+
* resources load during sanitization (the parsed document is inert).
|
|
113
|
+
*
|
|
114
|
+
* @param {string} html - untrusted HTML
|
|
115
|
+
* @returns {string} sanitized HTML
|
|
116
|
+
*/
|
|
117
|
+
export function sanitizeHtml(html) {
|
|
118
|
+
if (typeof html !== 'string' || html === '') return '';
|
|
119
|
+
|
|
120
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
121
|
+
const elements = Array.from(doc.body.querySelectorAll('*'));
|
|
122
|
+
|
|
123
|
+
for (const el of elements) {
|
|
124
|
+
const tag = el.tagName;
|
|
125
|
+
|
|
126
|
+
if (FORBIDDEN_TAGS.has(tag)) {
|
|
127
|
+
el.remove();
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Iframes are only allowed when pointing at a trusted embed host.
|
|
132
|
+
if (tag === 'IFRAME') {
|
|
133
|
+
const src = el.getAttribute('src') || '';
|
|
134
|
+
const trusted = TRUSTED_IFRAME_PREFIXES.some(prefix => src.startsWith(prefix));
|
|
135
|
+
if (!trusted) {
|
|
136
|
+
el.remove();
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
cleanElement(el);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return doc.body.innerHTML;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Sanitize an already-parsed DOM subtree in place (event handlers + unsafe URLs).
|
|
149
|
+
* Use when content is built via the DOM rather than from an HTML string.
|
|
150
|
+
* @param {Element} root
|
|
151
|
+
*/
|
|
152
|
+
export function sanitizeNode(root) {
|
|
153
|
+
if (!root) return;
|
|
154
|
+
const elements = Array.from(root.querySelectorAll('*'));
|
|
155
|
+
for (const el of elements) {
|
|
156
|
+
if (FORBIDDEN_TAGS.has(el.tagName)) {
|
|
157
|
+
el.remove();
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
cleanElement(el);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export default { isSafeUrl, sanitizeUrl, sanitizeHtml, sanitizeNode };
|
package/package.json
CHANGED
|
@@ -1,32 +1,51 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@oix1987/yjd",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "A powerful rich text editor with real-time content change tracking for web applications using base javascript",
|
|
5
|
-
"license": "ISC",
|
|
6
|
-
"author": "Oix1987",
|
|
7
|
-
"type": "module",
|
|
8
|
-
"main": "dist/rich-editor.min.js",
|
|
9
|
-
"module": "dist/rich-editor.esm.js",
|
|
10
|
-
"exports": {
|
|
11
|
-
".": {
|
|
12
|
-
"import": "./dist/rich-editor.esm.js",
|
|
13
|
-
"require": "./dist/rich-editor.min.js"
|
|
14
|
-
},
|
|
15
|
-
"./
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@oix1987/yjd",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "A powerful rich text editor with real-time content change tracking for web applications using base javascript",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "Oix1987",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "dist/rich-editor.min.js",
|
|
9
|
+
"module": "dist/rich-editor.esm.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./dist/rich-editor.esm.js",
|
|
13
|
+
"require": "./dist/rich-editor.min.js"
|
|
14
|
+
},
|
|
15
|
+
"./core": "./core.js",
|
|
16
|
+
"./styles.css": "./lib/styles.min.css",
|
|
17
|
+
"./lib/*": "./lib/*",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"types": "index.d.ts",
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"lib",
|
|
24
|
+
"core.js",
|
|
25
|
+
"index.js",
|
|
26
|
+
"umd-entry.js",
|
|
27
|
+
"index.d.ts",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test test/*.test.js",
|
|
33
|
+
"generate:css": "node scripts/generate-css.js",
|
|
34
|
+
"prebuild": "npm run generate:css",
|
|
35
|
+
"build": "rollup -c",
|
|
36
|
+
"prepublishOnly": "npm run build",
|
|
37
|
+
"build:demos": "rollup -c demos/rollup.demos.config.js",
|
|
38
|
+
"build:site": "node scripts/build-site.js",
|
|
39
|
+
"build:pages": "npm run build && node scripts/build-site.js"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@rollup/plugin-terser": "^0.4.4",
|
|
43
|
+
"csso": "^5.0.5",
|
|
44
|
+
"jsdom": "^29.1.1",
|
|
45
|
+
"rollup": "^4.46.3"
|
|
46
|
+
},
|
|
47
|
+
"sideEffects": [
|
|
48
|
+
"./index.js",
|
|
49
|
+
"./umd-entry.js"
|
|
50
|
+
]
|
|
51
|
+
}
|