mirror of
https://github.com/cappuccino/cappuccino.git
synced 2026-11-03 13:49:45 +00:00
* CPItemProvider.j: Introduce CPItemProvider, modelled on NSItemProvider. - Register data representations keyed by MIME type, with wildcard matching. - Load representations asynchronously, completing inside a run loop pass. - Read browser File objects with FileReader. - Document remaining NSItemProvider compatibility gaps via TODO comments. * Foundation.j: Import CPItemProvider.j. * CPPlatformFilePanel.j: Introduce the DOM platform class that runs the browser's file dialog through a hidden file input. * CPOpenPanel.j: Replace the NativeHost implementation with a browser implementation. - Implement -beginWithCompletionHandler: and -beginSheetModalForWindow:completionHandler:. - Add allowed file types, message, prompt, and an accessory view behind an Options button. - Validate chosen files through the window delegate's panel:validateURL:error:. - Add -itemProviders, and -URLs holding file names. - Raise an exception from -runModal. - Document remaining NSOpenPanel compatibility gaps via TODO comments. * CPTextView.j: Insert images through CPOpenPanel and CPItemProvider, for both -insertImage: and paste. * CPItemProviderTest.j, CPOpenPanelTest.j: Add unit tests covering registration, loading, type matching, panel results, and cancelled runs.
571 lines
18 KiB
Plaintext
571 lines
18 KiB
Plaintext
/*
|
|
* CPOpenPanel.j
|
|
* AppKit
|
|
*
|
|
* Created by Ross Boucher.
|
|
* Copyright 2009, 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/CPException.j>
|
|
@import <Foundation/CPItemProvider.j>
|
|
@import <Foundation/CPURL.j>
|
|
|
|
@import "CPAlert.j"
|
|
@import "CPApplication.j"
|
|
@import "CPButton.j"
|
|
@import "CPPanel.j"
|
|
@import "CPPlatformFilePanel.j"
|
|
@import "CPStringDrawing.j"
|
|
@import "CPTextField.j"
|
|
|
|
@protocol CPOpenSavePanelDelegate <CPObject>
|
|
|
|
@optional
|
|
- (BOOL)panel:(id)sender validateURL:(CPURL)aURL error:(CPError)outError;
|
|
|
|
@end
|
|
|
|
var CPOpenPanelContentWidth = 420.0,
|
|
CPOpenPanelInset = 20.0,
|
|
CPOpenPanelSpacing = 12.0,
|
|
CPOpenPanelButtonWidth = 90.0,
|
|
CPOpenPanelButtonHeight = 24.0;
|
|
|
|
/*
|
|
TODO: Gaps against NSOpenPanel that can be filled:
|
|
|
|
- NSOpenPanel is a subclass of NSSavePanel. CPOpenPanel is a subclass of CPPanel, so it does
|
|
not inherit the save panel's properties.
|
|
- The panel's layout metrics are constants in this file. CPAlert takes its metrics from
|
|
theme attributes; the panel should do the same.
|
|
- The alerts that report rejected files open over the modal panel. That case has not been
|
|
verified.
|
|
*/
|
|
|
|
/*!
|
|
@class CPOpenPanel
|
|
@ingroup appkit
|
|
@brief Lets the user choose files to open.
|
|
|
|
In a browser, a page cannot open a file by its path, and cannot draw or extend the
|
|
browser's file dialog. The panel works within those limits:
|
|
|
|
- The panel is asynchronous. Run it with -beginWithCompletionHandler: or
|
|
-beginSheetModalForWindow:completionHandler:. A browser cannot block while its file
|
|
dialog is open, so -runModal raises an exception.
|
|
- Start the panel from an action method, such as a menu item or button action. The browser
|
|
opens its file dialog only during a user action, and refuses a request made later.
|
|
- When the panel has a message or an accessory view, it shows a Cappuccino panel with them,
|
|
styled by the theme. Its Open button opens the browser's file dialog. Without a message
|
|
or an accessory view, the browser's file dialog opens at once.
|
|
- The chosen files are available as item providers, from -itemProviders. They load the
|
|
files' contents asynchronously.
|
|
- The browser gives no path to a file. Each URL in -URLs holds only the file's name, or its
|
|
path inside a chosen folder, percent-encoded.
|
|
- Allowed file types are a hint to the browser's dialog, which may still let the user choose
|
|
other files. Files of other types are removed from the result, and the user is told.
|
|
- With -canChooseDirectories set and -canChooseFiles not set, the user chooses a folder,
|
|
and the result holds every file in it. A browser gives no item for the folder itself, and
|
|
cannot let the user choose files and folders in one dialog.
|
|
- -directoryURL has no effect. A browser does not let a page choose where its file dialog
|
|
starts.
|
|
|
|
The panel's visible window is a separate CPPanel, made when the panel runs, because its
|
|
style differs for a sheet and for a modal panel.
|
|
|
|
The panel's delegate is its window delegate, set with -setDelegate:, as in Cocoa. It may
|
|
implement \c panel:validateURL:error: to accept or reject each chosen file.
|
|
Cocoa's other delegate methods, such as \c panel:shouldEnableURL:, need the browser's dialog
|
|
to consult the application while it is open, and are not supported.
|
|
*/
|
|
@implementation CPOpenPanel : CPPanel
|
|
{
|
|
BOOL _canChooseFiles @accessors(property=canChooseFiles);
|
|
BOOL _canChooseDirectories @accessors(property=canChooseDirectories);
|
|
BOOL _allowsMultipleSelection @accessors(property=allowsMultipleSelection);
|
|
CPURL _directoryURL @accessors(property=directoryURL);
|
|
CPArray _allowedFileTypes @accessors(property=allowedFileTypes);
|
|
CPString _message @accessors(property=message);
|
|
CPString _prompt @accessors(property=prompt);
|
|
CPView _accessoryView @accessors(property=accessoryView);
|
|
BOOL _accessoryViewDisclosed @accessors(getter=isAccessoryViewDisclosed, setter=setAccessoryViewDisclosed:);
|
|
|
|
CPArray _URLs;
|
|
CPArray _itemProviders;
|
|
|
|
Function _completionHandler;
|
|
CPPanel _panelWindow;
|
|
CPButton _optionsButton;
|
|
}
|
|
|
|
/*!
|
|
Returns a new open panel.
|
|
*/
|
|
+ (id)openPanel
|
|
{
|
|
return [[CPOpenPanel alloc] init];
|
|
}
|
|
|
|
/*!
|
|
Returns YES if a file's name or MIME type matches one of the file types. A file type that
|
|
contains a slash is a MIME type, or a wildcard such as "image/*". Any other file type is a
|
|
file name extension, with or without its leading dot. An empty list matches every file.
|
|
@ignore
|
|
*/
|
|
+ (BOOL)_fileName:(CPString)aFileName typeIdentifiers:(CPArray)someTypeIdentifiers matchesFileTypes:(CPArray)someFileTypes
|
|
{
|
|
if (![someFileTypes count])
|
|
return YES;
|
|
|
|
var dotIndex = aFileName ? aFileName.lastIndexOf(".") : -1,
|
|
extension = dotIndex >= 0 ? aFileName.substring(dotIndex + 1).toLowerCase() : nil;
|
|
|
|
for (var i = 0, count = [someFileTypes count]; i < count; i++)
|
|
{
|
|
var fileType = someFileTypes[i];
|
|
|
|
if (!fileType)
|
|
continue;
|
|
|
|
if (fileType.indexOf("/") >= 0)
|
|
{
|
|
for (var j = 0, typeCount = [someTypeIdentifiers count]; j < typeCount; j++)
|
|
if ([CPItemProvider _typeIdentifier:someTypeIdentifiers[j] conformsToTypeIdentifier:fileType])
|
|
return YES;
|
|
}
|
|
else if (extension && extension === fileType.replace(/^\./, "").toLowerCase())
|
|
return YES;
|
|
}
|
|
|
|
return NO;
|
|
}
|
|
|
|
/*!
|
|
Returns a URL that holds a file's name, or its path inside a chosen folder, with each path
|
|
component percent-encoded.
|
|
@ignore
|
|
*/
|
|
+ (CPURL)_URLForSuggestedName:(CPString)aName
|
|
{
|
|
var components = (aName || @"").split("/");
|
|
|
|
for (var i = 0; i < components.length; i++)
|
|
components[i] = encodeURIComponent(components[i]);
|
|
|
|
return [CPURL URLWithString:components.join("/")];
|
|
}
|
|
|
|
/*!
|
|
Initializes an open panel. It lets the user choose one file, of any type.
|
|
*/
|
|
- (id)init
|
|
{
|
|
if (self = [super init])
|
|
{
|
|
_canChooseFiles = YES;
|
|
_canChooseDirectories = NO;
|
|
_allowsMultipleSelection = NO;
|
|
_accessoryViewDisclosed = NO;
|
|
_URLs = [];
|
|
_itemProviders = [];
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
// MARK: Results
|
|
|
|
/*!
|
|
Returns a URL for each chosen file. A browser gives no path to a file, so each URL holds
|
|
only the file's name, or its path inside a chosen folder. Use -itemProviders to load the
|
|
files' contents.
|
|
*/
|
|
- (CPArray)URLs
|
|
{
|
|
return [_URLs copy];
|
|
}
|
|
|
|
/*!
|
|
Returns the URL of the first chosen file, or nil.
|
|
*/
|
|
- (CPURL)URL
|
|
{
|
|
return [_URLs firstObject];
|
|
}
|
|
|
|
/*!
|
|
Returns an item provider for each chosen file, in the same order as -URLs. Each item
|
|
provider loads its file's contents asynchronously.
|
|
*/
|
|
- (CPArray)itemProviders
|
|
{
|
|
return [_itemProviders copy];
|
|
}
|
|
|
|
// MARK: Running the Panel
|
|
|
|
/*!
|
|
Raises an exception. A browser cannot block while its file dialog is open. Use
|
|
-beginWithCompletionHandler: or -beginSheetModalForWindow:completionHandler:.
|
|
*/
|
|
- (CPInteger)runModal
|
|
{
|
|
[CPException raise:CPUnsupportedMethodException
|
|
reason:@"-[CPOpenPanel runModal] is not available, because a browser cannot block while its file dialog is open. Use -beginWithCompletionHandler: or -beginSheetModalForWindow:completionHandler:."];
|
|
}
|
|
|
|
/*!
|
|
Runs the panel as a modal panel. When the user chooses files or cancels, \c aHandler is
|
|
called with CPOKButton or CPCancelButton. This method must be called during a user action,
|
|
such as from an action method.
|
|
|
|
@param aHandler a function that takes the response
|
|
*/
|
|
- (void)beginWithCompletionHandler:(Function /*(CPInteger response)*/)aHandler
|
|
{
|
|
[self _beginForWindow:nil completionHandler:aHandler];
|
|
}
|
|
|
|
/*!
|
|
Runs the panel as a sheet on \c aWindow. When the user chooses files or cancels, \c aHandler
|
|
is called with CPOKButton or CPCancelButton. This method must be called during a user
|
|
action, such as from an action method.
|
|
|
|
@param aWindow the window the sheet is attached to
|
|
@param aHandler a function that takes the response
|
|
*/
|
|
- (void)beginSheetModalForWindow:(CPWindow)aWindow completionHandler:(Function /*(CPInteger response)*/)aHandler
|
|
{
|
|
[self _beginForWindow:aWindow completionHandler:aHandler];
|
|
}
|
|
|
|
/*!
|
|
@ignore
|
|
*/
|
|
- (void)_beginForWindow:(CPWindow)aWindow completionHandler:(Function)aHandler
|
|
{
|
|
_completionHandler = aHandler;
|
|
_URLs = [];
|
|
_itemProviders = [];
|
|
|
|
if (!_message && !_accessoryView)
|
|
{
|
|
[self _runFileDialog];
|
|
return;
|
|
}
|
|
|
|
[self _createPanelWindowForSheet:!!aWindow];
|
|
|
|
if (aWindow)
|
|
[CPApp beginSheet:_panelWindow modalForWindow:aWindow modalDelegate:self didEndSelector:@selector(_sheetDidEnd:returnCode:contextInfo:) contextInfo:nil];
|
|
else
|
|
{
|
|
[_panelWindow center];
|
|
[CPApp runModalForWindow:_panelWindow];
|
|
}
|
|
}
|
|
|
|
// MARK: The Panel Window
|
|
|
|
/*!
|
|
Makes the visible panel: the message, the accessory view, or an Options button that shows
|
|
it, and the Cancel and Open buttons.
|
|
@ignore
|
|
*/
|
|
- (void)_createPanelWindowForSheet:(BOOL)isSheet
|
|
{
|
|
var styleMask = isSheet ? CPDocModalWindowMask : CPTitledWindowMask;
|
|
|
|
_panelWindow = [[CPPanel alloc] initWithContentRect:CGRectMake(0.0, 0.0, CPOpenPanelContentWidth, 100.0) styleMask:styleMask];
|
|
[_panelWindow setTitle:[self title] || @"Open"];
|
|
|
|
if ([CPApp keyWindow])
|
|
[_panelWindow setPlatformWindow:[[CPApp keyWindow] platformWindow]];
|
|
|
|
[self _layoutPanelWindow];
|
|
}
|
|
|
|
/*!
|
|
Lays out the visible panel from the top down, and sizes it to fit.
|
|
@ignore
|
|
*/
|
|
- (void)_layoutPanelWindow
|
|
{
|
|
var contentView = [_panelWindow contentView],
|
|
width = MAX(CPOpenPanelContentWidth, _accessoryView ? CGRectGetWidth([_accessoryView frame]) + 2 * CPOpenPanelInset : 0),
|
|
innerWidth = width - 2 * CPOpenPanelInset,
|
|
y = CPOpenPanelInset;
|
|
|
|
[[contentView subviews] makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
|
|
|
if (_message)
|
|
{
|
|
var messageField = [CPTextField labelWithTitle:_message],
|
|
messageHeight = [_message sizeWithFont:[messageField font] inWidth:innerWidth].height;
|
|
|
|
[messageField setLineBreakMode:CPLineBreakByWordWrapping];
|
|
[messageField setFrame:CGRectMake(CPOpenPanelInset, y, innerWidth, messageHeight + 4.0)];
|
|
[contentView addSubview:messageField];
|
|
|
|
y = CGRectGetMaxY([messageField frame]) + CPOpenPanelSpacing;
|
|
}
|
|
|
|
if (_accessoryView)
|
|
{
|
|
if (!_optionsButton)
|
|
{
|
|
_optionsButton = [CPButton buttonWithTitle:@"Options"];
|
|
[_optionsButton setTarget:self];
|
|
[_optionsButton setAction:@selector(_toggleAccessoryView:)];
|
|
}
|
|
|
|
[_optionsButton setTitle:_accessoryViewDisclosed ? @"Hide Options" : @"Options"];
|
|
[_optionsButton sizeToFit];
|
|
[_optionsButton setFrameOrigin:CGPointMake(CPOpenPanelInset, y)];
|
|
[contentView addSubview:_optionsButton];
|
|
|
|
y = CGRectGetMaxY([_optionsButton frame]) + CPOpenPanelSpacing;
|
|
|
|
if (_accessoryViewDisclosed)
|
|
{
|
|
[_accessoryView setFrameOrigin:CGPointMake(FLOOR((width - CGRectGetWidth([_accessoryView frame])) / 2), y)];
|
|
[contentView addSubview:_accessoryView];
|
|
|
|
y = CGRectGetMaxY([_accessoryView frame]) + CPOpenPanelSpacing;
|
|
}
|
|
}
|
|
|
|
var openButton = [CPButton buttonWithTitle:_prompt || @"Open"],
|
|
cancelButton = [CPButton buttonWithTitle:@"Cancel"],
|
|
buttonY = y + CPOpenPanelSpacing;
|
|
|
|
[openButton setFrame:CGRectMake(width - CPOpenPanelInset - CPOpenPanelButtonWidth, buttonY, CPOpenPanelButtonWidth, CPOpenPanelButtonHeight)];
|
|
[openButton setTarget:self];
|
|
[openButton setAction:@selector(_open:)];
|
|
[contentView addSubview:openButton];
|
|
|
|
[cancelButton setFrame:CGRectMake(CGRectGetMinX([openButton frame]) - CPOpenPanelSpacing - CPOpenPanelButtonWidth, buttonY, CPOpenPanelButtonWidth, CPOpenPanelButtonHeight)];
|
|
[cancelButton setTarget:self];
|
|
[cancelButton setAction:@selector(_cancel:)];
|
|
[cancelButton setKeyEquivalent:CPEscapeFunctionKey];
|
|
[contentView addSubview:cancelButton];
|
|
|
|
[_panelWindow setDefaultButton:openButton];
|
|
|
|
var contentSize = CGSizeMake(width, CGRectGetMaxY([openButton frame]) + CPOpenPanelInset),
|
|
frame = [[_panelWindow class] frameRectForContentRect:CGRectMake(0.0, 0.0, contentSize.width, contentSize.height) styleMask:[_panelWindow styleMask]];
|
|
|
|
[_panelWindow setFrameSize:frame.size];
|
|
}
|
|
|
|
/*!
|
|
Shows or hides the accessory view.
|
|
@ignore
|
|
*/
|
|
- (@action)_toggleAccessoryView:(id)aSender
|
|
{
|
|
_accessoryViewDisclosed = !_accessoryViewDisclosed;
|
|
[self _layoutPanelWindow];
|
|
}
|
|
|
|
/*!
|
|
Opens the browser's file dialog. The click on the Open button is the user action the
|
|
browser requires.
|
|
@ignore
|
|
*/
|
|
- (@action)_open:(id)aSender
|
|
{
|
|
[self _runFileDialog];
|
|
}
|
|
|
|
/*!
|
|
@ignore
|
|
*/
|
|
- (@action)_cancel:(id)aSender
|
|
{
|
|
[self _endWithResponse:CPCancelButton];
|
|
}
|
|
|
|
// MARK: The Browser's File Dialog
|
|
|
|
/*!
|
|
@ignore
|
|
*/
|
|
- (void)_runFileDialog
|
|
{
|
|
var choosesDirectory = _canChooseDirectories && !_canChooseFiles;
|
|
|
|
[CPPlatformFilePanel runWithFileTypes:_allowedFileTypes
|
|
allowsMultipleSelection:_allowsMultipleSelection
|
|
choosesDirectory:choosesDirectory
|
|
completionHandler:function(itemProviders)
|
|
{
|
|
[self _fileDialogDidEndWithItemProviders:itemProviders];
|
|
}];
|
|
}
|
|
|
|
/*!
|
|
Handles the result of the browser's file dialog. When the user cancels it, a visible panel
|
|
stays open, so the user may choose again or cancel the panel. Without a visible panel, the
|
|
panel ends.
|
|
@ignore
|
|
*/
|
|
- (void)_fileDialogDidEndWithItemProviders:(CPArray)someItemProviders
|
|
{
|
|
if (![someItemProviders count])
|
|
{
|
|
if (!_panelWindow)
|
|
[self _endWithResponse:CPCancelButton];
|
|
|
|
return;
|
|
}
|
|
|
|
var acceptedItemProviders = [],
|
|
rejectedNames = [];
|
|
|
|
for (var i = 0, count = [someItemProviders count]; i < count; i++)
|
|
{
|
|
var itemProvider = someItemProviders[i];
|
|
|
|
if ([CPOpenPanel _fileName:[itemProvider suggestedName] typeIdentifiers:[itemProvider registeredTypeIdentifiers] matchesFileTypes:_allowedFileTypes])
|
|
acceptedItemProviders.push(itemProvider);
|
|
else
|
|
rejectedNames.push([itemProvider suggestedName]);
|
|
}
|
|
|
|
if ([rejectedNames count])
|
|
{
|
|
[self _showRejectionOfNames:rejectedNames thenAcceptItemProviders:acceptedItemProviders];
|
|
return;
|
|
}
|
|
|
|
[self _validateAndAcceptItemProviders:acceptedItemProviders];
|
|
}
|
|
|
|
/*!
|
|
Tells the user which files were removed because their type is not allowed, then goes on
|
|
with the other files.
|
|
@ignore
|
|
*/
|
|
- (void)_showRejectionOfNames:(CPArray)someNames thenAcceptItemProviders:(CPArray)someItemProviders
|
|
{
|
|
var alert = [CPAlert alertWithError:@"These files cannot be opened here: " + someNames.join(", ")];
|
|
|
|
[alert setInformativeText:@"Choose files of these types: " + _allowedFileTypes.join(", ")];
|
|
[alert runModalWithDidEndBlock:function(anAlert, aReturnCode)
|
|
{
|
|
[self _validateAndAcceptItemProviders:someItemProviders];
|
|
}];
|
|
}
|
|
|
|
/*!
|
|
Asks the delegate to validate each file. If every file is accepted, the panel ends with
|
|
CPOKButton. If the delegate rejects a file, the user is told why.
|
|
@ignore
|
|
*/
|
|
- (void)_validateAndAcceptItemProviders:(CPArray)someItemProviders
|
|
{
|
|
if (![someItemProviders count])
|
|
{
|
|
if (!_panelWindow)
|
|
[self _endWithResponse:CPCancelButton];
|
|
|
|
return;
|
|
}
|
|
|
|
var URLs = [];
|
|
|
|
for (var i = 0, count = [someItemProviders count]; i < count; i++)
|
|
{
|
|
var URL = [CPOpenPanel _URLForSuggestedName:[someItemProviders[i] suggestedName]],
|
|
error = nil;
|
|
|
|
if ([_delegate respondsToSelector:@selector(panel:validateURL:error:)] && ![_delegate panel:self validateURL:URL error:@ref(error)])
|
|
{
|
|
var alert = [CPAlert alertWithError:[error localizedDescription] || @"The file " + [someItemProviders[i] suggestedName] + @" cannot be opened."];
|
|
|
|
[alert runModalWithDidEndBlock:function(anAlert, aReturnCode)
|
|
{
|
|
if (!_panelWindow)
|
|
[self _endWithResponse:CPCancelButton];
|
|
}];
|
|
|
|
return;
|
|
}
|
|
|
|
URLs.push(URL);
|
|
}
|
|
|
|
_URLs = URLs;
|
|
_itemProviders = someItemProviders;
|
|
|
|
[self _endWithResponse:CPOKButton];
|
|
}
|
|
|
|
/*!
|
|
Closes the visible panel, if there is one, and calls the completion handler once.
|
|
@ignore
|
|
*/
|
|
- (void)_endWithResponse:(CPInteger)aResponse
|
|
{
|
|
if (_panelWindow && [_panelWindow isSheet])
|
|
{
|
|
[_panelWindow orderOut:nil];
|
|
[CPApp endSheet:_panelWindow returnCode:aResponse];
|
|
return;
|
|
}
|
|
|
|
if (_panelWindow)
|
|
{
|
|
[CPApp abortModal];
|
|
[_panelWindow close];
|
|
_panelWindow = nil;
|
|
}
|
|
|
|
[self _callCompletionHandlerWithResponse:aResponse];
|
|
}
|
|
|
|
/*!
|
|
@ignore
|
|
*/
|
|
- (void)_sheetDidEnd:(CPWindow)aSheet returnCode:(CPInteger)aResponse contextInfo:(id)contextInfo
|
|
{
|
|
_panelWindow = nil;
|
|
[self _callCompletionHandlerWithResponse:aResponse];
|
|
}
|
|
|
|
/*!
|
|
@ignore
|
|
*/
|
|
- (void)_callCompletionHandlerWithResponse:(CPInteger)aResponse
|
|
{
|
|
var handler = _completionHandler;
|
|
|
|
_completionHandler = nil;
|
|
|
|
if (aResponse !== CPOKButton)
|
|
{
|
|
_URLs = [];
|
|
_itemProviders = [];
|
|
}
|
|
|
|
if (handler)
|
|
handler(aResponse);
|
|
}
|
|
|
|
@end
|