001package gwt.material.design.addins.client.cutout;
002
003/*
004 * #%L
005 * GwtMaterial
006 * %%
007 * Copyright (C) 2015 GwtMaterialDesign
008 * %%
009 * Licensed under the Apache License, Version 2.0 (the "License");
010 * you may not use this file except in compliance with the License.
011 * You may obtain a copy of the License at
012 * 
013 *      http://www.apache.org/licenses/LICENSE-2.0
014 * 
015 * Unless required by applicable law or agreed to in writing, software
016 * distributed under the License is distributed on an "AS IS" BASIS,
017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
018 * See the License for the specific language governing permissions and
019 * limitations under the License.
020 * #L%
021 */
022
023import gwt.material.design.client.base.HasCircle;
024import gwt.material.design.client.base.MaterialWidget;
025import gwt.material.design.client.base.helper.ColorHelper;
026
027import com.google.gwt.core.client.Scheduler;
028import com.google.gwt.core.client.Scheduler.ScheduledCommand;
029import com.google.gwt.dom.client.Document;
030import com.google.gwt.dom.client.Element;
031import com.google.gwt.dom.client.Style;
032import com.google.gwt.dom.client.Style.Display;
033import com.google.gwt.dom.client.Style.Overflow;
034import com.google.gwt.dom.client.Style.Position;
035import com.google.gwt.dom.client.Style.Unit;
036import com.google.gwt.event.dom.client.ClickEvent;
037import com.google.gwt.event.dom.client.ClickHandler;
038import com.google.gwt.event.dom.client.HasClickHandlers;
039import com.google.gwt.event.logical.shared.CloseEvent;
040import com.google.gwt.event.logical.shared.CloseHandler;
041import com.google.gwt.event.logical.shared.HasCloseHandlers;
042import com.google.gwt.event.logical.shared.ResizeEvent;
043import com.google.gwt.event.logical.shared.ResizeHandler;
044import com.google.gwt.event.shared.HandlerRegistration;
045import com.google.gwt.user.client.Window;
046import com.google.gwt.user.client.Window.ScrollEvent;
047import com.google.gwt.user.client.Window.ScrollHandler;
048import com.google.gwt.user.client.ui.RootPanel;
049import com.google.gwt.user.client.ui.Widget;
050
051//@formatter:off
052/**
053 * MaterialCutOut is a fullscreen modal-like component to show users about new
054 * features or important elements of the document.
055 *
056 * You can use {@link CloseHandler}s to be notified when the cut out is closed.
057 *
058 * <h3>XML Namespace Declaration</h3>
059 * <pre>
060 * {@code
061 * xmlns:ma='urn:import:gwt.material.design.addins.client'
062 * }
063 * </pre>
064 *
065 * <h3>UiBinder Usage:</h3>
066 *
067 * <pre>
068 * {@code
069 * <ma:cutout.MaterialCutOut ui:field="cutOut">
070 *      <!-- add any widgets here -->
071 * </ma:cutout.MaterialCutOut>
072 * }
073 * </pre>
074 *
075 * <h3>Java Usage:</h3>
076 * {@code
077 * MaterialCutOut cutOut = ... //create using new or using UiBinder
078 * cutOut.setTarget(myTargetWidget); //the widget or element you want to focus
079 * cutOut.openCutOut(); //shows the modal over the page
080 * }
081 *
082 * <h3>Custom styling:</h3> You use change the cut out style by using the
083 * <code>material-cutout</code> class, and <code>material-cutout-focus</code>
084 * class for the focus box.
085 * 
086 * <h3>Notice:</h3>On some iOS devices, on mobile Safari, the CutOut may not open when the
087 * {@link #setCircle(boolean)} is set to <code>true</code>. This is because of problems on Safari
088 * with box-shadows over rounded borders. To avoid this issue you can disable the circle. Check the 
089 * <a href="https://github.com/GwtMaterialDesign/gwt-material/issues/227">issue 227</a> for details.
090 * 
091 *
092 * @author gilberto-torrezan
093 * @see <a href="http://gwtmaterialdesign.github.io/gwt-material-demo/#cutouts">Material Cutouts</a>
094 */
095// @formatter:on
096public class MaterialCutOut extends MaterialWidget implements HasCloseHandlers<MaterialCutOut>,
097        HasClickHandlers, HasCircle {
098
099    private String backgroundColor = "blue";
100    private double opacity = 0.8;
101
102    private boolean animated = true;
103    private String animationDuration = "0.5s";
104    private String animationTimingFunction = "ease";
105    private String backgroundSize = "100rem";
106
107    private String computedBackgroundColor;
108    private int cutOutPadding = 10;
109    private boolean circle = false;
110    private boolean autoAddedToDocument = false;
111    private String viewportOverflow;
112    private Element targetElement;
113    private Element focus;
114    
115    private HandlerRegistration resizeHandler;
116    private HandlerRegistration scrollHandler;
117
118    public MaterialCutOut() {
119        super(Document.get().createDivElement(), "material-cutout");
120        focus = Document.get().createDivElement();
121        getElement().appendChild(focus);
122
123        Style style = getElement().getStyle();
124        style.setWidth(100, Unit.PCT);
125        style.setHeight(100, Unit.PCT);
126        style.setPosition(Position.FIXED);
127        style.setTop(0, Unit.PX);
128        style.setLeft(0, Unit.PX);
129        style.setOverflow(Overflow.HIDDEN);
130        style.setZIndex(10000);
131        style.setDisplay(Display.NONE);
132
133        focus.setClassName("material-cutout-focus");
134        style = focus.getStyle();
135        style.setProperty("content", "\'\'");
136        style.setPosition(Position.ABSOLUTE);
137        style.setZIndex(-1);
138    }
139
140    @Override
141    public void setBackgroundColor(String bgColor) {
142        backgroundColor = bgColor;
143        //resetting the computedBackgroundColor
144        computedBackgroundColor = null;
145    }
146
147    @Override
148    public String getBackgroundColor() {
149        return backgroundColor;
150    }
151
152    @Override
153    public void setOpacity(double opacity) {
154        this.opacity = opacity;
155        //resetting the computedBackgroundColor
156        computedBackgroundColor = null;
157    }
158
159    @Override
160    public double getOpacity() {
161        return opacity;
162    }
163
164    /**
165     * @return the animation duration of the opening cut out
166     */
167    public String getAnimationDuration() {
168        return animationDuration;
169    }
170
171    /**
172     * Sets the animation duration of the opening cut out.
173     * The default is 0.5s.
174     *
175     * @param animationDuration
176     *            The duration in CSS time unit, such as s and ms.
177     */
178    public void setAnimationDuration(String animationDuration) {
179        this.animationDuration = animationDuration;
180    }
181
182    /**
183     * @return the animation timing fucntion of the opening cut out
184     */
185    public String getAnimationTimingFunction() {
186        return animationTimingFunction;
187    }
188
189    /**
190     * Sets the animation timing fucntion of the opening cut out.
191     *
192     * @param animationTimingFunction
193     *            The speed curve of the animation, such as ease (the default), linear and
194     *            ease-in-out
195     */
196    public void setAnimationTimingFunction(String animationTimingFunction) {
197        this.animationTimingFunction = animationTimingFunction;
198    }
199
200    /**
201     * Sets if the cut out should be rendered as a circle or a simple rectangle.
202     * Circle is better for targets with same width and height. The default is
203     * <code>false</code> (is a rectangle).
204     */
205    @Override
206    public void setCircle(boolean circle) {
207        this.circle = circle;
208    }
209
210    /**
211     * @return The if the cut out should be rendered as a circle or a simple
212     *         rectangle
213     */
214    @Override
215    public boolean isCircle() {
216        return circle;
217    }
218
219    /**
220     * Sets the padding in pixels of the cut out focus in relation to the target
221     * element. The default is 10.
222     */
223    public void setCutOutPadding(int cutOutPadding) {
224        this.cutOutPadding = cutOutPadding;
225    }
226
227    /**
228     * @return The padding in pixels of the cut out focus in relation to the
229     *         target element
230     */
231    public int getCutOutPadding() {
232        return cutOutPadding;
233    }
234
235    /**
236     * Sets the target element to be focused by the cut out.
237     */
238    public void setTarget(Element targetElement) {
239        this.targetElement = targetElement;
240    }
241
242    /**
243     * Sets the target widget to be focused by the cut out. Its the same as
244     * calling setTarget(widget.getElement());
245     *
246     * @see #setTarget(Element)
247     */
248    public void setTarget(Widget widget) {
249        setTarget(widget.getElement());
250    }
251
252    /**
253     * @return The target element to be focused
254     */
255    public Element getTargetElement() {
256        return targetElement;
257    }
258    
259    /**
260     * Enables or disables the open animation of the cut out.
261     * The default is <code>true</code>.
262     */
263    public void setAnimated(boolean animated) {
264        this.animated = animated;
265    }
266    
267    /**
268     * @return If the animation of the cut out is enabled when opening.
269     */
270    public boolean isAnimated() {
271        return animated;
272    }
273    
274    /**
275     * Sets the radius size of the Cut Out background. By default, it takes the whole page
276     * by using 100rem as size.
277     * 
278     * @param backgroundSize 
279     *          The size of the background of the Cut Out. You can use any supported 
280     *          CSS unit for box shadows, such as rem and px.
281     */
282    public void setBackgroundSize(String backgroundSize) {
283        this.backgroundSize = backgroundSize;
284    }
285    
286    /**
287     * @return The radius size of the background of the Cut Out.
288     */
289    public String getBackgroundSize() {
290        return backgroundSize;
291    }
292
293    /**
294     * Opens the modal cut out taking all the screen. The target element should
295     * be set before calling this method.
296     *
297     * @throws IllegalStateException
298     *             if the target element is <code>null</code>
299     * @see #setTargetElement(Element)
300     */
301    public void openCutOut() {
302        if (targetElement == null) {
303            throw new IllegalStateException("The target element should be set before calling openCutOut().");
304        }
305        targetElement.scrollIntoView();
306
307        if (computedBackgroundColor == null){
308            setupComputedBackgroundColor();
309        }
310
311        //temporarily disables scrolling by setting the overflow of the page to hidden
312        Style docStyle = Document.get().getDocumentElement().getStyle();
313        viewportOverflow = docStyle.getOverflow();
314        docStyle.setProperty("overflow", "hidden");
315
316        setupTransition();
317        if (animated){
318            focus.getStyle().setProperty("boxShadow", "0px 0px 0px 0rem "+computedBackgroundColor);
319            
320            //the animation will take place after the boxshadow is set by the deferred command
321            Scheduler.get().scheduleDeferred(new ScheduledCommand() {
322                @Override
323                public void execute() {
324                    focus.getStyle().setProperty("boxShadow", "0px 0px 0px " + backgroundSize + " " + computedBackgroundColor);
325                }
326            });
327        }
328        else {
329            focus.getStyle().setProperty("boxShadow", "0px 0px 0px " + backgroundSize + " " + computedBackgroundColor);
330        }
331
332        if (circle) {
333            focus.getStyle().setProperty("WebkitBorderRadius", "50%");
334            focus.getStyle().setProperty("borderRadius", "50%");
335        } else {
336            focus.getStyle().clearProperty("WebkitBorderRadius");
337            focus.getStyle().clearProperty("borderRadius");
338        }
339        setupCutOutPosition(focus, targetElement, cutOutPadding, circle);
340        
341        setupWindowHandlers();
342        getElement().getStyle().clearDisplay();
343
344        // verify if the component is added to the document (via UiBinder for
345        // instance)
346        if (getParent() == null) {
347            autoAddedToDocument = true;
348            RootPanel.get().add(this);
349        }
350    }
351
352    /**
353     * Closes the cut out. It is the same as calling
354     * {@link #closeCutOut(boolean)} with <code>false</code>.
355     */
356    public void closeCutOut() {
357        this.closeCutOut(false);
358    }
359
360    /**
361     * Closes the cut out.
362     *
363     * @param autoClosed
364     *            Notifies with the modal was auto closed or closed by user action
365     */
366    public void closeCutOut(boolean autoClosed) {
367        //restore the old overflow of the page
368        Document.get().getDocumentElement().getStyle().setProperty("overflow", viewportOverflow);
369
370        getElement().getStyle().setDisplay(Display.NONE);
371        
372        //remove old handlers to avoid memory leaks
373        if (resizeHandler != null){
374            resizeHandler.removeHandler();
375            resizeHandler = null;
376        }
377        if (scrollHandler != null){
378            scrollHandler.removeHandler();
379            scrollHandler = null;
380        }
381
382        // if the component added himself to the document, it must remove
383        // himself too
384        if (autoAddedToDocument) {
385            this.removeFromParent();
386            autoAddedToDocument = false;
387        }
388        CloseEvent.fire(this, this, autoClosed);
389    }
390
391    /**
392     * Setups the cut out position when the screen changes size or is scrolled.
393     */
394    protected native void setupCutOutPosition(Element cutOut, Element relativeTo, int padding, boolean circle)/*-{
395        var rect = relativeTo.getBoundingClientRect();
396
397        var top = rect.top;
398        var left = rect.left;
399        var width = rect.right - rect.left;
400        var height = rect.bottom - rect.top;
401        
402        if (circle){
403            if (width != height){
404                var dif = width - height;
405                if (width > height){
406                    height += dif;
407                    top -= dif/2;
408                }
409                else {
410                    dif = -dif;
411                    width += dif;
412                    left -= dif/2;
413                }
414            }
415        }
416
417        top -= padding;
418        left -= padding;
419        width += padding * 2;
420        height += padding * 2;
421
422        cutOut.style.top = top + 'px';
423        cutOut.style.left = left + 'px';
424        cutOut.style.width = width + 'px';
425        cutOut.style.height = height + 'px';
426    }-*/;
427
428    /**
429     * Configures a resize handler and a scroll handler on the window to
430     * properly adjust the Cut Out.
431     */
432    protected void setupWindowHandlers(){
433        if (resizeHandler != null){
434            resizeHandler.removeHandler();
435        }
436        if (scrollHandler != null){
437            scrollHandler.removeHandler();
438        }
439        resizeHandler = Window.addResizeHandler(new ResizeHandler() {
440            @Override
441            public void onResize(ResizeEvent event) {
442                setupCutOutPosition(focus, targetElement, cutOutPadding, circle);
443            }
444        });
445        scrollHandler = Window.addWindowScrollHandler(new ScrollHandler() {
446            @Override
447            public void onWindowScroll(ScrollEvent event) {
448                setupCutOutPosition(focus, targetElement, cutOutPadding, circle);
449            }
450        });
451    }
452
453    protected void setupTransition(){
454        if (animated){
455            focus.getStyle().setProperty("WebkitTransition", "box-shadow " + animationDuration + " " + animationTimingFunction);
456            focus.getStyle().setProperty("transition", "box-shadow " + animationDuration + " " + animationTimingFunction);            
457        }
458        else {
459            focus.getStyle().clearProperty("WebkitTransition");
460            focus.getStyle().clearProperty("transition");
461        }
462    }
463
464    /**
465     * Gets the computed background color, based on the backgroundColor CSS
466     * class.
467     */
468    protected void setupComputedBackgroundColor() {
469        // temp is just a widget created to evaluate the computed background
470        // color
471        MaterialWidget temp = new MaterialWidget(Document.get().createDivElement());
472        temp.setBackgroundColor(backgroundColor);
473
474        // setting a style to make it invisible for the user
475        Style style = temp.getElement().getStyle();
476        style.setPosition(Position.FIXED);
477        style.setWidth(1, Unit.PX);
478        style.setHeight(1, Unit.PX);
479        style.setLeft(-10, Unit.PX);
480        style.setTop(-10, Unit.PX);
481        style.setZIndex(-10000);
482
483        // adding it to the body (on Chrome the component must be added to the
484        // DOM before getting computed values).
485        String computed = ColorHelper.setupComputedBackgroundColor(backgroundColor);
486
487        // convert rgb to rgba, considering the opacity field
488        if (opacity < 1 && computed.startsWith("rgb(")) {
489            computed = computed.replace("rgb(", "rgba(").replace(")", ", " + opacity + ")");
490        }
491
492        computedBackgroundColor = computed;
493    }
494
495    @Override
496    public HandlerRegistration addCloseHandler(final CloseHandler<MaterialCutOut> handler) {
497        return addHandler(new CloseHandler<MaterialCutOut>() {
498            @Override
499            public void onClose(CloseEvent<MaterialCutOut> event) {
500                if(isEnabled()){
501                    handler.onClose(event);
502                }
503            }
504        }, CloseEvent.getType());
505    }
506
507    @Override
508    public HandlerRegistration addClickHandler(final ClickHandler handler) {
509        return addDomHandler(new ClickHandler() {
510            @Override
511            public void onClick(ClickEvent event) {
512                if(isEnabled()){
513                    handler.onClick(event);
514                }
515            }
516        }, ClickEvent.getType());
517    }
518
519}