diagram-js-direct-editing
Version:
Direct editing support for diagram-js
461 lines (361 loc) • 11.3 kB
JavaScript
import {
assign,
bind,
pick
} from 'min-dash';
import {
domify,
query as domQuery,
event as domEvent,
remove as domRemove
} from 'min-dom';
var min = Math.min,
max = Math.max;
function preventDefault(e) {
e.preventDefault();
}
function stopPropagation(e) {
e.stopPropagation();
}
function isTextNode(node) {
return node.nodeType === Node.TEXT_NODE;
}
function toArray(nodeList) {
return [].slice.call(nodeList);
}
/**
* Initializes a container for a content editable div.
*
* Structure:
*
* container
* parent
* content
* resize-handle
*
* @param {object} options
* @param {DOMElement} options.container The DOM element to append the contentContainer to
* @param {Function} options.keyHandler Handler for key events
* @param {Function} options.resizeHandler Handler for resize events
*/
export default function TextBox(options) {
this.container = options.container;
this.parent = domify(
'<div class="djs-direct-editing-parent">' +
'<div class="djs-direct-editing-content" contenteditable="true"></div>' +
'</div>'
);
this.content = domQuery('[contenteditable]', this.parent);
this.keyHandler = options.keyHandler || function() {};
this.resizeHandler = options.resizeHandler || function() {};
this.autoResize = bind(this.autoResize, this);
this.handlePaste = bind(this.handlePaste, this);
}
/**
* Create a text box with the given position, size, style and text content
*
* @param {Object} bounds
* @param {Number} bounds.x absolute x position
* @param {Number} bounds.y absolute y position
* @param {Number} [bounds.width] fixed width value
* @param {Number} [bounds.height] fixed height value
* @param {Number} [bounds.maxWidth] maximum width value
* @param {Number} [bounds.maxHeight] maximum height value
* @param {Number} [bounds.minWidth] minimum width value
* @param {Number} [bounds.minHeight] minimum height value
* @param {Object} [style]
* @param {String} value text content
*
* @return {DOMElement} The created content DOM element
*/
TextBox.prototype.create = function(bounds, style, value, options) {
var self = this;
var parent = this.parent,
content = this.content,
container = this.container;
options = this.options = options || {};
style = this.style = style || {};
var parentStyle = pick(style, [
'width',
'height',
'maxWidth',
'maxHeight',
'minWidth',
'minHeight',
'left',
'top',
'backgroundColor',
'position',
'overflow',
'border',
'wordWrap',
'textAlign',
'outline',
'transform'
]);
assign(parent.style, {
width: bounds.width + 'px',
height: bounds.height + 'px',
maxWidth: bounds.maxWidth + 'px',
maxHeight: bounds.maxHeight + 'px',
minWidth: bounds.minWidth + 'px',
minHeight: bounds.minHeight + 'px',
left: bounds.x + 'px',
top: bounds.y + 'px',
backgroundColor: '#ffffff',
position: 'absolute',
overflow: 'visible',
border: '1px solid #ccc',
boxSizing: 'border-box',
wordWrap: 'normal',
textAlign: 'center',
outline: 'none'
}, parentStyle);
var contentStyle = pick(style, [
'fontFamily',
'fontSize',
'fontWeight',
'lineHeight',
'padding',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft'
]);
assign(content.style, {
boxSizing: 'border-box',
width: '100%',
outline: 'none',
wordWrap: 'break-word'
}, contentStyle);
if (options.centerVertically) {
assign(content.style, {
position: 'absolute',
top: '50%',
transform: 'translate(0, -50%)'
}, contentStyle);
}
content.innerText = value;
domEvent.bind(content, 'keydown', this.keyHandler);
domEvent.bind(content, 'mousedown', stopPropagation);
domEvent.bind(content, 'paste', self.handlePaste);
if (options.autoResize) {
domEvent.bind(content, 'input', this.autoResize);
}
if (options.resizable) {
this.resizable(style);
}
container.appendChild(parent);
// set selection to end of text
this.setSelection(content.lastChild, content.lastChild && content.lastChild.length);
return parent;
};
/**
* Intercept paste events to remove formatting from pasted text.
*/
TextBox.prototype.handlePaste = function(e) {
var options = this.options,
style = this.style;
e.preventDefault();
var text;
if (e.clipboardData) {
// Chrome, Firefox, Safari
text = e.clipboardData.getData('text/plain');
} else {
// Internet Explorer
text = window.clipboardData.getData('Text');
}
this.insertText(text);
if (options.autoResize) {
var hasResized = this.autoResize(style);
if (hasResized) {
this.resizeHandler(hasResized);
}
}
};
TextBox.prototype.insertText = function(text) {
text = normalizeEndOfLineSequences(text);
// insertText command not supported by Internet Explorer
var success = document.execCommand('insertText', false, text);
if (success) {
return;
}
this._insertTextIE(text);
};
TextBox.prototype._insertTextIE = function(text) {
// Internet Explorer
var range = this.getSelection(),
startContainer = range.startContainer,
endContainer = range.endContainer,
startOffset = range.startOffset,
endOffset = range.endOffset,
commonAncestorContainer = range.commonAncestorContainer;
var childNodesArray = toArray(commonAncestorContainer.childNodes);
var container,
offset;
if (isTextNode(commonAncestorContainer)) {
var containerTextContent = startContainer.textContent;
startContainer.textContent =
containerTextContent.substring(0, startOffset)
+ text
+ containerTextContent.substring(endOffset);
container = startContainer;
offset = startOffset + text.length;
} else if (startContainer === this.content && endContainer === this.content) {
var textNode = document.createTextNode(text);
this.content.insertBefore(textNode, childNodesArray[startOffset]);
container = textNode;
offset = textNode.textContent.length;
} else {
var startContainerChildIndex = childNodesArray.indexOf(startContainer),
endContainerChildIndex = childNodesArray.indexOf(endContainer);
childNodesArray.forEach(function(childNode, index) {
if (index === startContainerChildIndex) {
childNode.textContent =
startContainer.textContent.substring(0, startOffset) +
text +
endContainer.textContent.substring(endOffset);
} else if (index > startContainerChildIndex && index <= endContainerChildIndex) {
domRemove(childNode);
}
});
container = startContainer;
offset = startOffset + text.length;
}
if (container && offset !== undefined) {
// is necessary in Internet Explorer
setTimeout(function() {
self.setSelection(container, offset);
});
}
};
/**
* Automatically resize element vertically to fit its content.
*/
TextBox.prototype.autoResize = function() {
var parent = this.parent,
content = this.content;
var fontSize = parseInt(this.style.fontSize) || 12;
if (content.scrollHeight > parent.offsetHeight ||
content.scrollHeight < parent.offsetHeight - fontSize) {
var bounds = parent.getBoundingClientRect();
var height = content.scrollHeight;
parent.style.height = height + 'px';
this.resizeHandler({
width: bounds.width,
height: bounds.height,
dx: 0,
dy: height - bounds.height
});
}
};
/**
* Make an element resizable by adding a resize handle.
*/
TextBox.prototype.resizable = function() {
var self = this;
var parent = this.parent,
resizeHandle = this.resizeHandle;
var minWidth = parseInt(this.style.minWidth) || 0,
minHeight = parseInt(this.style.minHeight) || 0,
maxWidth = parseInt(this.style.maxWidth) || Infinity,
maxHeight = parseInt(this.style.maxHeight) || Infinity;
if (!resizeHandle) {
resizeHandle = this.resizeHandle = domify(
'<div class="djs-direct-editing-resize-handle"></div>'
);
var startX, startY, startWidth, startHeight;
var onMouseDown = function(e) {
preventDefault(e);
stopPropagation(e);
startX = e.clientX;
startY = e.clientY;
var bounds = parent.getBoundingClientRect();
startWidth = bounds.width;
startHeight = bounds.height;
domEvent.bind(document, 'mousemove', onMouseMove);
domEvent.bind(document, 'mouseup', onMouseUp);
};
var onMouseMove = function(e) {
preventDefault(e);
stopPropagation(e);
var newWidth = min(max(startWidth + e.clientX - startX, minWidth), maxWidth);
var newHeight = min(max(startHeight + e.clientY - startY, minHeight), maxHeight);
parent.style.width = newWidth + 'px';
parent.style.height = newHeight + 'px';
self.resizeHandler({
width: startWidth,
height: startHeight,
dx: e.clientX - startX,
dy: e.clientY - startY
});
};
var onMouseUp = function(e) {
preventDefault(e);
stopPropagation(e);
domEvent.unbind(document,'mousemove', onMouseMove, false);
domEvent.unbind(document, 'mouseup', onMouseUp, false);
};
domEvent.bind(resizeHandle, 'mousedown', onMouseDown);
}
assign(resizeHandle.style, {
position: 'absolute',
bottom: '0px',
right: '0px',
cursor: 'nwse-resize',
width: '0',
height: '0',
borderTop: (parseInt(this.style.fontSize) / 4 || 3) + 'px solid transparent',
borderRight: (parseInt(this.style.fontSize) / 4 || 3) + 'px solid #ccc',
borderBottom: (parseInt(this.style.fontSize) / 4 || 3) + 'px solid #ccc',
borderLeft: (parseInt(this.style.fontSize) / 4 || 3) + 'px solid transparent'
});
parent.appendChild(resizeHandle);
};
/**
* Clear content and style of the textbox, unbind listeners and
* reset CSS style.
*/
TextBox.prototype.destroy = function() {
var parent = this.parent,
content = this.content,
resizeHandle = this.resizeHandle;
// clear content
content.innerText = '';
// clear styles
parent.removeAttribute('style');
content.removeAttribute('style');
domEvent.unbind(content, 'keydown', this.keyHandler);
domEvent.unbind(content, 'mousedown', stopPropagation);
domEvent.unbind(content, 'input', this.autoResize);
domEvent.unbind(content, 'paste', this.handlePaste);
if (resizeHandle) {
resizeHandle.removeAttribute('style');
domRemove(resizeHandle);
}
domRemove(parent);
};
TextBox.prototype.getValue = function() {
return this.content.innerText.trim();
};
TextBox.prototype.getSelection = function() {
var selection = window.getSelection(),
range = selection.getRangeAt(0);
return range;
};
TextBox.prototype.setSelection = function(container, offset) {
var range = document.createRange();
if (container === null) {
range.selectNodeContents(this.content);
} else {
range.setStart(container, offset);
range.setEnd(container, offset);
}
var selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
};
// helpers //////////
function normalizeEndOfLineSequences(string) {
return string.replace(/\r\n|\r|\n/g, '\n');
}