/**
* ControlSelection.js
*
* Released under LGPL License.
* Copyright (c) 1999-2017 Ephox Corp. All rights reserved
*
* License: http://www.tinymce.com/license
* Contributing: http://www.tinymce.com/contributing
*/
/**
* This class handles control selection of elements. Controls are elements
* that can be resized and needs to be selected as a whole. It adds custom resize handles
* to all browser engines that support properly disabling the built in resize logic.
*
* @class tinymce.dom.ControlSelection
*/
define(
'tinymce.core.dom.ControlSelection',
[
'ephox.katamari.api.Fun',
'ephox.sugar.api.node.Element',
'ephox.sugar.api.search.Selectors',
'global!document',
'tinymce.core.dom.NodeType',
'tinymce.core.dom.RangePoint',
'tinymce.core.Env',
'tinymce.core.util.Delay',
'tinymce.core.util.Tools',
'tinymce.core.util.VK'
],
function (Fun, Element, Selectors, document, NodeType, RangePoint, Env, Delay, Tools, VK) {
var isContentEditableFalse = NodeType.isContentEditableFalse;
var isContentEditableTrue = NodeType.isContentEditableTrue;
var getContentEditableRoot = function (root, node) {
while (node && node != root) {
if (isContentEditableTrue(node) || isContentEditableFalse(node)) {
return node;
}
node = node.parentNode;
}
return null;
};
return function (selection, editor) {
var dom = editor.dom, each = Tools.each;
var selectedElm, selectedElmGhost, resizeHelper, resizeHandles, selectedHandle;
var startX, startY, selectedElmX, selectedElmY, startW, startH, ratio, resizeStarted;
var width, height, editableDoc = editor.getDoc(), rootDocument = document;
var abs = Math.abs, round = Math.round, rootElement = editor.getBody(), startScrollWidth, startScrollHeight;
// Details about each resize handle how to scale etc
resizeHandles = {
// Name: x multiplier, y multiplier, delta size x, delta size y
/*n: [0.5, 0, 0, -1],
e: [1, 0.5, 1, 0],
s: [0.5, 1, 0, 1],
w: [0, 0.5, -1, 0],*/
nw: [0, 0, -1, -1],
ne: [1, 0, 1, -1],
se: [1, 1, 1, 1],
sw: [0, 1, -1, 1]
};
// Add CSS for resize handles, cloned element and selected
var rootClass = '.mce-content-body';
editor.contentStyles.push(
rootClass + ' div.mce-resizehandle {' +
'position: absolute;' +
'border: 1px solid black;' +
'box-sizing: content-box;' +
'background: #FFF;' +
'width: 7px;' +
'height: 7px;' +
'z-index: 10000' +
'}' +
rootClass + ' .mce-resizehandle:hover {' +
'background: #000' +
'}' +
rootClass + ' img[data-mce-selected],' + rootClass + ' hr[data-mce-selected] {' +
'outline: 1px solid black;' +
'resize: none' + // Have been talks about implementing this in browsers
'}' +
rootClass + ' .mce-clonedresizable {' +
'position: absolute;' +
(Env.gecko ? '' : 'outline: 1px dashed black;') + // Gecko produces trails while resizing
'opacity: .5;' +
'filter: alpha(opacity=50);' +
'z-index: 10000' +
'}' +
rootClass + ' .mce-resize-helper {' +
'background: #555;' +
'background: rgba(0,0,0,0.75);' +
'border-radius: 3px;' +
'border: 1px;' +
'color: white;' +
'display: none;' +
'font-family: sans-serif;' +
'font-size: 12px;' +
'white-space: nowrap;' +
'line-height: 14px;' +
'margin: 5px 10px;' +
'padding: 5px;' +
'position: absolute;' +
'z-index: 10001' +
'}'
);
var isImage = function (elm) {
return elm && (elm.nodeName === 'IMG' || editor.dom.is(elm, 'figure.image'));
};
var isEventOnImageOutsideRange = function (evt, range) {
return isImage(evt.target) && !RangePoint.isXYWithinRange(evt.clientX, evt.clientY, range);
};
var contextMenuSelectImage = function (evt) {
var target = evt.target;
if (isEventOnImageOutsideRange(evt, editor.selection.getRng()) && !evt.isDefaultPrevented()) {
evt.preventDefault();
editor.selection.select(target);
}
};
var getResizeTarget = function (elm) {
return editor.dom.is(elm, 'figure.image') ? elm.querySelector('img') : elm;
};
var isResizable = function (elm) {
var selector = editor.settings.object_resizing;
if (selector === false || Env.iOS) {
return false;
}
if (typeof selector != 'string') {
selector = 'table,img,figure.image,div';
}
if (elm.getAttribute('data-mce-resize') === 'false') {
return false;
}
if (elm == editor.getBody()) {
return false;
}
return Selectors.is(Element.fromDom(elm), selector);
};
var resizeGhostElement = function (e) {
var deltaX, deltaY, proportional;
var resizeHelperX, resizeHelperY;
// Calc new width/height
deltaX = e.screenX - startX;
deltaY = e.screenY - startY;
// Calc new size
width = deltaX * selectedHandle[2] + startW;
height = deltaY * selectedHandle[3] + startH;
// Never scale down lower than 5 pixels
width = width < 5 ? 5 : width;
height = height < 5 ? 5 : height;
if (isImage(selectedElm) && editor.settings.resize_img_proportional !== false) {
proportional = !VK.modifierPressed(e);
} else {
proportional = VK.modifierPressed(e) || (isImage(selectedElm) && selectedHandle[2] * selectedHandle[3] !== 0);
}
// Constrain proportions
if (proportional) {
if (abs(deltaX) > abs(deltaY)) {
height = round(width * ratio);
width = round(height / ratio);
} else {
width = round(height / ratio);
height = round(width * ratio);
}
}
// Update ghost size
dom.setStyles(getResizeTarget(selectedElmGhost), {
width: width,
height: height
});
// Update resize helper position
resizeHelperX = selectedHandle.startPos.x + deltaX;
resizeHelperY = selectedHandle.startPos.y + deltaY;
resizeHelperX = resizeHelperX > 0 ? resizeHelperX : 0;
resizeHelperY = resizeHelperY > 0 ? resizeHelperY : 0;
dom.setStyles(resizeHelper, {
left: resizeHelperX,
top: resizeHelperY,
display: 'block'
});
resizeHelper.innerHTML = width + ' × ' + height;
// Update ghost X position if needed
if (selectedHandle[2] < 0 && selectedElmGhost.clientWidth <= width) {
dom.setStyle(selectedElmGhost, 'left', selectedElmX + (startW - width));
}
// Update ghost Y position if needed
if (selectedHandle[3] < 0 && selectedElmGhost.clientHeight <= height) {
dom.setStyle(selectedElmGhost, 'top', selectedElmY + (startH - height));
}
// Calculate how must overflow we got
deltaX = rootElement.scrollWidth - startScrollWidth;
deltaY = rootElement.scrollHeight - startScrollHeight;
// Re-position the resize helper based on the overflow
if (deltaX + deltaY !== 0) {
dom.setStyles(resizeHelper, {
left: resizeHelperX - deltaX,
top: resizeHelperY - deltaY
});
}
if (!resizeStarted) {
editor.fire('ObjectResizeStart', { target: selectedElm, width: startW, height: startH });
resizeStarted = true;
}
};
var endGhostResize = function () {
resizeStarted = false;
var setSizeProp = function (name, value) {
if (value) {
// Resize by using style or attribute
if (selectedElm.style[name] || !editor.schema.isValid(selectedElm.nodeName.toLowerCase(), name)) {
dom.setStyle(getResizeTarget(selectedElm), name, value);
} else {
dom.setAttrib(getResizeTarget(selectedElm), name, value);
}
}
};
// Set width/height properties
setSizeProp('width', width);
setSizeProp('height', height);
dom.unbind(editableDoc, 'mousemove', resizeGhostElement);
dom.unbind(editableDoc, 'mouseup', endGhostResize);
if (rootDocument != editableDoc) {
dom.unbind(rootDocument, 'mousemove', resizeGhostElement);
dom.unbind(rootDocument, 'mouseup', endGhostResize);
}
// Remove ghost/helper and update resize handle positions
dom.remove(selectedElmGhost);
dom.remove(resizeHelper);
showResizeRect(selectedElm);
editor.fire('ObjectResized', { target: selectedElm, width: width, height: height });
dom.setAttrib(selectedElm, 'style', dom.getAttrib(selectedElm, 'style'));
editor.nodeChanged();
};
var showResizeRect = function (targetElm) {
var position, targetWidth, targetHeight, e, rect;
hideResizeRect();
unbindResizeHandleEvents();
// Get position and size of target
position = dom.getPos(targetElm, rootElement);
selectedElmX = position.x;
selectedElmY = position.y;
rect = targetElm.getBoundingClientRect(); // Fix for Gecko offsetHeight for table with caption
targetWidth = rect.width || (rect.right - rect.left);
targetHeight = rect.height || (rect.bottom - rect.top);
// Reset width/height if user selects a new image/table
if (selectedElm != targetElm) {
selectedElm = targetElm;
width = height = 0;
}
// Makes it possible to disable resizing
e = editor.fire('ObjectSelected', { target: targetElm });
if (isResizable(targetElm) && !e.isDefaultPrevented()) {
each(resizeHandles, function (handle, name) {
var handleElm;
var startDrag = function (e) {
startX = e.screenX;
startY = e.screenY;
startW = getResizeTarget(selectedElm).clientWidth;
startH = getResizeTarget(selectedElm).clientHeight;
ratio = startH / startW;
selectedHandle = handle;
handle.startPos = {
x: targetWidth * handle[0] + selectedElmX,
y: targetHeight * handle[1] + selectedElmY
};
startScrollWidth = rootElement.scrollWidth;
startScrollHeight = rootElement.scrollHeight;
selectedElmGhost = selectedElm.cloneNode(true);
dom.addClass(selectedElmGhost, 'mce-clonedresizable');
dom.setAttrib(selectedElmGhost, 'data-mce-bogus', 'all');
selectedElmGhost.contentEditable = false; // Hides IE move layer cursor
selectedElmGhost.unSelectabe = true;
dom.setStyles(selectedElmGhost, {
left: selectedElmX,
top: selectedElmY,
margin: 0
});
selectedElmGhost.removeAttribute('data-mce-selected');
rootElement.appendChild(selectedElmGhost);
dom.bind(editableDoc, 'mousemove', resizeGhostElement);
dom.bind(editableDoc, 'mouseup', endGhostResize);
if (rootDocument != editableDoc) {
dom.bind(rootDocument, 'mousemove', resizeGhostElement);
dom.bind(rootDocument, 'mouseup', endGhostResize);
}
resizeHelper = dom.add(rootElement, 'div', {
'class': 'mce-resize-helper',
'data-mce-bogus': 'all'
}, startW + ' × ' + startH);
};
// Get existing or render resize handle
handleElm = dom.get('mceResizeHandle' + name);
if (handleElm) {
dom.remove(handleElm);
}
handleElm = dom.add(rootElement, 'div', {
id: 'mceResizeHandle' + name,
'data-mce-bogus': 'all',
'class': 'mce-resizehandle',
unselectable: true,
style: 'cursor:' + name + '-resize; margin:0; padding:0'
});
// Hides IE move layer cursor
// If we set it on Chrome we get this wounderful bug: #6725
if (Env.ie) {
handleElm.contentEditable = false;
}
dom.bind(handleElm, 'mousedown', function (e) {
e.stopImmediatePropagation();
e.preventDefault();
startDrag(e);
});
handle.elm = handleElm;
// Position element
dom.setStyles(handleElm, {
left: (targetWidth * handle[0] + selectedElmX) - (handleElm.offsetWidth / 2),
top: (targetHeight * handle[1] + selectedElmY) - (handleElm.offsetHeight / 2)
});
});
} else {
hideResizeRect();
}
selectedElm.setAttribute('data-mce-selected', '1');
};
var hideResizeRect = function () {
var name, handleElm;
unbindResizeHandleEvents();
if (selectedElm) {
selectedElm.removeAttribute('data-mce-selected');
}
for (name in resizeHandles) {
handleElm = dom.get('mceResizeHandle' + name);
if (handleElm) {
dom.unbind(handleElm);
dom.remove(handleElm);
}
}
};
var updateResizeRect = function (e) {
var startElm, controlElm;
var isChildOrEqual = function (node, parent) {
if (node) {
do {
if (node === parent) {
return true;
}
} while ((node = node.parentNode));
}
};
// Ignore all events while resizing or if the editor instance was removed
if (resizeStarted || editor.removed) {
return;
}
// Remove data-mce-selected from all elements since they might have been copied using Ctrl+c/v
each(dom.select('img[data-mce-selected],hr[data-mce-selected]'), function (img) {
img.removeAttribute('data-mce-selected');
});
controlElm = e.type == 'mousedown' ? e.target : selection.getNode();
controlElm = dom.$(controlElm).closest('table,img,figure.image,hr')[0];
if (isChildOrEqual(controlElm, rootElement)) {
disableGeckoResize();
startElm = selection.getStart(true);
if (isChildOrEqual(startElm, controlElm) && isChildOrEqual(selection.getEnd(true), controlElm)) {
showResizeRect(controlElm);
return;
}
}
hideResizeRect();
};
var isWithinContentEditableFalse = function (elm) {
return isContentEditableFalse(getContentEditableRoot(editor.getBody(), elm));
};
var unbindResizeHandleEvents = function () {
for (var name in resizeHandles) {
var handle = resizeHandles[name];
if (handle.elm) {
dom.unbind(handle.elm);
delete handle.elm;
}
}
};
var disableGeckoResize = function () {
try {
// Disable object resizing on Gecko
editor.getDoc().execCommand('enableObjectResizing', false, false);
} catch (ex) {
// Ignore
}
};
editor.on('init', function () {
disableGeckoResize();
// Sniff sniff, hard to feature detect this stuff
if (Env.ie && Env.ie >= 11) {
// Needs to be mousedown for drag/drop to work on IE 11
// Needs to be click on Edge to properly select images
editor.on('mousedown click', function (e) {
var target = e.target, nodeName = target.nodeName;
if (!resizeStarted && /^(TABLE|IMG|HR)$/.test(nodeName) && !isWithinContentEditableFalse(target)) {
if (e.button !== 2) {
editor.selection.select(target, nodeName == 'TABLE');
}
// Only fire once since nodeChange is expensive
if (e.type == 'mousedown') {
editor.nodeChanged();
}
}
});
editor.dom.bind(rootElement, 'mscontrolselect', function (e) {
var delayedSelect = function (node) {
Delay.setEditorTimeout(editor, function () {
editor.selection.select(node);
});
};
if (isWithinContentEditableFalse(e.target)) {
e.preventDefault();
delayedSelect(e.target);
return;
}
if (/^(TABLE|IMG|HR)$/.test(e.target.nodeName)) {
e.preventDefault();
// This moves the selection from being a control selection to a text like selection like in WebKit #6753
// TODO: Fix this the day IE works like other browsers without this nasty native ugly control selections.
if (e.target.tagName == 'IMG') {
delayedSelect(e.target);
}
}
});
}
var throttledUpdateResizeRect = Delay.throttle(function (e) {
if (!editor.composing) {
updateResizeRect(e);
}
});
editor.on('nodechange ResizeEditor ResizeWindow drop', throttledUpdateResizeRect);
// Update resize rect while typing in a table
editor.on('keyup compositionend', function (e) {
// Don't update the resize rect while composing since it blows away the IME see: #2710
if (selectedElm && selectedElm.nodeName == "TABLE") {
throttledUpdateResizeRect(e);
}
});
editor.on('hide blur', hideResizeRect);
editor.on('contextmenu', contextMenuSelectImage);
// Hide rect on focusout since it would float on top of windows otherwise
//editor.on('focusout', hideResizeRect);
});
editor.on('remove', unbindResizeHandleEvents);
var destroy = function () {
selectedElm = selectedElmGhost = null;
};
return {
isResizable: isResizable,
showResizeRect: showResizeRect,
hideResizeRect: hideResizeRect,
updateResizeRect: updateResizeRect,
destroy: destroy
};
};
}
);
|