Files
cappuccino/AppKit/CPTokenField.j
T
Alexander Ljungberg 33fe1573ad Ensure autocomplete menu is on top.
The menu should appear above other normal windows and controls like a regular CPMenu. To do this, put the pop up in its own window with the `CPPopUpMenuWindowLevel` window level.
2012-06-16 12:31:48 +01:00

1271 lines
40 KiB
Plaintext
Executable File

/*
* CPTokenField.j
* AppKit
*
* Created by Klaas Pieter Annema.
* Copyright 2008, 280 North, Inc.
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
@import <Foundation/CPCharacterSet.j>
@import <Foundation/CPIndexSet.j>
@import <Foundation/CPTimer.j>
@import "CPButton.j"
@import "CPScrollView.j"
@import "CPTextField.j"
@import "CPTableView.j"
@import "CPWindow.j"
@import "_CPAutocompleteMenu.j"
#if PLATFORM(DOM)
var CPTokenFieldDOMInputElement = nil,
CPTokenFieldDOMPasswordInputElement = nil,
CPTokenFieldDOMStandardInputElement = nil,
CPTokenFieldInputOwner = nil,
CPTokenFieldTextDidChangeValue = nil,
CPTokenFieldInputResigning = NO,
CPTokenFieldInputDidBlur = NO,
CPTokenFieldInputIsActive = NO,
CPTokenFieldCachedSelectStartFunction = nil,
CPTokenFieldCachedDragFunction = nil,
CPTokenFieldFocusInput = NO,
CPTokenFieldBlurFunction = nil,
CPTokenFieldKeyUpFunction = nil,
CPTokenFieldKeyPressFunction = nil,
CPTokenFieldKeyDownFunction = nil;
#endif
var CPScrollDestinationNone = 0,
CPScrollDestinationLeft = 1,
CPScrollDestinationRight = 2;
@implementation CPTokenField : CPTextField
{
CPScrollView _tokenScrollView;
int _shouldScrollTo;
CPRange _selectedRange;
_CPAutocompleteMenu _autocompleteMenu;
CPTimeInterval _completionDelay;
CPCharacterSet _tokenizingCharacterSet @accessors(property=tokenizingCharacterSet);
CPEvent _mouseDownEvent;
BOOL _preventResign;
BOOL _shouldNotifyTarget;
}
+ (CPCharacterSet)defaultTokenizingCharacterSet
{
return [CPCharacterSet characterSetWithCharactersInString:@","];
}
+ (CPString)defaultThemeClass
{
return "tokenfield";
}
- (id)initWithFrame:(CPRect)frame
{
if (self = [super initWithFrame:frame])
{
_completionDelay = [CPTokenField defaultCompletionDelay];
_tokenizingCharacterSet = [[self class] defaultTokenizingCharacterSet];
[self setBezeled:YES];
[self _init];
[self setObjectValue:[]];
[self setNeedsLayout];
}
return self;
}
- (void)_init
{
_selectedRange = CPMakeRange(0, 0);
var frame = [self frame];
_tokenScrollView = [[CPScrollView alloc] initWithFrame:CGRectMakeZero()];
[_tokenScrollView setHasHorizontalScroller:NO];
[_tokenScrollView setHasVerticalScroller:NO];
[_tokenScrollView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable];
var contentView = [[CPView alloc] initWithFrame:CGRectMakeZero()];
[contentView setAutoresizingMask:CPViewWidthSizable];
[_tokenScrollView setDocumentView:contentView];
[self addSubview:_tokenScrollView];
}
- (_CPAutocompleteMenu)_autocompleteMenu
{
if (!_autocompleteMenu)
_autocompleteMenu = [[_CPAutocompleteMenu alloc] initWithTextField:self];
return _autocompleteMenu;
}
- (void)_autocompleteWithDOMEvent:(JSObject)DOMEvent
{
if (![self _inputElement].value && (![_autocompleteMenu contentArray] || ![self hasThemeState:CPThemeStateAutocompleting]))
return;
[self _hideCompletions];
var token = [_autocompleteMenu selectedItem],
shouldRemoveLastObject = token !== @"" && [self _inputElement].value !== @"";
if (!token)
token = [self _inputElement].value;
// Make sure the user typed an actual token to prevent the previous token from being emptied
// If the input area is empty, we want to fall back to the normal behavior, resigning first
// responder or selecting the next or previous key view.
if (!token || token === @"")
{
if (DOMEvent && DOMEvent.keyCode === CPTabKeyCode)
{
if (!DOMEvent.shiftKey)
[[self window] selectNextKeyView:self];
else
[[self window] selectPreviousKeyView:self];
}
else
[[self window] makeFirstResponder:nil];
return;
}
var objectValue = [self objectValue];
// Remove the uncompleted token and add the token string.
// Explicitly remove the last object because the array contains strings and removeObject uses isEqual to compare objects
if (shouldRemoveLastObject)
[objectValue removeObjectAtIndex:_selectedRange.location];
// Convert typed text into a represented object.
token = [self _representedObjectForEditingString:token];
// Give the delegate a chance to confirm, replace or add to the list of tokens being added.
var delegateApprovedObjects = [self _shouldAddObjects:[CPArray arrayWithObject:token] atIndex:_selectedRange.location],
delegateApprovedObjectsCount = [delegateApprovedObjects count];
if (delegateApprovedObjects)
{
for (var i = 0; i < delegateApprovedObjectsCount; i++)
{
[objectValue insertObject:[delegateApprovedObjects objectAtIndex:i] atIndex:_selectedRange.location + i];
}
}
// Put the cursor after the last inserted token.
var location = _selectedRange.location;
[self setObjectValue:objectValue];
if (delegateApprovedObjectsCount)
location += delegateApprovedObjectsCount;
_selectedRange = CPMakeRange(location, 0);
[self _inputElement].value = @"";
[self setNeedsLayout];
[self _controlTextDidChange];
}
- (void)_autocomplete
{
[self _autocompleteWithDOMEvent:nil];
}
- (void)_selectToken:(_CPTokenFieldToken)token byExtendingSelection:(BOOL)extend
{
var indexOfToken = [[self _tokens] indexOfObject:token];
if (indexOfToken == CPNotFound)
{
if (!extend)
_selectedRange = CPMakeRange([[self _tokens] count], 0);
}
else if (extend)
_selectedRange = CPUnionRange(_selectedRange, CPMakeRange(indexOfToken, 1));
else
_selectedRange = CPMakeRange(indexOfToken, 1);
[self setNeedsLayout];
}
- (void)_deselectToken:(_CPTokenFieldToken)token
{
var indexOfToken = [[self _tokens] indexOfObject:token];
if (CPLocationInRange(indexOfToken, _selectedRange))
_selectedRange = CPMakeRange(MAX(indexOfToken, _selectedRange.location), MIN(_selectedRange.length, indexOfToken - _selectedRange.location));
[self setNeedsLayout];
}
- (void)_deleteToken:(_CPTokenFieldToken)token
{
var indexOfToken = [[self _tokens] indexOfObject:token],
objectValue = [self objectValue];
// If the selection was to the right of the deleted token, move it to the left. If the deleted token was
// selected, deselect it.
if (indexOfToken < _selectedRange.location)
_selectedRange.location--;
else
[self _deselectToken:token];
// Preserve selection.
var selection = CPCopyRange(_selectedRange);
[objectValue removeObjectAtIndex:indexOfToken];
[self setObjectValue:objectValue];
_selectedRange = selection;
[self setNeedsLayout];
[self _controlTextDidChange];
}
- (void)_controlTextDidChange
{
var binderClass = [[self class] _binderClassForBinding:CPValueBinding],
theBinding = [binderClass getBinding:CPValueBinding forObject:self];
if (theBinding)
[theBinding reverseSetValueFor:@"objectValue"];
[self textDidChange:[CPNotification notificationWithName:CPControlTextDidChangeNotification object:self userInfo:nil]];
_shouldNotifyTarget = YES;
}
- (void)_removeSelectedTokens:(id)sender
{
var tokens = [self objectValue];
for (var i = _selectedRange.length - 1; i >= 0; i--)
[tokens removeObjectAtIndex:_selectedRange.location + i];
var collapsedSelection = _selectedRange.location;
[self setObjectValue:tokens];
// setObjectValue moves the cursor to the end of the selection. We want it to stay
// where the selected tokens were.
_selectedRange = CPMakeRange(collapsedSelection, 0);
[self _controlTextDidChange];
}
- (void)_updatePlaceholderState
{
if (([[self _tokens] count] === 0) && ![self hasThemeState:CPThemeStateEditing])
[self setThemeState:CPTextFieldStatePlaceholder];
else
[self unsetThemeState:CPTextFieldStatePlaceholder];
}
// =============
// = RESPONDER =
// =============
- (BOOL)becomeFirstResponder
{
if (CPTokenFieldInputOwner && [CPTokenFieldInputOwner window] !== [self window])
[[CPTokenFieldInputOwner window] makeFirstResponder:nil];
[self setThemeState:CPThemeStateEditing];
[self _updatePlaceholderState];
[self setNeedsLayout];
#if PLATFORM(DOM)
var string = [self stringValue],
element = [self _inputElement];
element.value = nil;
element.style.color = [[self currentValueForThemeAttribute:@"text-color"] cssString];
element.style.font = [[self currentValueForThemeAttribute:@"font"] cssString];
element.style.zIndex = 1000;
switch ([self alignment])
{
case CPCenterTextAlignment: element.style.textAlign = "center";
break;
case CPRightTextAlignment: element.style.textAlign = "right";
break;
default: element.style.textAlign = "left";
}
var contentRect = [self contentRectForBounds:[self bounds]];
element.style.top = CGRectGetMinY(contentRect) + "px";
element.style.left = (CGRectGetMinX(contentRect) - 1) + "px"; // why -1?
element.style.width = CGRectGetWidth(contentRect) + "px";
element.style.height = CGRectGetHeight(contentRect) + "px";
[_tokenScrollView documentView]._DOMElement.appendChild(element);
window.setTimeout(function()
{
element.focus();
CPTokenFieldInputOwner = self;
}, 0.0);
//post CPControlTextDidBeginEditingNotification
[self textDidBeginEditing:[CPNotification notificationWithName:CPControlTextDidBeginEditingNotification object:self userInfo:nil]];
[[[self window] platformWindow] _propagateCurrentDOMEvent:YES];
CPTokenFieldInputIsActive = YES;
if (document.attachEvent)
{
CPTokenFieldCachedSelectStartFunction = document.body.onselectstart;
CPTokenFieldCachedDragFunction = document.body.ondrag;
document.body.ondrag = function () {};
document.body.onselectstart = function () {};
}
#endif
return YES;
}
- (BOOL)resignFirstResponder
{
if (_preventResign)
return NO;
[self unsetThemeState:CPThemeStateEditing];
[self _autocomplete];
#if PLATFORM(DOM)
var element = [self _inputElement];
CPTokenFieldInputResigning = YES;
element.blur();
if (!CPTokenFieldInputDidBlur)
CPTokenFieldBlurFunction();
CPTokenFieldInputDidBlur = NO;
CPTokenFieldInputResigning = NO;
if (element.parentNode == [_tokenScrollView documentView]._DOMElement)
element.parentNode.removeChild(element);
CPTokenFieldInputIsActive = NO;
if (document.attachEvent)
{
CPTokenFieldCachedSelectStartFunction = nil;
CPTokenFieldCachedDragFunction = nil;
document.body.ondrag = CPTokenFieldCachedDragFunction
document.body.onselectstart = CPTokenFieldCachedSelectStartFunction
}
#endif
[self _updatePlaceholderState];
[self setNeedsLayout];
if (_shouldNotifyTarget)
{
_shouldNotifyTarget = NO;
[self textDidEndEditing:[CPNotification notificationWithName:CPControlTextDidEndEditingNotification object:self userInfo:nil]];
if ([self sendsActionOnEndEditing])
[self sendAction:[self action] to:[self target]];
}
return YES;
}
- (void)mouseDown:(CPEvent)anEvent
{
_preventResign = YES;
_mouseDownEvent = anEvent;
[self _selectToken:nil byExtendingSelection:NO];
[super mouseDown:anEvent];
}
- (void)mouseUp:(CPEvent)anEvent
{
_preventResign = NO;
_mouseDownEvent = nil;
}
- (void)mouseDownOnToken:(_CPTokenFieldToken)aToken withEvent:(CPEvent)anEvent
{
_preventResign = YES;
_mouseDownEvent = anEvent;
}
- (void)mouseUpOnToken:(_CPTokenFieldToken)aToken withEvent:(CPEvent)anEvent
{
if (_mouseDownEvent && CGPointEqualToPoint([_mouseDownEvent locationInWindow], [anEvent locationInWindow]))
{
[self _selectToken:aToken byExtendingSelection:[anEvent modifierFlags] & CPShiftKeyMask];
[[self window] makeFirstResponder:self];
// Snap to the token if it's only half visible due to mouse wheel scrolling.
_shouldScrollTo = aToken;
}
_preventResign = NO;
}
// ===========
// = CONTROL =
// ===========
- (CPArray)_tokens
{
// We return super here because objectValue uses this method
// If we called self we would loop infinitely
return [super objectValue];
}
- (CPString)stringValue
{
return [[self objectValue] componentsJoinedByString:@","];
}
- (id)objectValue
{
var objectValue = [];
for (var i = 0, count = [[self _tokens] count]; i < count; i++)
{
var token = [[self _tokens] objectAtIndex:i];
if ([token isKindOfClass:[CPString class]])
continue;
[objectValue addObject:[token representedObject]];
}
#if PLATFORM(DOM)
if ([self _inputElement].value != @"")
[objectValue insertObject:[self _inputElement].value atIndex:_selectedRange.location];
#endif
return objectValue;
}
- (void)setObjectValue:(id)aValue
{
if (aValue !== nil && ![aValue isKindOfClass:[CPArray class]])
{
[super setObjectValue:nil];
return;
}
var superValue = [super objectValue];
if (aValue === superValue || [aValue isEqualToArray:superValue])
return;
var contentView = [_tokenScrollView documentView];
// Preserve as many existing tokens as possible to reduce redraw flickering.
var oldTokens = [self _tokens],
newTokens = [];
if (aValue !== nil)
{
for (var i = 0, count = [aValue count]; i < count; i++)
{
// Do we have this token among the old ones?
var tokenObject = aValue[i],
tokenValue = [self _displayStringForRepresentedObject:tokenObject],
newToken = nil;
for (var j = 0, oldCount = [oldTokens count]; j < oldCount; j++)
{
var oldToken = oldTokens[j];
if ([oldToken representedObject] == tokenObject)
{
// Yep. Reuse it.
[oldTokens removeObjectAtIndex:j];
newToken = oldToken;
break;
}
}
if (newToken === nil)
{
newToken = [[_CPTokenFieldToken alloc] init];
[newToken setTokenField:self];
[newToken setRepresentedObject:tokenObject];
[newToken setStringValue:tokenValue];
[contentView addSubview:newToken];
}
newTokens.push(newToken);
}
}
// Remove any now unused tokens.
for (var j = 0, oldCount = [oldTokens count]; j < oldCount; j++)
[oldTokens[j] removeFromSuperview];
/*
[CPTextField setObjectValue] will try to set the _inputElement.value to
the new objectValue, if the _inputElement exists. This is wrong for us
since our objectValue is an array of tokens, so we can't use
[super setObjectValue:objectValue];
Instead do what CPControl setObjectValue would.
*/
_value = newTokens;
// Reset the selection.
[self _selectToken:nil byExtendingSelection:NO];
[self _updatePlaceholderState];
_shouldScrollTo = CPScrollDestinationRight;
[self setNeedsLayout];
[self setNeedsDisplay:YES];
}
- (void)sendAction:(SEL)anAction to:(id)anObject
{
_shouldNotifyTarget = NO;
[super sendAction:anAction to:anObject];
}
// Incredible hack to disable supers implementation
// so it cannot change our object value and break the tokenfield
- (void)_setStringValue:(id)aValue
{
}
// =============
// = TEXTFIELD =
// =============
#if PLATFORM(DOM)
- (DOMElement)_inputElement
{
if (!CPTokenFieldDOMInputElement)
{
CPTokenFieldDOMInputElement = document.createElement("input");
CPTokenFieldDOMInputElement.style.position = "absolute";
CPTokenFieldDOMInputElement.style.border = "0px";
CPTokenFieldDOMInputElement.style.padding = "0px";
CPTokenFieldDOMInputElement.style.margin = "0px";
CPTokenFieldDOMInputElement.style.whiteSpace = "pre";
CPTokenFieldDOMInputElement.style.background = "transparent";
CPTokenFieldDOMInputElement.style.outline = "none";
CPTokenFieldBlurFunction = function(anEvent)
{
if (CPTokenFieldInputOwner && [CPTokenFieldInputOwner._tokenScrollView documentView]._DOMElement != CPTokenFieldDOMInputElement.parentNode)
return;
if (CPTokenFieldInputOwner && CPTokenFieldInputOwner._preventResign)
return false;
if (!CPTokenFieldInputResigning && !CPTokenFieldFocusInput)
{
[[CPTokenFieldInputOwner window] makeFirstResponder:nil];
return;
}
CPTokenFieldHandleBlur(anEvent, CPTokenFieldDOMInputElement);
CPTokenFieldInputDidBlur = YES;
return true;
};
CPTokenFieldKeyDownFunction = function(aDOMEvent)
{
aDOMEvent = aDOMEvent || window.event;
CPTokenFieldTextDidChangeValue = [CPTokenFieldInputOwner stringValue];
if (aDOMEvent.keyCode === CPUpArrowKeyCode)
[[CPTokenFieldInputOwner _autocompleteMenu] selectPrevious];
else if (aDOMEvent.keyCode === CPDownArrowKeyCode)
[[CPTokenFieldInputOwner _autocompleteMenu] selectNext];
var owner = CPTokenFieldInputOwner;
if (aDOMEvent.keyCode === CPReturnKeyCode || aDOMEvent.keyCode === CPTabKeyCode)
{
if (aDOMEvent.preventDefault)
aDOMEvent.preventDefault();
if (aDOMEvent.stopPropagation)
aDOMEvent.stopPropagation();
aDOMEvent.cancelBubble = true;
// Only resign first responder if we weren't auto-completing
if (![CPTokenFieldInputOwner hasThemeState:CPThemeStateAutocompleting])
{
if (aDOMEvent && aDOMEvent.keyCode === CPReturnKeyCode)
{
[owner sendAction:[owner action] to:[owner target]];
[[owner window] makeFirstResponder:nil];
}
else if (aDOMEvent && aDOMEvent.keyCode === CPTabKeyCode)
{
if (!aDOMEvent.shiftKey)
[[owner window] selectNextKeyView:owner];
else
[[owner window] selectPreviousKeyView:owner];
}
}
[owner _autocompleteWithDOMEvent:aDOMEvent];
[owner setNeedsLayout];
}
else if (aDOMEvent.keyCode === CPEscapeKeyCode)
{
[CPTokenFieldInputOwner _hideCompletions];
}
else if (aDOMEvent.keyCode === CPUpArrowKeyCode || aDOMEvent.keyCode === CPDownArrowKeyCode)
{
if (aDOMEvent.preventDefault)
aDOMEvent.preventDefault();
if (aDOMEvent.stopPropagation)
aDOMEvent.stopPropagation();
aDOMEvent.cancelBubble = true;
}
else if (aDOMEvent.keyCode == CPLeftArrowKeyCode && owner._selectedRange.location > 0 && CPTokenFieldDOMInputElement.value == "")
{
// Move the cursor back one token if the input is empty and the left arrow key is pressed.
if (!aDOMEvent.shiftKey)
{
if (owner._selectedRange.length)
// Simply collapse the range.
owner._selectedRange.length = 0;
else
owner._selectedRange.location--;
}
else
{
owner._selectedRange.location--;
// When shift is depressed, select the next token backwards.
owner._selectedRange.length++;
}
owner._shouldScrollTo = CPScrollDestinationLeft;
[owner setNeedsLayout];
}
else if (aDOMEvent.keyCode == CPRightArrowKeyCode && owner._selectedRange.location < [[owner _tokens] count] && CPTokenFieldDOMInputElement.value == "")
{
if (!aDOMEvent.shiftKey)
{
if (owner._selectedRange.length)
{
// Place the cursor at the end of the selection and collapse.
owner._selectedRange.location = CPMaxRange(owner._selectedRange);
owner._selectedRange.length = 0;
}
else
{
// Move the cursor forward one token if the input is empty and the right arrow key is pressed.
owner._selectedRange.location = MIN([[owner _tokens] count], owner._selectedRange.location + owner._selectedRange.length + 1);
}
}
else
{
// Leave the selection location in place but include the next token to the right.
owner._selectedRange.length++;
}
owner._shouldScrollTo = CPScrollDestinationRight;
[owner setNeedsLayout];
}
else if (aDOMEvent.keyCode === CPDeleteKeyCode)
{
// Highlight the previous token if backspace was pressed in an empty input element or re-show the completions view
if (CPTokenFieldDOMInputElement.value == @"")
{
[self _hideCompletions];
if (CPEmptyRange(CPTokenFieldInputOwner._selectedRange))
{
if (CPTokenFieldInputOwner._selectedRange.location > 0)
{
var tokens = [CPTokenFieldInputOwner _tokens],
tokenView = [tokens objectAtIndex:(CPTokenFieldInputOwner._selectedRange.location - 1)];
[CPTokenFieldInputOwner _selectToken:tokenView byExtendingSelection:NO];
}
}
else
[CPTokenFieldInputOwner _removeSelectedTokens:nil];
}
else
[CPTokenFieldInputOwner _delayedShowCompletions];
}
else if (aDOMEvent.keyCode === CPDeleteForwardKeyCode && CPTokenFieldDOMInputElement.value == @"")
{
// Delete forward if nothing is selected, else delete all selected.
[self _hideCompletions];
if (CPEmptyRange(CPTokenFieldInputOwner._selectedRange))
{
var tokens = [CPTokenFieldInputOwner _tokens];
if (CPTokenFieldInputOwner._selectedRange.location < [tokens count])
[CPTokenFieldInputOwner _deleteToken:tokens[CPTokenFieldInputOwner._selectedRange.location]];
}
else
[CPTokenFieldInputOwner _removeSelectedTokens:nil];
}
return true;
};
CPTokenFieldKeyPressFunction = function(aDOMEvent)
{
aDOMEvent = aDOMEvent || window.event;
var character = String.fromCharCode(aDOMEvent.keyCode || aDOMEvent.which),
owner = CPTokenFieldInputOwner;
if ([[owner tokenizingCharacterSet] characterIsMember:character])
{
if (aDOMEvent.preventDefault)
aDOMEvent.preventDefault();
if (aDOMEvent.stopPropagation)
aDOMEvent.stopPropagation();
aDOMEvent.cancelBubble = true;
[owner _autocompleteWithDOMEvent:aDOMEvent];
[owner setNeedsLayout];
return true;
}
[CPTokenFieldInputOwner _delayedShowCompletions];
// If there was a selection, collapse it now since we're typing in a new token.
owner._selectedRange.length = 0;
// Force immediate layout in case word wrapping is now necessary.
[owner setNeedsLayout];
[[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode];
};
CPTokenFieldKeyUpFunction = function()
{
if ([CPTokenFieldInputOwner stringValue] !== CPTokenFieldTextDidChangeValue)
{
CPTokenFieldTextDidChangeValue = [CPTokenFieldInputOwner stringValue];
[CPTokenFieldInputOwner textDidChange:[CPNotification notificationWithName:CPControlTextDidChangeNotification object:CPTokenFieldInputOwner userInfo:nil]];
}
[self setNeedsLayout];
[[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode];
};
CPTokenFieldHandleBlur = function(anEvent)
{
CPTokenFieldInputOwner = nil;
[[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode];
};
if (document.attachEvent)
{
CPTokenFieldDOMInputElement.attachEvent("on" + CPDOMEventKeyUp, CPTokenFieldKeyUpFunction);
CPTokenFieldDOMInputElement.attachEvent("on" + CPDOMEventKeyDown, CPTokenFieldKeyDownFunction);
CPTokenFieldDOMInputElement.attachEvent("on" + CPDOMEventKeyPress, CPTokenFieldKeyPressFunction);
}
else
{
CPTokenFieldDOMInputElement.addEventListener(CPDOMEventKeyUp, CPTokenFieldKeyUpFunction, NO);
CPTokenFieldDOMInputElement.addEventListener(CPDOMEventKeyDown, CPTokenFieldKeyDownFunction, NO);
CPTokenFieldDOMInputElement.addEventListener(CPDOMEventKeyPress, CPTokenFieldKeyPressFunction, NO);
}
//FIXME make this not onblur
CPTokenFieldDOMInputElement.onblur = CPTokenFieldBlurFunction;
CPTokenFieldDOMStandardInputElement = CPTokenFieldDOMInputElement;
}
if (CPFeatureIsCompatible(CPInputTypeCanBeChangedFeature))
{
if ([CPTokenFieldInputOwner isSecure])
CPTokenFieldDOMInputElement.type = "password";
else
CPTokenFieldDOMInputElement.type = "text";
return CPTokenFieldDOMInputElement;
}
return CPTokenFieldDOMInputElement;
}
#endif
// - (void)setTokenStyle: (NSTokenStyle) style;
// - (NSTokenStyle)tokenStyle;
//
// ====================
// = COMPLETION DELAY =
// ====================
- (void)setCompletionDelay:(CPTimeInterval)delay
{
_completionDelay = delay;
}
- (NSTimeInterval)completionDelay
{
return _completionDelay;
}
+ (NSTimeInterval)defaultCompletionDelay
{
return 0.5;
}
// ==========
// = LAYOUT =
// ==========
- (void)layoutSubviews
{
[super layoutSubviews];
[_tokenScrollView setFrame:[self rectForEphemeralSubviewNamed:"content-view"]];
var textFieldContentView = [self layoutEphemeralSubviewNamed:@"content-view"
positioned:CPWindowAbove
relativeToEphemeralSubviewNamed:@"bezel-view"];
if (textFieldContentView)
[textFieldContentView setHidden:[self stringValue] !== @""];
var frame = [self frame],
contentView = [_tokenScrollView documentView],
tokens = [self _tokens],
shouldShowAutoComplete = [self hasThemeState:CPThemeStateAutocompleting];
// Hack to make sure we are handling an array
if (![tokens isKindOfClass:[CPArray class]])
return;
// Move each token into the right position.
var contentRect = CGRectMakeCopy([contentView bounds]),
contentOrigin = contentRect.origin,
contentSize = contentRect.size,
offset = CPPointMake(contentOrigin.x, contentOrigin.y),
spaceBetweenTokens = CPSizeMake(2.0, 2.0),
isEditing = [[self window] firstResponder] == self,
tokenToken = [_CPTokenFieldToken new];
// Get the height of a typical token, or a token token if you will.
[tokenToken sizeToFit];
var tokenHeight = CGRectGetHeight([tokenToken bounds]);
var fitAndFrame = function(width, height)
{
var r = CGRectMake(0, 0, width, height);
if (offset.x + width >= contentSize.width && offset.x > contentOrigin.x)
{
offset.x = contentOrigin.x;
offset.y += height + spaceBetweenTokens.height;
}
r.origin.x = offset.x;
r.origin.y = offset.y;
// Make sure the frame fits.
if (CGRectGetHeight([contentView bounds]) < offset.y + height)
[contentView setFrame:CGRectMake(0, 0, CGRectGetWidth([_tokenScrollView bounds]), offset.y + height)];
offset.x += width + spaceBetweenTokens.width;
return r;
};
var placeEditor = function(useRemainingWidth)
{
var element = [self _inputElement],
textWidth = 1;
if (_selectedRange.length === 0)
{
// XXX The "X" here is used to estimate the space needed to fit the next character
// without clipping. Since different fonts might have different sizes of "X" this
// solution is not ideal, but it works.
textWidth = [(element.value || @"") + "X" sizeWithFont:[self font]].width;
if (useRemainingWidth)
textWidth = MAX(contentSize.width - offset.x - 1, textWidth);
}
var inputFrame = fitAndFrame(textWidth, tokenHeight);
element.style.left = inputFrame.origin.x + "px";
element.style.top = inputFrame.origin.y + "px";
element.style.width = inputFrame.size.width + "px";
element.style.height = inputFrame.size.height + "px";
// When editing, always scroll to the cursor.
if (_selectedRange.length == 0)
[[_tokenScrollView documentView] scrollRectToVisible:inputFrame];
};
for (var i = 0, count = [tokens count]; i < count; i++)
{
if (isEditing && i == CPMaxRange(_selectedRange))
placeEditor(false);
var tokenView = [tokens objectAtIndex:i];
// Make sure we are only changing completed tokens
if ([tokenView isKindOfClass:[CPString class]])
continue;
[tokenView setHighlighted:CPLocationInRange(i, _selectedRange)];
[tokenView sizeToFit];
var size = [contentView bounds].size,
tokenViewSize = [tokenView bounds].size,
tokenFrame = fitAndFrame(tokenViewSize.width, tokenViewSize.height);
[tokenView setFrame:tokenFrame];
}
if (isEditing && CPMaxRange(_selectedRange) >= [tokens count])
placeEditor(true);
// Hide the editor if there are selected tokens, but still keep it active
// so we can continue using our standard keyboard handling events.
if (isEditing && _selectedRange.length)
{
[self _inputElement].style.left = "-10000px";
[self _inputElement].focus();
}
// Trim off any excess height downwards.
if (CGRectGetHeight([contentView bounds]) > offset.y + tokenHeight)
[contentView setFrame:CGRectMake(0, 0, CGRectGetWidth([_tokenScrollView bounds]), offset.y + tokenHeight)];
if (_shouldScrollTo !== CPScrollDestinationNone)
{
// Only carry out the scroll if the cursor isn't visible.
if (!(isEditing && _selectedRange.length == 0))
{
var scrollToToken = _shouldScrollTo;
if (scrollToToken === CPScrollDestinationLeft)
scrollToToken = tokens[_selectedRange.location]
else if (scrollToToken === CPScrollDestinationRight)
scrollToToken = tokens[MAX(0, CPMaxRange(_selectedRange) - 1)];
[self _scrollTokenViewToVisible:scrollToToken];
}
_shouldScrollTo = CPScrollDestinationNone;
}
}
- (BOOL)_scrollTokenViewToVisible:(_CPTokenFieldToken)aToken
{
if (!aToken)
return;
return [[_tokenScrollView documentView] scrollRectToVisible:[aToken frame]];
}
@end
@implementation CPTokenField (CPTokenFieldDelegate)
/*!
Private API to get the delegate tokenField:completionsForSubstring:indexOfToken:indexOfSelectedItem: result.
The delegate method should return an array of strings matching the provided substring for autocompletion.
tokenIndex is the index of the token being completed. selectedIndex allows the selected autocompletion option
to be indicated by reference.
@ignore
*/
- (CPArray)_completionsForSubstring:(CPString)substring indexOfToken:(int)tokenIndex indexOfSelectedItem:(int)selectedIndex
{
if ([[self delegate] respondsToSelector:@selector(tokenField:completionsForSubstring:indexOfToken:indexOfSelectedItem:)])
{
return [[self delegate] tokenField:self completionsForSubstring:substring indexOfToken:tokenIndex indexOfSelectedItem:selectedIndex];
}
return [];
}
/*!
Private API to get the delegate tokenField:displayStringForRepresentedObject: result.
The delegate method should return a string to be displayed for the given represtented object.
If this delegate method is not implemented, the representedObject is displayed as a string.
@ignore
*/
- (CPString)_displayStringForRepresentedObject:(id)representedObject
{
if ([[self delegate] respondsToSelector:@selector(tokenField:displayStringForRepresentedObject:)])
{
var stringForRepresentedObject = [[self delegate] tokenField:self displayStringForRepresentedObject:representedObject];
if (stringForRepresentedObject !== nil)
{
return stringForRepresentedObject;
}
}
return representedObject;
}
/*!
Private API to get the delegate tokenField:shouldAddObjects:atIndex: result.
The delegate should return an array of represented objects which should be added based on the
suggested tokens to add and the insertion position specified by index. To add no tokens,
return an empty array. Returning nil is an error.
@ignore
*/
- (CPArray)_shouldAddObjects:(CPArray)tokens atIndex:(int)index
{
var delegate = [self delegate];
if ([delegate respondsToSelector:@selector(tokenField:shouldAddObjects:atIndex:)])
{
var approvedObjects = [delegate tokenField:self shouldAddObjects:tokens atIndex:index];
if (approvedObjects !== nil)
return approvedObjects;
}
return tokens;
}
/*!
Private API to get the delegate tokenField:representedObjectForEditingString: result.
The delegate method should return a represented object for the provided string which
may have been typed by the user or selected from the completion menu. If the method is
not implemented, the string is assumed to be the represented object.
@ignore
*/
- (id)_representedObjectForEditingString:(CPString)aString
{
var delegate = [self delegate];
if ([delegate respondsToSelector:@selector(tokenField:representedObjectForEditingString:)])
return [delegate tokenField:self representedObjectForEditingString:aString];
return aString;
}
//
// If you return nil or don't implement these delegate methods, we will assume
// editing string = display string = represented object
// - (NSString *)tokenField:(NSTokenField *)tokenField editingStringForRepresentedObject:(id)representedObject;
//
// We put the string on the pasteboard before calling this delegate method.
// By default, we write the NSStringPboardType as well as an array of NSStrings.
// - (BOOL)tokenField:(NSTokenField *)tokenField writeRepresentedObjects:(NSArray *)objects toPasteboard:(NSPasteboard *)pboard;
//
// Return an array of represented objects to add to the token field.
// - (NSArray *)tokenField:(NSTokenField *)tokenField readFromPasteboard:(NSPasteboard *)pboard;
//
// By default the tokens have no menu.
// - (NSMenu *)tokenField:(NSTokenField *)tokenField menuForRepresentedObject:(id)representedObject;
// - (BOOL)tokenField:(NSTokenField *)tokenField hasMenuForRepresentedObject:(id)representedObject;
//
// This method allows you to change the style for individual tokens as well as have mixed text and tokens.
// - (NSTokenStyle)tokenField:(NSTokenField *)tokenField styleForRepresentedObject:(id)representedObject;
- (void)_delayedShowCompletions
{
[[self _autocompleteMenu] _delayedShowCompletions];
}
- (void)_hideCompletions
{
[_autocompleteMenu _hideCompletions];
}
@end
@implementation _CPTokenFieldToken : CPTextField
{
_CPTokenFieldTokenCloseButton _deleteButton;
CPTokenField _tokenField;
id _representedObject;
}
+ (CPString)defaultThemeClass
{
return "tokenfield-token";
}
- (id)initWithFrame:(CPRect)frame
{
if (self = [super initWithFrame:frame])
{
_deleteButton = [[_CPTokenFieldTokenCloseButton alloc] initWithFrame:CPRectMakeZero()];
[self addSubview:_deleteButton];
[self setEditable:NO];
[self setHighlighted:NO];
[self setBezeled:YES];
}
return self;
}
- (CPTokenField)tokenField
{
return _tokenField;
}
- (void)setTokenField:(CPTokenField)tokenField
{
_tokenField = tokenField;
}
- (id)representedObject
{
return _representedObject;
}
- (void)setRepresentedObject:(id)representedObject
{
_representedObject = representedObject;
}
- (CGSize)_minimumFrameSize
{
var size = CGSizeMakeZero(),
minSize = [self currentValueForThemeAttribute:@"min-size"],
contentInset = [self currentValueForThemeAttribute:@"content-inset"];
// Tokens are fixed height, so we could as well have used max-size here.
size.height = minSize.height;
size.width = MAX(minSize.width, [([self stringValue] || @" ") sizeWithFont:[self font]].width + contentInset.left + contentInset.right);
return size;
}
- (void)layoutSubviews
{
[super layoutSubviews];
var bezelView = [self layoutEphemeralSubviewNamed:@"bezel-view"
positioned:CPWindowBelow
relativeToEphemeralSubviewNamed:@"content-view"];
if (bezelView)
{
[_deleteButton setTarget:self];
[_deleteButton setAction:@selector(_delete:)];
var frame = [bezelView frame],
buttonOffset = [_deleteButton currentValueForThemeAttribute:@"offset"],
buttonSize = [_deleteButton currentValueForThemeAttribute:@"min-size"];
[_deleteButton setFrame:CPRectMake(CPRectGetMaxX(frame) - buttonOffset.x, CPRectGetMinY(frame) + buttonOffset.y, buttonSize.width, buttonSize.height)];
}
}
- (void)mouseDown:(CPEvent)anEvent
{
[_tokenField mouseDownOnToken:self withEvent:anEvent];
}
- (void)mouseUp:(CPEvent)anEvent
{
[_tokenField mouseUpOnToken:self withEvent:anEvent];
}
- (void)_delete:(id)sender
{
[_tokenField _deleteToken:self];
}
@end
/*
Theming hook.
*/
@implementation _CPTokenFieldTokenCloseButton : CPButton
{
}
+ (id)themeAttributes
{
var attributes = [CPButton themeAttributes];
[attributes setObject:CGPointMake(15, 5) forKey:@"offset"];
return attributes;
}
+ (CPString)defaultThemeClass
{
return "tokenfield-token-close-button";
}
@end
var CPTokenFieldTokenizingCharacterSetKey = "CPTokenFieldTokenizingCharacterSetKey",
CPTokenFieldCompletionDelayKey = "CPTokenFieldCompletionDelay";
@implementation CPTokenField (CPCoding)
- (id)initWithCoder:(CPCoder)aCoder
{
self = [super initWithCoder:aCoder];
if (self)
{
_tokenizingCharacterSet = [aCoder decodeObjectForKey:CPTokenFieldTokenizingCharacterSetKey] || [[self class] defaultTokenizingCharacterSet];
_completionDelay = [aCoder decodeDoubleForKey:CPTokenFieldCompletionDelayKey] || [[self class] defaultCompletionDelay];
[self _init];
[self setNeedsLayout];
[self setNeedsDisplay:YES];
}
return self;
}
- (void)encodeWithCoder:(CPCoder)aCoder
{
[super encodeWithCoder:aCoder];
[aCoder encodeInt:_tokenizingCharacterSet forKey:CPTokenFieldTokenizingCharacterSetKey];
[aCoder encodeDouble:_completionDelay forKey:CPTokenFieldCompletionDelayKey];
}
@end