WKDateTimeInputControl.mm   [plain text]

/*
* Copyright (C) 2014 Apple Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
* BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
* THE POSSIBILITY OF SUCH DAMAGE.
*/

#import "config.h"
#import "WKDateTimeInputControl.h"

#if PLATFORM(IOS_FAMILY)

#import "UserInterfaceIdiom.h"
#import "WKContentView.h"
#import "WKContentViewInteraction.h"
#import "WKWebViewPrivateForTesting.h"
#import "WebPageProxy.h"
#import <UIKit/UIBarButtonItem.h>
#import <UIKit/UIDatePicker.h>
#import <WebCore/LocalizedStrings.h>
#import <algorithm>
#import <wtf/RetainPtr.h>

@interface WKDateTimeContextMenuViewController : UIViewController
@end

@interface WKDateTimePicker : NSObject<WKFormControl
#if USE(UICONTEXTMENU)
, UIContextMenuInteractionDelegate
#endif
> {
RetainPtr<UIDatePicker> _datePicker;
NSString *_formatString;
RetainPtr<NSString> _initialValue;
NSTimeInterval _initialValueAsNumber;
BOOL _shouldRemoveTimeZoneInformation;
BOOL _isTimeInput;
WKContentView *_view;
CGPoint _interactionPoint;
RetainPtr<WKDateTimeContextMenuViewController> _viewController;
#if USE(UICONTEXTMENU)
RetainPtr<UIContextMenuInteraction> _dateTimeContextMenuInteraction;
#endif
BOOL _presenting;
BOOL _preservingFocus;
}
- (instancetype)initWithView:(WKContentView *)view datePickerMode:(UIDatePickerMode)mode;
- (WKDateTimeContextMenuViewController *)viewController;
@property (nonatomic, readonly) NSString *calendarType;
@property (nonatomic, readonly) double hour;
@property (nonatomic, readonly) double minute;
- (void)setHour:(NSInteger)hour minute:(NSInteger)minute;
@end

@implementation WKDateTimeContextMenuViewController

- (CGSize)preferredContentSize
{
// FIXME: Workaround, should be able to be readdressed after <rdar://problem/64143534>
UIView *view = self.view.subviews[0];
if (UIEdgeInsetsEqualToEdgeInsets(view.layoutMargins, UIEdgeInsetsZero)) {
view.translatesAutoresizingMaskIntoConstraints = NO;
[view layoutIfNeeded];
view.translatesAutoresizingMaskIntoConstraints = YES;
view.layoutMargins = UIEdgeInsetsMake(16, 16, 16, 16);
}
auto size = [view systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];

size.width = std::max<CGFloat>(size.width, 250.0);
return size;
}

@end

@implementation WKDateTimePicker

static NSString * const kDateFormatString = @"yyyy-MM-dd"; // "2011-01-27".
static NSString * const kMonthFormatString = @"yyyy-MM"; // "2011-01".
static NSString * const kTimeFormatString = @"HH:mm"; // "13:45".
static const NSTimeInterval kMillisecondsPerSecond = 1000;

#if HAVE(UIDATEPICKER_STYLE)
- (UIDatePickerStyle)datePickerStyle
{
if ([_view focusedElementInformation].elementType == WebKit::InputType::Month)
return UIDatePickerStyleWheels;
return UIDatePickerStyleInline;
}
#endif

- (id)initWithView:(WKContentView *)view datePickerMode:(UIDatePickerMode)mode
{
if (!(self = [super init]))
return nil;
_view = view;
_interactionPoint = [_view lastInteractionLocation];
_shouldRemoveTimeZoneInformation = NO;
_isTimeInput = NO;
switch (view.focusedElementInformation.elementType) {
case WebKit::InputType::Date:
_formatString = kDateFormatString;
break;
case WebKit::InputType::Month:
_formatString = kMonthFormatString;
break;
case WebKit::InputType::Time:
_formatString = kTimeFormatString;
_isTimeInput = YES;
break;
case WebKit::InputType::DateTimeLocal:
_shouldRemoveTimeZoneInformation = YES;
break;
default:
break;
}

_datePicker = adoptNS([[UIDatePicker alloc] init]);

[_datePicker setDatePickerMode:mode];
[_datePicker setHidden:NO];

#if HAVE(UIDATEPICKER_STYLE)
[_datePicker setPreferredDatePickerStyle:[self datePickerStyle]];
#endif
if ([self shouldPresentGregorianCalendar:view.focusedElementInformation])
_datePicker.get().calendar = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian];

[_datePicker addTarget:self action:@selector(_dateChangeHandler:) forControlEvents:UIControlEventValueChanged];

return self;
}

#if USE(UICONTEXTMENU)

- (UITargetedPreview *)contextMenuInteraction:(UIContextMenuInteraction *)interaction previewForHighlightingMenuWithConfiguration:(UIContextMenuConfiguration *)configuration
{
return [_view _createTargetedContextMenuHintPreviewIfPossible];
}

- (_UIContextMenuStyle *)_contextMenuInteraction:(UIContextMenuInteraction *)interaction styleForMenuWithConfiguration:(UIContextMenuConfiguration *)configuration
{
_UIContextMenuStyle *style = [_UIContextMenuStyle defaultStyle];
style.hasInteractivePreview = YES;
style.preferredLayout = _UIContextMenuLayoutAutomatic;
return style;
}

- (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction configurationForMenuAtLocation:(CGPoint)location
{
return [UIContextMenuConfiguration configurationWithIdentifier:@"_UIDatePickerCompactEditor" previewProvider:^{
[_viewController setView:nil];
_viewController = adoptNS([[WKDateTimeContextMenuViewController alloc] init]);
RetainPtr<UINavigationController> navigationController = adoptNS([[UINavigationController alloc] initWithRootViewController:_viewController.get()]);

NSString *resetString = WEB_UI_STRING_KEY("Reset", "Reset Button Date/Time Context Menu", "Reset button in date input context menu");
NSString *okString = WEB_UI_STRING_KEY("OK", "OK (OK button title in date/time picker)", "Title of the OK button in date/time form controls.");

RetainPtr<UIBarButtonItem> okBarButton = adoptNS([[UIBarButtonItem alloc] initWithTitle:okString style:UIBarButtonItemStyleDone target:self action:@selector(ok:)]);
RetainPtr<UIBarButtonItem> blankBarButton = adoptNS([[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil]);
RetainPtr<UIBarButtonItem> resetBarButton = adoptNS([[UIBarButtonItem alloc] initWithTitle:resetString style:UIBarButtonItemStylePlain target:self action:@selector(reset:)]);

[_viewController setToolbarItems:@[resetBarButton.get(), blankBarButton.get(), okBarButton.get()]];
[navigationController setToolbarHidden:NO];

auto centeringView = adoptNS([[UIView alloc] init]);

[centeringView addSubview:_datePicker.get()];

_datePicker.get().translatesAutoresizingMaskIntoConstraints = NO;
auto widthConstraint = [[centeringView widthAnchor] constraintGreaterThanOrEqualToAnchor:[_datePicker widthAnchor]];
auto heightConstraint = [[centeringView heightAnchor] constraintEqualToAnchor:[_datePicker heightAnchor]];
auto horizontalConstraint = [[centeringView centerXAnchor] constraintEqualToAnchor:[_datePicker centerXAnchor]];
auto verticalConstraint = [[centeringView centerYAnchor] constraintEqualToAnchor:[_datePicker centerYAnchor]];

[NSLayoutConstraint activateConstraints:@[verticalConstraint, horizontalConstraint, widthConstraint, heightConstraint]];

[_viewController setView:centeringView.get()];

NSString *titleText = _view.inputLabelText;
if (![titleText isEqual:@""]) {
RetainPtr<UILabel> title = adoptNS([[UILabel alloc] init]);
[title setFont:[UIFont preferredFontForTextStyle:UIFontTextStyleFootnote]];
[title setText:titleText];
[title setTextColor:[UIColor secondaryLabelColor]];
[title setTextAlignment:NSTextAlignmentNatural];
RetainPtr<UIBarButtonItem> titleButton = adoptNS([[UIBarButtonItem alloc] initWithCustomView:title.get()]);
[_viewController navigationItem].leftBarButtonItem = titleButton.get();
} else
[navigationController setNavigationBarHidden:YES animated:NO];

[navigationController navigationBar].translucent = NO;
[navigationController navigationBar].barTintColor = [UIColor systemBackgroundColor];
[navigationController toolbar].translucent = NO;
[navigationController toolbar].barTintColor = [UIColor systemBackgroundColor];
return navigationController.get();
} actionProvider:nil];
}

- (void)contextMenuInteraction:(UIContextMenuInteraction *)interaction willDisplayMenuForConfiguration:(UIContextMenuConfiguration *)configuration animator:(id <UIContextMenuInteractionAnimating>)animator
{
[animator addCompletion:[weakSelf = WeakObjCPtr<WKDateTimePicker>(self)] {
auto strongSelf = weakSelf.get();
if (strongSelf)
[strongSelf->_view.webView _didShowContextMenu];
}];
}

- (void)contextMenuInteraction:(UIContextMenuInteraction *)interaction willEndForConfiguration:(UIContextMenuConfiguration *)configuration animator:(id <UIContextMenuInteractionAnimating>)animator
{
[animator addCompletion:[weakSelf = WeakObjCPtr<WKDateTimePicker>(self)] {
auto strongSelf = weakSelf.get();
if (strongSelf) {
[strongSelf->_view accessoryDone];
[strongSelf->_view.webView _didDismissContextMenu];
}
}];
}

- (void)removeContextMenuInteraction
{
if (_dateTimeContextMenuInteraction) {
[_view removeInteraction:_dateTimeContextMenuInteraction.get()];
_dateTimeContextMenuInteraction = nil;
[_view _removeContextMenuViewIfPossible];
[_view.webView _didDismissContextMenu];
}
}

- (void)ensureContextMenuInteraction
{
if (!_dateTimeContextMenuInteraction) {
_dateTimeContextMenuInteraction = adoptNS([[UIContextMenuInteraction alloc] initWithDelegate:self]);
[_view addInteraction:_dateTimeContextMenuInteraction.get()];
}
}

- (void)showDateTimePicker
{
#if HAVE(UICONTEXTMENU_LOCATION)
[self ensureContextMenuInteraction];
[_dateTimeContextMenuInteraction _presentMenuAtLocation:_interactionPoint];
#endif
}

#endif

- (void)reset:(id)sender
{
[self setDateTimePickerToInitialValue];
[_view page]->setFocusedElementValue(String());
}

- (void)ok:(id)sender
{
#if USE(UICONTEXTMENU)
[_dateTimeContextMenuInteraction dismissMenu];
#endif
}

- (NSString *)calendarType
{
return [_datePicker calendar].calendarIdentifier;
}

- (double)hour
{
NSCalendar *calendar = [NSCalendar currentCalendar];
NSDateComponents *components = [calendar components:NSCalendarUnitHour fromDate:[_datePicker date]];

return [components hour];
}

- (double)minute
{
NSCalendar *calendar = [NSCalendar currentCalendar];
NSDateComponents *components = [calendar components:NSCalendarUnitMinute fromDate:[_datePicker date]];

return [components minute];
}

- (void)dealloc
{
[_datePicker removeTarget:self action:NULL forControlEvents:UIControlEventValueChanged];
#if USE(UICONTEXTMENU)
[self removeContextMenuInteraction];
#endif
[super dealloc];
}

- (BOOL)shouldPresentGregorianCalendar:(const WebKit::FocusedElementInformation&)nodeInfo
{
return nodeInfo.autofillFieldName == WebCore::AutofillFieldName::CcExpMonth
|| nodeInfo.autofillFieldName == WebCore::AutofillFieldName::CcExp
|| nodeInfo.autofillFieldName == WebCore::AutofillFieldName::CcExpYear;
}

- (UIView *)controlView
{
return _datePicker.get();
}

- (NSInteger)_timeZoneOffsetFromGMT:(NSDate *)date
{
if (!_shouldRemoveTimeZoneInformation)
return 0;

return [_datePicker.get().timeZone secondsFromGMTForDate:date];
}

- (NSString *)_sanitizeInputValueForFormatter:(NSString *)value
{
// The "time" input type may have seconds and milliseconds information which we
// just ignore. For example: "01:56:20.391" is shortened to just "01:56".
if (_isTimeInput)
return [value substringToIndex:[kTimeFormatString length]];

return value;
}

- (void)_dateChangedSetAsNumber
{
NSDate *date = [_datePicker date];
[_view updateFocusedElementValueAsNumber:([date timeIntervalSince1970] + [self _timeZoneOffsetFromGMT:date]) * kMillisecondsPerSecond];
}

- (RetainPtr<NSDateFormatter>)dateFormatterForPicker
{
RetainPtr<NSLocale> englishLocale = adoptNS([[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]);
RetainPtr<NSDateFormatter> dateFormatter = adoptNS([[NSDateFormatter alloc] init]);
[dateFormatter setTimeZone:_datePicker.get().timeZone];
[dateFormatter setDateFormat:_formatString];
[dateFormatter setLocale:englishLocale.get()];
return dateFormatter;
}

- (void)_dateChangedSetAsString
{
// Force English locale because that is what HTML5 value parsing expects.
RetainPtr<NSDateFormatter> dateFormatter = [self dateFormatterForPicker];
[_view updateFocusedElementValue:[dateFormatter stringFromDate:[_datePicker date]]];
}

- (void)_dateChanged
{
// Internally, DOMHTMLInputElement setValueAs* each take different values for
// different date types. It is sometimes easier to set the date in different ways:
// - use setValueAsString for "date", "month", and "time".
// - use setValueAsNumber for "datetime-local".
if (_formatString)
[self _dateChangedSetAsString];
else
[self _dateChangedSetAsNumber];
}

- (void)_dateChangeHandler:(id)sender
{
[self _dateChanged];
}

- (void)setDateTimePickerToInitialValue
{
if ([_initialValue isEqual: @""]) {
[_datePicker setDate:[NSDate date]];
[self _dateChanged];
} else if (_formatString) {
// Convert the string value to a date object for the fields where we have a format string.
RetainPtr<NSDateFormatter> dateFormatter = [self dateFormatterForPicker];
NSDate *parsedDate = [dateFormatter dateFromString:[self _sanitizeInputValueForFormatter:_initialValue.get()]];
[_datePicker setDate:parsedDate ? parsedDate : [NSDate date]];
} else {
// Convert the number value to a date object for the fields affected by timezones.
NSTimeInterval secondsSince1970 = _initialValueAsNumber / kMillisecondsPerSecond;
NSInteger timeZoneOffset = [self _timeZoneOffsetFromGMT:[NSDate dateWithTimeIntervalSince1970:secondsSince1970]];
NSTimeInterval adjustedSecondsSince1970 = secondsSince1970 - timeZoneOffset;
[_datePicker setDate:[NSDate dateWithTimeIntervalSince1970:adjustedSecondsSince1970]];
}
}

- (void)controlBeginEditing
{
if (_presenting)
return;

_presenting = YES;

auto elementType = _view.focusedElementInformation.elementType;
if (elementType == WebKit::InputType::Time || elementType == WebKit::InputType::DateTimeLocal)
[_view startRelinquishingFirstResponderToFocusedElement];

// Set the time zone in case it changed.
_datePicker.get().timeZone = [NSTimeZone localTimeZone];

// Currently no value for the <input>. Start the picker with the current time.
// Also, update the actual <input> value.
_initialValue = _view.focusedElementInformation.value;
_initialValueAsNumber = _view.focusedElementInformation.valueAsNumber;
[self setDateTimePickerToInitialValue];

#if USE(UICONTEXTMENU)
WebKit::InteractionInformationRequest positionInformationRequest { WebCore::IntPoint(_view.focusedElementInformation.lastInteractionLocation) };
[_view doAfterPositionInformationUpdate:^(WebKit::InteractionInformationAtPosition interactionInformation) {
[self showDateTimePicker];
} forRequest:positionInformationRequest];
#endif

}

- (void)setHour:(NSInteger)hour minute:(NSInteger)minute
{
NSString *timeString = [NSString stringWithFormat:@"%.2ld:%.2ld", (long)hour, (long)minute];
[_datePicker setDate:[[self dateFormatterForPicker] dateFromString:timeString]];
[self _dateChanged];
}

- (WKDateTimeContextMenuViewController *)viewController
{
return _viewController.get();
}

- (void)controlEndEditing
{
_presenting = NO;

[_view stopRelinquishingFirstResponderToFocusedElement];

#if USE(UICONTEXTMENU)
[self removeContextMenuInteraction];
#endif
}
@end

@implementation WKDateTimeInputControl

- (instancetype)initWithView:(WKContentView *)view
{
UIDatePickerMode mode;

switch (view.focusedElementInformation.elementType) {
case WebKit::InputType::Date:
mode = UIDatePickerModeDate;
break;
case WebKit::InputType::DateTimeLocal:
mode = UIDatePickerModeDateAndTime;
break;
case WebKit::InputType::Time:
mode = UIDatePickerModeTime;
break;
case WebKit::InputType::Month:
mode = (UIDatePickerMode)UIDatePickerModeYearAndMonth;
break;
default:
[self release];
return nil;
}

return [super initWithView:view control:adoptNS([[WKDateTimePicker alloc] initWithView:view datePickerMode:mode])];
}

@end

@implementation WKDateTimeInputControl (WKTesting)

- (void)setTimePickerHour:(NSInteger)hour minute:(NSInteger)minute
{
if ([self.control isKindOfClass:WKDateTimePicker.class])
[(WKDateTimePicker *)self.control setHour:hour minute:minute];
}

- (NSString *)dateTimePickerCalendarType
{
if ([self.control isKindOfClass:WKDateTimePicker.class])
return [(WKDateTimePicker *)self.control calendarType];
return nil;
}

- (double)timePickerValueHour
{
if ([self.control isKindOfClass:WKDateTimePicker.class])
return [(WKDateTimePicker *)self.control hour];
return -1;
}

- (double)timePickerValueMinute
{
if ([self.control isKindOfClass:WKDateTimePicker.class])
return [(WKDateTimePicker *)self.control minute];
return -1;
}

@end

#endif // PLATFORM(IOS_FAMILY)