Files
cappuccino/AppKit/CPOpenPanel.j
T
David Richardson 5a1845957e AppKit: Implement CPOpenPanel for the browser
* 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.
2026-11-02 14:52:41 -06:00

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