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