%PDF- %PDF-
Direktori : /www/varak.net/wiki.varak.net/extensions/VisualEditor/lib/ve/src/ce/ |
Current File : /www/varak.net/wiki.varak.net/extensions/VisualEditor/lib/ve/src/ce/ve.ce.FocusableNode.js |
/*! * VisualEditor ContentEditable FocusableNode class. * * @copyright 2011-2016 VisualEditor Team and others; see http://ve.mit-license.org */ /** * ContentEditable focusable node. * * Focusable elements have a special treatment by ve.ce.Surface. When the user selects only a single * node, if it is focusable, the surface will set the focusable node's focused state. Other systems, * such as the context, may also use a focusable node's $focusable property as a hint of where the * primary element in the node is. Typically, and by default, the primary element is the root * element, but in some cases it may need to be configured to be a specific child element within the * node's DOM rendering. * * If your focusable node changes size and the highlight must be redrawn, call redrawHighlights(). * 'resizeEnd' and 'rerender' are already bound to call this. * * @class * @abstract * * @constructor * @param {jQuery} [$focusable=this.$element] Primary element user is focusing on * @param {Object} [config] Configuration options * @cfg {string[]} [classes] CSS classes to be added to the highlight container */ ve.ce.FocusableNode = function VeCeFocusableNode( $focusable, config ) { config = config || {}; // Properties this.focused = false; this.highlighted = false; this.isFocusableSetup = false; this.$highlights = $( '<div>' ).addClass( 've-ce-focusableNode-highlights' ); this.$focusable = $focusable || this.$element; this.focusableSurface = null; this.rects = null; this.boundingRect = null; this.startAndEndRects = null; this.$icon = null; this.touchMoved = false; if ( Array.isArray( config.classes ) ) { this.$highlights.addClass( config.classes.join( ' ' ) ); } // DOM changes this.$element .addClass( 've-ce-focusableNode' ) .prop( 'contentEditable', 'false' ); // Events this.connect( this, { setup: 'onFocusableSetup', teardown: 'onFocusableTeardown', resizeStart: 'onFocusableResizeStart', resizeEnd: 'onFocusableResizeEnd', rerender: 'onFocusableRerender' } ); }; /* Inheritance */ OO.initClass( ve.ce.FocusableNode ); /* Events */ /** * @event focus */ /** * @event blur */ /* Static properties */ /** * Icon to use when the rendering is considered not visible, as defined in #hasRendering * * No icon is show if null. * * @static * @property {string|null} * @inheritable */ ve.ce.FocusableNode.static.iconWhenInvisible = null; /* Methods */ /** * Create a highlight element. * * @return {jQuery} A highlight element */ ve.ce.FocusableNode.prototype.createHighlight = function () { var extraClasses = this.generatedContentsInvalid ? ' ve-ce-focusableNode-highlight-error' : ''; return $( '<div>' ) .addClass( 've-ce-focusableNode-highlight' + extraClasses ) .prop( { title: this.constructor.static.getDescription( this.model ), draggable: false } ) .append( $( '<img>' ) .addClass( 've-ce-focusableNode-highlight-relocatable-marker' ) .attr( 'src', 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' ) .on( { mousedown: this.onFocusableMouseDown.bind( this ), dragstart: this.onFocusableDragStart.bind( this ), dragend: this.onFocusableDragEnd.bind( this ) } ) ); }; /** * Handle node setup. * * @method */ ve.ce.FocusableNode.prototype.onFocusableSetup = function () { var rAF = requestAnimationFrame || setTimeout; // Exit if already setup or not attached if ( this.isFocusableSetup || !this.root ) { return; } this.focusableSurface = this.root.getSurface(); // DOM changes (duplicated from constructor in case this.$element is replaced) this.$element .addClass( 've-ce-focusableNode' ) .prop( 'contentEditable', 'false' ); // Events this.$focusable.on( { 'mouseenter.ve-ce-focusableNode': this.onFocusableMouseEnter.bind( this ), 'touchstart.ve-ce-focusableNode': this.onFocusableTouchStart.bind( this ), 'touchmove.ve-ce-focusableNode': this.onFocusableTouchMove.bind( this ), 'mousedown.ve-ce-focusableNode touchend.ve-ce-focusableNode': this.onFocusableMouseDown.bind( this ) } ); // $element is ce=false so make sure nothing happens when you click // on it, just in case the browser decides to do something. // If $element == $focusable then this can be skipped as $focusable already // handles mousedown events. if ( !this.$element.is( this.$focusable ) ) { this.$element.on( { 'mousedown.ve-ce-focusableNode': function ( e ) { e.preventDefault(); } } ); } if ( this.constructor.static.iconWhenInvisible ) { // Set up the invisible icon, and watch for its continued necessity if // unloaded images which don't specify their width or height are // involved. this.$element .find( 'img:not([width]),img:not([height])' ) .addBack( 'img:not([width]),img:not([height])' ) .on( 'load', this.updateInvisibleIcon.bind( this ) ); rAF( this.updateInvisibleIcon.bind( this ) ); } this.isFocusableSetup = true; }; /** * Update the state of icon if this node is invisible * * If the node doesn't have a visible rendering, we insert an icon to represent * it. If the icon was already present, and this is called again when rendering * has developed, we remove the icon. * * @method */ ve.ce.FocusableNode.prototype.updateInvisibleIcon = function () { if ( !this.constructor.static.iconWhenInvisible ) { return; } if ( !this.hasRendering() ) { if ( !this.$icon ) { this.$icon = this.createInvisibleIcon(); } this.$element.first() .addClass( 've-ce-focusableNode-invisible' ) .prepend( this.$icon ); } else if ( this.$icon ) { this.$element.first().removeClass( 've-ce-focusableNode-invisible' ); this.$icon.detach(); } }; /** * Create a element to show if the node is invisible * * @return {jQuery} Element to show */ ve.ce.FocusableNode.prototype.createInvisibleIcon = function () { var icon = new OO.ui.IconWidget( { classes: [ 've-ce-focusableNode-invisibleIcon' ], icon: this.constructor.static.iconWhenInvisible } ); // Add em space for selection highlighting icon.$element.text( '\u2003' ); return icon.$element; }; /** * Handle node teardown. * * @method */ ve.ce.FocusableNode.prototype.onFocusableTeardown = function () { // Exit if not setup or not attached if ( !this.isFocusableSetup || !this.root ) { return; } // Events this.$focusable.off( '.ve-ce-focusableNode' ); this.$element.off( '.ve-ce-focusableNode' ); // Highlights this.clearHighlights(); // DOM changes this.$element .removeClass( 've-ce-focusableNode' ) .removeProp( 'contentEditable' ); this.focusableSurface = null; this.isFocusableSetup = false; }; /** * Handle highlight mouse down events. * * @method * @param {jQuery.Event} e Mouse down event */ ve.ce.FocusableNode.prototype.onFocusableMouseDown = function ( e ) { var range, node = this, surfaceModel = this.focusableSurface.getModel(), selection = surfaceModel.getSelection(), nodeRange = this.model.getOuterRange(); if ( e.type === 'touchend' && this.touchMoved ) { return; } if ( !this.isInContentEditable() ) { return; } if ( e.which === OO.ui.MouseButtons.RIGHT ) { // Hide images, and select spans so context menu shows 'copy', but not 'copy image' this.$highlights.addClass( 've-ce-focusableNode-highlights-contextOpen' ); // Make ce=true so we get cut/paste options in context menu this.$highlights.prop( 'contentEditable', 'true' ); ve.selectElement( this.$highlights[ 0 ] ); setTimeout( function () { // Undo everything as soon as the context menu is show node.$highlights.removeClass( 've-ce-focusableNode-highlights-contextOpen' ); node.$highlights.prop( 'contentEditable', 'true' ); node.focusableSurface.preparePasteTargetForCopy(); } ); } // Wait for native selection to change before correcting setTimeout( function () { range = selection instanceof ve.dm.LinearSelection && selection.getRange(); surfaceModel.getLinearFragment( e.shiftKey && range ? ve.Range.static.newCoveringRange( [ range, nodeRange ], range.from > nodeRange.from ) : nodeRange ).select(); node.focusableSurface.updateActiveLink(); } ); }; /** * Handle highlight double click events. * * @method * @param {jQuery.Event} e Double click event */ ve.ce.FocusableNode.prototype.onFocusableDblClick = function () { if ( !this.isInContentEditable() ) { return; } this.executeCommand(); }; /** * Execute the command associated with this node. * * @method */ ve.ce.FocusableNode.prototype.executeCommand = function () { var command, surface; if ( !this.model.isInspectable() ) { return false; } surface = this.focusableSurface.getSurface(); command = surface.commandRegistry.getCommandForNode( this ); if ( command ) { command.execute( surface ); } }; /** * Handle element drag start. * * @method * @param {jQuery.Event} e Drag start event */ ve.ce.FocusableNode.prototype.onFocusableDragStart = function () { if ( this.focusableSurface ) { // Allow dragging this node in the surface this.focusableSurface.startRelocation( this ); } this.$highlights.addClass( 've-ce-focusableNode-highlights-relocating' ); }; /** * Handle element drag end. * * If a relocation actually takes place the node is destroyed before this events fires. * * @method * @param {jQuery.Event} e Drag end event */ ve.ce.FocusableNode.prototype.onFocusableDragEnd = function () { // endRelocation is usually triggered by onDocumentDrop in the surface, but if it isn't // trigger it here instead if ( this.focusableSurface ) { this.focusableSurface.endRelocation(); } this.$highlights.removeClass( 've-ce-focusableNode-highlights-relocating' ); }; /** * Handle mouse enter events. * * @method * @param {jQuery.Event} e Mouse enter event */ ve.ce.FocusableNode.prototype.onFocusableMouseEnter = function () { if ( !this.root.getSurface().dragging && !this.root.getSurface().resizing && this.isInContentEditable() ) { this.createHighlights(); } }; /** * Handle touch start events. * * @method * @param {jQuery.Event} e Touch start event */ ve.ce.FocusableNode.prototype.onFocusableTouchStart = function () { this.touchMoved = false; }; /** * Handle touch move events. * * @method * @param {jQuery.Event} e Touch move event */ ve.ce.FocusableNode.prototype.onFocusableTouchMove = function () { this.touchMoved = true; }; /** * Handle surface mouse move events. * * @method * @param {jQuery.Event} e Mouse move event */ ve.ce.FocusableNode.prototype.onSurfaceMouseMove = function ( e ) { var $target = $( e.target ); if ( !$target.hasClass( 've-ce-focusableNode-highlight' ) && !OO.ui.contains( this.$focusable.toArray(), $target[ 0 ], true ) ) { this.clearHighlights(); } }; /** * Handle surface mouse leave events. * * @method * @param {jQuery.Event} e Mouse leave event */ ve.ce.FocusableNode.prototype.onSurfaceMouseLeave = function ( e ) { if ( e.relatedTarget === null ) { this.clearHighlights(); } }; /** * Handle resize start events. * * @method */ ve.ce.FocusableNode.prototype.onFocusableResizeStart = function () { this.clearHighlights(); }; /** * Handle resize end event. * * @method */ ve.ce.FocusableNode.prototype.onFocusableResizeEnd = function () { this.redrawHighlights(); }; /** * Handle rerender event. * * @method */ ve.ce.FocusableNode.prototype.onFocusableRerender = function () { if ( this.focused && this.focusableSurface ) { this.redrawHighlights(); // reposition menu this.focusableSurface.getSurface().getContext().updateDimensions( true ); } }; /** * Check if node is focused. * * @method * @return {boolean} Node is focused */ ve.ce.FocusableNode.prototype.isFocused = function () { return this.focused; }; /** * Set the selected state of the node. * * @method * @param {boolean} value Node is focused * @fires focus * @fires blur */ ve.ce.FocusableNode.prototype.setFocused = function ( value ) { value = !!value; if ( this.focused !== value ) { this.focused = value; if ( this.focused ) { this.emit( 'focus' ); this.$element.addClass( 've-ce-focusableNode-focused' ); this.createHighlights(); this.focusableSurface.appendHighlights( this.$highlights, this.focused ); this.focusableSurface.$element.off( '.ve-ce-focusableNode' ); this.focusableSurface.connect( this, { position: 'positionHighlights' } ); } else { this.emit( 'blur' ); this.$element.removeClass( 've-ce-focusableNode-focused' ); this.clearHighlights(); } } }; /** * Creates highlights. * * @method */ ve.ce.FocusableNode.prototype.createHighlights = function () { if ( this.highlighted ) { return; } this.$highlights.on( { mousedown: this.onFocusableMouseDown.bind( this ), dblclick: this.onFocusableDblClick.bind( this ) } ); this.highlighted = true; this.positionHighlights(); this.focusableSurface.appendHighlights( this.$highlights, this.focused ); // Events if ( !this.focused ) { this.focusableSurface.$element.on( { 'mousemove.ve-ce-focusableNode': this.onSurfaceMouseMove.bind( this ), 'mouseleave.ve-ce-focusableNode': this.onSurfaceMouseLeave.bind( this ) } ); } }; /** * Clears highlight. * * @method */ ve.ce.FocusableNode.prototype.clearHighlights = function () { if ( !this.highlighted ) { return; } this.$highlights.remove().empty(); this.focusableSurface.$element.off( '.ve-ce-focusableNode' ); this.focusableSurface.disconnect( this, { position: 'positionHighlights' } ); this.highlighted = false; this.boundingRect = null; }; /** * Redraws highlight. * * @method */ ve.ce.FocusableNode.prototype.redrawHighlights = function () { this.clearHighlights(); this.createHighlights(); }; /** * Calculate position of highlights */ ve.ce.FocusableNode.prototype.calculateHighlights = function () { var i, l, $set, columnCount, columnWidth, surfaceOffset, rects = [], filteredRects = [], webkitColumns = 'webkitColumnCount' in document.createElement( 'div' ).style; // Protect against calling before/after surface setup/teardown if ( !this.focusableSurface ) { this.boundingRect = null; this.startAndEndRects = null; this.rects = []; return; } surfaceOffset = this.focusableSurface.getSurface().getBoundingClientRect(); function contains( rect1, rect2 ) { return rect2.left >= rect1.left && rect2.top >= rect1.top && rect2.right <= rect1.right && rect2.bottom <= rect1.bottom; } function process( el ) { var i, j, il, jl, contained, clientRects, overflow, $el; if ( el.classList.contains( 've-ce-noHighlight' ) ) { return; } $el = $( el ); if ( webkitColumns ) { columnCount = $el.css( '-webkit-column-count' ); columnWidth = $el.css( '-webkit-column-width' ); if ( ( columnCount && columnCount !== 'auto' ) || ( columnWidth && columnWidth !== 'auto' ) ) { // Support: Chrome // Chrome incorrectly measures children of nodes with columns [1], let's // just ignore them rather than render a possibly bizarre highlight. They // will usually not be positioned, because Chrome also doesn't position // them correctly [2] and so people avoid doing it. // // Of course there are other ways to render a node outside the bounding // box of its parent, like negative margin. We do not handle these cases, // and the highlight may not correctly cover the entire node if that // happens. This can't be worked around without implementing CSS // layouting logic ourselves, which is not worth it. // // [1] https://code.google.com/p/chromium/issues/detail?id=391271 // [2] https://code.google.com/p/chromium/issues/detail?id=291616 // jQuery keeps nodes in its collections in document order, so the // children have not been processed yet and can be safely removed. $set = $set.not( $el.find( '*' ) ); } } // Don't descend if overflow is anything but visible as this prevents // child elements appearing beyond the bounding box of the parent overflow = $el.css( 'overflow' ); if ( overflow && overflow !== 'visible' ) { $set = $set.not( $el.find( '*' ) ); } clientRects = el.getClientRects(); for ( i = 0, il = clientRects.length; i < il; i++ ) { contained = false; for ( j = 0, jl = rects.length; j < jl; j++ ) { // This rect is contained by an existing rect, discard if ( contains( rects[ j ], clientRects[ i ] ) ) { contained = true; break; } // An existing rect is contained by this rect, discard the existing rect if ( contains( clientRects[ i ], rects[ j ] ) ) { rects.splice( j, 1 ); j--; jl--; } } if ( !contained ) { rects.push( clientRects[ i ] ); } } } $set = this.$focusable.find( '*' ).addBack(); // Calling process() may change $set.length for ( i = 0; i < $set.length; i++ ) { process( $set[ i ] ); } // Elements with a width/height of 0 return a clientRect with a width/height of 1 // As elements with an actual width/height of 1 aren't that useful anyway, just // throw away anything that is <=1 filteredRects = rects.filter( function ( rect ) { return rect.width > 1 && rect.height > 1; } ); // But if this filtering doesn't leave any rects at all, then we do want to use the 1px rects if ( filteredRects.length > 0 ) { rects = filteredRects; } this.boundingRect = null; // startAndEndRects is lazily evaluated in getStartAndEndRects from rects this.startAndEndRects = null; for ( i = 0, l = rects.length; i < l; i++ ) { // Translate to relative rects[ i ] = ve.translateRect( rects[ i ], -surfaceOffset.left, -surfaceOffset.top ); this.$highlights.append( this.createHighlight().css( { top: rects[ i ].top, left: rects[ i ].left, width: rects[ i ].width, height: rects[ i ].height } ) ); if ( !this.boundingRect ) { this.boundingRect = ve.copy( rects[ i ] ); } else { this.boundingRect.top = Math.min( this.boundingRect.top, rects[ i ].top ); this.boundingRect.left = Math.min( this.boundingRect.left, rects[ i ].left ); this.boundingRect.bottom = Math.max( this.boundingRect.bottom, rects[ i ].bottom ); this.boundingRect.right = Math.max( this.boundingRect.right, rects[ i ].right ); } } if ( this.boundingRect ) { this.boundingRect.width = this.boundingRect.right - this.boundingRect.left; this.boundingRect.height = this.boundingRect.bottom - this.boundingRect.top; } this.rects = rects; }; /** * Positions highlights, and remove collapsed ones * * @method */ ve.ce.FocusableNode.prototype.positionHighlights = function () { var i, l; if ( !this.highlighted ) { return; } this.calculateHighlights(); this.$highlights.empty() // Append something selectable for right-click copy .append( $( '<span>' ).addClass( 've-ce-focusableNode-highlight-selectable' ).html( ' ' ) ); for ( i = 0, l = this.rects.length; i < l; i++ ) { this.$highlights.append( this.createHighlight().css( { top: this.rects[ i ].top, left: this.rects[ i ].left, width: this.rects[ i ].width, height: this.rects[ i ].height } ) ); } }; /** * Get list of rectangles outlining the shape of the node relative to the surface * * @return {Object[]} List of rectangle objects */ ve.ce.FocusableNode.prototype.getRects = function () { if ( !this.highlighted ) { this.calculateHighlights(); } return this.rects; }; /** * Get the bounding rectangle of the focusable node highlight relative to the surface * * @return {Object|null} Top, left, bottom & right positions of the focusable node relative to the surface */ ve.ce.FocusableNode.prototype.getBoundingRect = function () { if ( !this.highlighted ) { this.calculateHighlights(); } return this.boundingRect; }; /** * Get start and end rectangles of an inline focusable node relative to the surface * * @return {Object|null} Start and end rectangles */ ve.ce.FocusableNode.prototype.getStartAndEndRects = function () { if ( !this.highlighted ) { this.calculateHighlights(); } if ( !this.startAndEndRects ) { this.startAndEndRects = ve.getStartAndEndRects( this.rects ); } return this.startAndEndRects; }; /** * Check if the rendering is visible * * "Visible", in this case, is defined as any of: * * contains any non-whitespace text * * is greater than 8px x 8px in dimensions * * @return {boolean} The node has a visible rendering */ ve.ce.FocusableNode.prototype.hasRendering = function () { var visible = false; if ( this.$element.text().trim() !== '' ) { return true; } this.$element.each( function () { var $this = $( this ); if ( ( $this.width() >= 8 && $this.height() >= 8 ) || // jQuery handles disparate cases, but is prone to elements which // haven't experienced layout yet having 0 width / height. So, // check the raw DOM width / height properties as well. If it's an // image or other thing-with-width, this will work slightly more // reliably. If it's not, this will be undefined and the // comparison will thus just be false. ( this.width >= 8 && this.height >= 8 ) ) { visible = true; return false; } } ); return visible; };