// Copyright 2012 Google Inc. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.google.collide.client.util; import com.google.collide.client.util.dom.eventcapture.MouseCaptureListener; import com.google.collide.shared.util.StringUtils; import com.google.gwt.animation.client.AnimationScheduler; import com.google.gwt.animation.client.AnimationScheduler.AnimationCallback; import com.google.gwt.resources.client.ClientBundle; import com.google.gwt.resources.client.CssResource; import elemental.css.CSSStyleDeclaration; import elemental.events.Event; import elemental.events.MouseEvent; import elemental.html.Element; /** * Controller that adds resizing capabilities to elements. */ public class ResizeController { /** * CSS used by the resize controller. * */ public interface Css extends CssResource { String horizontalCursor(); String verticalCursor(); String elementResizing(); String hSplitter(); String vSplitter(); } /** * POJO that encapsulates an element and the CSS property that should be * updated as the user drags the splitter. * */ public static class ElementInfo { private final Element element; /** * Stores the CSS property's value. This is faster than reading the CSS * property value from {@link #applyDelta(int)}. This is also required to * avoid out-of-sync with the cursor and width/height-resizing elements. * (For example, an element's width is to be adjusted, and the mouse moves * to the left of the element's left. The width should be negative, but the * CSS property won't store a negative value. When the mouse moves back to * the right, it will grow the element's width, but the mouse pointer will * not be exactly over the element anymore.) */ private int propertyValue; private int propertyMinValue = Integer.MIN_VALUE; private int propertyMaxValue = Integer.MAX_VALUE; private final ResizeProperty resizeProperty; private final String resizePropertyName; public ElementInfo(Element element, ResizeProperty resizeProperty) { this.element = element; this.resizeProperty = resizeProperty; this.resizePropertyName = resizeProperty.toString().toLowerCase(); } /** * Constructs a new {@link ElementInfo} and sets the default value of the property. */ public ElementInfo(Element element, ResizeProperty resizeProperty, String defaultValue) { this(element, resizeProperty); element.getStyle().setProperty(resizePropertyName, defaultValue); } public Element getElement() { return element; } public ElementInfo setPropertyMinValue(int value) { propertyMinValue = value; return this; } public ElementInfo setPropertyMaxValue(int value) { propertyMaxValue = value; return this; } private void applyDelta(int delta) { propertyValue += delta; element.getStyle().setProperty( resizePropertyName, propertyValue + CSSStyleDeclaration.Unit.PX); } private int computeApplicableDelta(int delta) { int nextValue = propertyValue + delta; nextValue = Math.min(nextValue, propertyMaxValue); nextValue = Math.max(nextValue, propertyMinValue); return nextValue - propertyValue; } private void resetPropertyValue() { // Use the value of a CSS property if it has been explicitly set. String value = getElement().getStyle().getPropertyValue(resizeProperty.toString()); if (!StringUtils.isNullOrEmpty(value) && CssUtils.isPixels(value)) { propertyValue = CssUtils.parsePixels(value); return; } switch (resizeProperty) { case WIDTH: propertyValue = getElement().getClientWidth(); break; case HEIGHT: propertyValue = getElement().getClientHeight(); break; case LEFT: propertyValue = getElement().getOffsetLeft(); break; case TOP: propertyValue = getElement().getOffsetTop(); break; case RIGHT: propertyValue = getElement().getOffsetParent().getClientWidth() - getElement().getOffsetLeft() - getElement().getOffsetWidth(); break; case BOTTOM: propertyValue = getElement().getOffsetParent().getClientHeight() - getElement().getOffsetTop() - getElement().getOffsetHeight(); break; } } } /** * Enumeration that describes which CSS property should be affected by the * resize. * */ public enum ResizeProperty { BOTTOM, HEIGHT, LEFT, RIGHT, TOP, WIDTH } /** * ClientBundle for the resize controller. * */ public interface Resources extends ClientBundle { @Source("ResizeController.css") Css resizeControllerCss(); } private static boolean isHorizontal(ResizeProperty resizeProperty) { return resizeProperty == ResizeProperty.WIDTH || resizeProperty == ResizeProperty.LEFT || resizeProperty == ResizeProperty.RIGHT; } private final Css css; private final ElementInfo[] elementInfos; private final boolean horizontal; private final MouseCaptureListener mouseCaptureListener = new MouseCaptureListener() { @Override protected boolean onMouseDown(MouseEvent evt) { return canStartResizing(); } @Override protected void onMouseMove(MouseEvent evt) { if (!resizing) { resizeStarted(); } int delta = horizontal ? getDeltaX() : getDeltaY(); resizeDragged(negativeDelta ? -delta : delta); } @Override protected void onMouseUp(MouseEvent evt) { if (resizing) { resizeEnded(); } } }; private boolean resizing; private AnimationCallback animationCallback; private final Element splitter; private boolean negativeDelta; private int unappliedDelta; private String hoverClass; /** * @param splitter the element that will act as the splitter * @param elementInfos element(s) that will be resized as the user drags the * splitter */ public ResizeController(Resources resources, Element splitter, ElementInfo... elementInfos) { this.css = resources.resizeControllerCss(); this.splitter = splitter; this.elementInfos = elementInfos; this.horizontal = isHorizontal(elementInfos[0].resizeProperty); if (horizontal) { splitter.addClassName(css.hSplitter()); } else { splitter.addClassName(css.vSplitter()); } } public void setNegativeDelta(boolean negativeDelta) { this.negativeDelta = negativeDelta; } public ElementInfo[] getElementInfos() { return elementInfos; } public Element getSplitter() { return splitter; } public void start() { getSplitter().addEventListener(Event.MOUSEDOWN, mouseCaptureListener, false); } public void stop() { getSplitter().removeEventListener(Event.MOUSEDOWN, mouseCaptureListener, false); mouseCaptureListener.release(); if (resizing) { resizeEnded(); } } private void resizeDragged(int delta) { unappliedDelta += delta; /* * Give the browser a chance to redraw before applying the next delta. * Otherwise, we'll end up locking the browser if the user moves the mouse * too quickly. */ if (animationCallback == null) { animationCallback = new AnimationCallback() { @Override public void execute(double arg0) { if (this != animationCallback) { // The resize event was already ended. return; } animationCallback = null; applyUnappliedDelta(); } }; AnimationScheduler.get().requestAnimationFrame(animationCallback); } } private void applyUnappliedDelta() { int deltaToApply = unappliedDelta; for (ElementInfo elementInfo : getElementInfos()) { // deltaToApply ends up being the minimum delta that any element can // accept. deltaToApply = elementInfo.computeApplicableDelta(deltaToApply); } unappliedDelta -= deltaToApply; applyDelta(deltaToApply); } protected void applyDelta(int delta) { for (ElementInfo elementInfo : getElementInfos()) { elementInfo.applyDelta(delta); } } protected boolean canStartResizing() { return true; } protected void resizeStarted() { resizing = true; if (hoverClass != null) { splitter.addClassName(hoverClass); } for (ElementInfo elementInfo : getElementInfos()) { // Disables transitions while we resize, or it will appear laggy. elementInfo.getElement().addClassName(css.elementResizing()); elementInfo.resetPropertyValue(); } setResizeCursorEnabled(true); } protected Css getCss() { return css; } protected void resizeEnded() { // Force a final resize if there is some unapplied delta. if (unappliedDelta > 0) { applyUnappliedDelta(); } for (ElementInfo elementInfo : getElementInfos()) { elementInfo.getElement().removeClassName(css.elementResizing()); } if (hoverClass != null) { splitter.removeClassName(hoverClass); } setResizeCursorEnabled(false); resizing = false; animationCallback = null; unappliedDelta = 0; } /** * Setting this property allows to avoid control "blinking" during resizing. * * Set the class to be applied when control is being dragged. * * The specified style-class should have at least the same sense as * {@code :hover} pseudo-class applied to control. * * @param hoverClass style-class to be saved ad applied appropriately */ public void setHoverClass(String hoverClass) { this.hoverClass = hoverClass; } /** * Forces all elements on the page to use the resize cursor while resizing. */ private void setResizeCursorEnabled(boolean enabled) { String className = horizontal ? css.horizontalCursor() : css.verticalCursor(); CssUtils.setClassNameEnabled(Elements.getBody(), className, enabled); } }