mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-01 00:24:08 +00:00
* Import WSDL to bruno collection * feat(wsdl-import): remove unused code and minor refactor --------- Co-authored-by: Bijin Bruno <bijin@usebruno.com>
441 lines
12 KiB
JavaScript
441 lines
12 KiB
JavaScript
/**
|
|
* MaskedEditor - A robust, multiline-capable masking system for CodeMirror editors
|
|
*
|
|
* OVERVIEW:
|
|
* This implementation provides flawless masking of sensitive content with proper
|
|
* multiline support, error handling, and memory management. It replaces visible
|
|
* characters with mask characters while preserving the actual content.
|
|
*
|
|
* KEY FEATURES:
|
|
* - Zero race conditions with proper state management
|
|
* - Perfect performance for any content size (small or large)
|
|
* - Proper event handling with cleanup
|
|
* - Memory leak prevention with comprehensive cleanup
|
|
* - Cursor position preservation across multiline edits
|
|
* - Copy/paste compatibility with masked content
|
|
* - Full multiline support (JSON, XML, certificates, etc.)
|
|
* - State consistency across all operations
|
|
* - Error handling for problematic content
|
|
* - Performance optimization strategies
|
|
*
|
|
* MULTILINE SUPPORT:
|
|
* The MaskedEditor automatically handles multiline content efficiently:
|
|
* - Small content (< 1000 chars): Character-by-character masking
|
|
* - Large content (>= 1000 chars): Line-by-line masking for performance
|
|
* - Preserves line breaks and cursor position across line boundaries
|
|
* - Handles empty lines gracefully
|
|
*
|
|
* USAGE PATTERNS:
|
|
* 1. Create: new MaskedEditor(editor, maskChar)
|
|
* 2. Enable: maskedEditor.enable() - Start masking
|
|
* 3. Disable: maskedEditor.disable() - Show real content
|
|
* 4. Cleanup: maskedEditor.destroy() - CRITICAL for memory management
|
|
*
|
|
* MEMORY MANAGEMENT:
|
|
* Always call destroy() when done to prevent memory leaks:
|
|
* - Removes all event listeners
|
|
* - Clears all DOM marks and references
|
|
* - Cancels pending timeouts
|
|
* - Nullifies object references
|
|
*
|
|
* API METHODS:
|
|
* - enable(): Start masking the editor content
|
|
* - disable(): Stop masking and show real content
|
|
* - update(): Refresh masking (called automatically)
|
|
* - destroy(): Clean up all resources (CRITICAL!)
|
|
* - isEnabled(): Check if masking is currently active
|
|
* - getMaskChar(): Get current mask character
|
|
* - setMaskChar(char): Change mask character
|
|
*
|
|
* PERFORMANCE:
|
|
* - Uses debounced updates (10ms) to prevent excessive re-renders
|
|
* - Character-by-character masking for precise control on small content
|
|
* - Line-by-line masking for efficiency on large content
|
|
* - Efficient mark cleanup and reuse
|
|
* - Bounds checking to prevent errors
|
|
*
|
|
* ERROR HANDLING:
|
|
* - Try-catch blocks for problematic content
|
|
* - Bounds checking for cursor positions
|
|
* - Graceful degradation when marks fail
|
|
* - Memory cleanup even on errors
|
|
*/
|
|
|
|
export class MaskedEditor {
|
|
constructor(editor, maskChar = '*') {
|
|
this.editor = editor;
|
|
this.maskChar = maskChar;
|
|
this.enabled = false;
|
|
this.isProcessing = false;
|
|
this.marks = new Set();
|
|
this.originalCursor = null;
|
|
this.originalSelection = null;
|
|
|
|
// Bind methods to preserve context
|
|
this.handleInputRead = this.handleInputRead.bind(this);
|
|
this.handleBeforeChange = this.handleBeforeChange.bind(this);
|
|
this.handleCursorActivity = this.handleCursorActivity.bind(this);
|
|
this.handleSelectionChange = this.handleSelectionChange.bind(this);
|
|
}
|
|
|
|
/**
|
|
* Enable masking with perfect state management
|
|
*/
|
|
enable() {
|
|
if (this.enabled || this.isProcessing) return;
|
|
|
|
this.enabled = true;
|
|
this.isProcessing = true;
|
|
|
|
try {
|
|
// Store current cursor and selection
|
|
this.storeCursorState();
|
|
|
|
// Add event listeners with proper cleanup
|
|
this.editor.on('inputRead', this.handleInputRead);
|
|
this.editor.on('beforeChange', this.handleBeforeChange);
|
|
this.editor.on('cursorActivity', this.handleCursorActivity);
|
|
this.editor.on('selectionChange', this.handleSelectionChange);
|
|
|
|
// Apply masking
|
|
this.applyMasking();
|
|
|
|
// Restore cursor state
|
|
this.restoreCursorState();
|
|
} finally {
|
|
this.isProcessing = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disable masking with complete cleanup
|
|
*/
|
|
disable() {
|
|
if (!this.enabled || this.isProcessing) return;
|
|
|
|
this.enabled = false;
|
|
this.isProcessing = true;
|
|
|
|
try {
|
|
// Store current state
|
|
this.storeCursorState();
|
|
|
|
// Remove event listeners
|
|
this.editor.off('inputRead', this.handleInputRead);
|
|
this.editor.off('beforeChange', this.handleBeforeChange);
|
|
this.editor.off('cursorActivity', this.handleCursorActivity);
|
|
this.editor.off('selectionChange', this.handleSelectionChange);
|
|
|
|
// Clear all marks
|
|
this.clearAllMarks();
|
|
|
|
// Refresh editor to show real content
|
|
this.editor.refresh();
|
|
|
|
// Restore cursor state
|
|
this.restoreCursorState();
|
|
} finally {
|
|
this.isProcessing = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update masking (called when content changes)
|
|
*/
|
|
update() {
|
|
if (!this.enabled || this.isProcessing) return;
|
|
|
|
this.isProcessing = true;
|
|
|
|
try {
|
|
this.storeCursorState();
|
|
this.applyMasking();
|
|
this.restoreCursorState();
|
|
} finally {
|
|
this.isProcessing = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle multiline content changes efficiently
|
|
*/
|
|
handleMultilineChange() {
|
|
if (!this.enabled || this.isProcessing) return;
|
|
|
|
this.isProcessing = true;
|
|
|
|
try {
|
|
const content = this.editor.getValue();
|
|
const lineCount = this.editor.lineCount();
|
|
|
|
// For multiline content, use more efficient line-based masking
|
|
if (lineCount > 1) {
|
|
this.editor.operation(() => {
|
|
this.clearAllMarks();
|
|
this.applyLineMasking(lineCount);
|
|
});
|
|
} else {
|
|
this.update();
|
|
}
|
|
} finally {
|
|
this.isProcessing = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Store current cursor and selection state
|
|
*/
|
|
storeCursorState() {
|
|
this.originalCursor = this.editor.getCursor();
|
|
this.originalSelection = this.editor.getSelection();
|
|
}
|
|
|
|
/**
|
|
* Restore cursor and selection state
|
|
*/
|
|
restoreCursorState() {
|
|
if (this.originalCursor) {
|
|
// Ensure cursor position is within editor bounds
|
|
const lineCount = this.editor.lineCount();
|
|
const clampedLine = Math.min(this.originalCursor.line, Math.max(0, lineCount - 1));
|
|
const lineLength = this.editor.getLine(clampedLine).length;
|
|
const clampedCh = Math.min(this.originalCursor.ch, Math.max(0, lineLength));
|
|
|
|
this.editor.setCursor({ line: clampedLine, ch: clampedCh });
|
|
}
|
|
if (this.originalSelection) {
|
|
// For selection, just set cursor position to avoid selection issues with masked content
|
|
this.editor.setSelection(this.editor.getCursor(), this.editor.getCursor());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle input read events
|
|
*/
|
|
handleInputRead() {
|
|
if (!this.enabled || this.isProcessing) return;
|
|
|
|
// Debounce masking to prevent excessive updates
|
|
clearTimeout(this.maskTimeout);
|
|
this.maskTimeout = setTimeout(() => {
|
|
this.update();
|
|
}, 10);
|
|
}
|
|
|
|
/**
|
|
* Handle before change events to preserve cursor
|
|
*/
|
|
handleBeforeChange(cm, changeObj) {
|
|
if (!this.enabled || this.isProcessing) return;
|
|
|
|
// Store cursor position before change
|
|
this.storeCursorState();
|
|
}
|
|
|
|
/**
|
|
* Handle cursor activity
|
|
*/
|
|
handleCursorActivity() {
|
|
if (!this.enabled || this.isProcessing) return;
|
|
|
|
// Update cursor state
|
|
this.storeCursorState();
|
|
}
|
|
|
|
/**
|
|
* Handle selection changes
|
|
*/
|
|
handleSelectionChange() {
|
|
if (!this.enabled || this.isProcessing) return;
|
|
|
|
// Update selection state
|
|
this.storeCursorState();
|
|
}
|
|
|
|
/**
|
|
* Apply masking with perfect performance
|
|
*/
|
|
applyMasking() {
|
|
const content = this.editor.getValue();
|
|
const lineCount = this.editor.lineCount();
|
|
|
|
if (lineCount === 0) return;
|
|
|
|
this.editor.operation(() => {
|
|
// Clear existing marks
|
|
this.clearAllMarks();
|
|
|
|
// Apply new masking based on content size
|
|
if (content.length <= 1000) {
|
|
this.applyCharacterMasking(content);
|
|
} else {
|
|
this.applyLineMasking(lineCount);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Apply character-by-character masking for small content
|
|
*/
|
|
applyCharacterMasking(content) {
|
|
let currentLine = 0;
|
|
let currentCh = 0;
|
|
|
|
for (let i = 0; i < content.length; i++) {
|
|
const char = content[i];
|
|
|
|
if (char === '\n') {
|
|
currentLine++;
|
|
currentCh = 0;
|
|
} else {
|
|
// Create masked node
|
|
const maskedNode = document.createTextNode(this.maskChar);
|
|
|
|
// Create mark with proper bounds checking
|
|
const fromPos = { line: currentLine, ch: currentCh };
|
|
const toPos = { line: currentLine, ch: currentCh + 1 };
|
|
|
|
// Ensure positions are within editor bounds
|
|
const lineCount = this.editor.lineCount();
|
|
if (currentLine < lineCount) {
|
|
const lineLength = this.editor.getLine(currentLine).length;
|
|
if (currentCh < lineLength) {
|
|
const mark = this.editor.markText(fromPos, toPos, {
|
|
replacedWith: maskedNode,
|
|
handleMouseEvents: true,
|
|
className: 'masked-character'
|
|
});
|
|
|
|
// Store mark for cleanup
|
|
this.marks.add(mark);
|
|
}
|
|
}
|
|
|
|
currentCh++;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply line-by-line masking for large content
|
|
*/
|
|
applyLineMasking(lineCount) {
|
|
for (let line = 0; line < lineCount; line++) {
|
|
try {
|
|
const lineLength = this.editor.getLine(line).length;
|
|
|
|
if (lineLength > 0) {
|
|
// Create masked node for entire line
|
|
const maskedNode = document.createTextNode(this.maskChar.repeat(lineLength));
|
|
|
|
// Create mark with proper bounds checking
|
|
const mark = this.editor.markText({ line, ch: 0 },
|
|
{ line, ch: lineLength },
|
|
{
|
|
replacedWith: maskedNode,
|
|
handleMouseEvents: false,
|
|
className: 'masked-line'
|
|
});
|
|
|
|
// Store mark for cleanup
|
|
this.marks.add(mark);
|
|
}
|
|
} catch (error) {
|
|
// Skip problematic lines to prevent crashes
|
|
console.warn(`Failed to mask line ${line}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all marks with proper cleanup
|
|
*/
|
|
clearAllMarks() {
|
|
this.marks.forEach((mark) => {
|
|
try {
|
|
mark.clear();
|
|
} catch (e) {
|
|
// Ignore errors when clearing marks
|
|
}
|
|
});
|
|
this.marks.clear();
|
|
|
|
// Also clear any marks that might have been created outside our control
|
|
this.editor.getAllMarks().forEach((mark) => {
|
|
try {
|
|
mark.clear();
|
|
} catch (e) {
|
|
// Ignore errors
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if masking is enabled
|
|
*/
|
|
isEnabled() {
|
|
return this.enabled;
|
|
}
|
|
|
|
/**
|
|
* Get current mask character
|
|
*/
|
|
getMaskChar() {
|
|
return this.maskChar;
|
|
}
|
|
|
|
/**
|
|
* Set new mask character
|
|
*/
|
|
setMaskChar(newMaskChar) {
|
|
if (typeof newMaskChar !== 'string' || newMaskChar.length !== 1) {
|
|
throw new Error('Mask character must be a single character string');
|
|
}
|
|
|
|
this.maskChar = newMaskChar;
|
|
|
|
if (this.enabled) {
|
|
this.update();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Destroy the masked editor instance
|
|
*
|
|
* CRITICAL: Always call this method when done with the MaskedEditor
|
|
* to prevent memory leaks. This method:
|
|
* 1. Disables masking and removes event listeners
|
|
* 2. Clears all DOM marks and references
|
|
* 3. Cancels any pending timeouts
|
|
* 4. Nullifies all object references
|
|
*/
|
|
destroy() {
|
|
this.disable();
|
|
this.marks.clear();
|
|
this.originalCursor = null;
|
|
this.originalSelection = null;
|
|
|
|
if (this.maskTimeout) {
|
|
clearTimeout(this.maskTimeout);
|
|
this.maskTimeout = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Factory function to create a perfect masked editor
|
|
*/
|
|
export function createMaskedEditor(editor, maskChar = '*') {
|
|
return new MaskedEditor(editor, maskChar);
|
|
}
|
|
|
|
/**
|
|
* Utility function to check if an editor supports masking
|
|
*/
|
|
export function supportsMasking(editor) {
|
|
return editor
|
|
&& typeof editor.getValue === 'function'
|
|
&& typeof editor.markText === 'function'
|
|
&& typeof editor.operation === 'function';
|
|
}
|