Files
cappuccino/AppKit/Platform/DOM/CPPlatformFilePanel.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

191 lines
6.6 KiB
Plaintext

/*
* CPPlatformFilePanel.j
* AppKit
*
* Created by David Richardson on 2026-11-02.
* Copyright David Richardson 2026.
*
* 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/CPObject.j>
@import <Foundation/CPItemProvider.j>
@import <Foundation/CPRunLoop.j>
@import "CPCompatibility.j"
@global document
var CPPlatformFilePanelPendingRun = nil;
/*
This class runs the browser's file dialog for CPOpenPanel. It isolates the DOM details:
- A page cannot draw or extend the browser's file dialog. It can only open it, through a
hidden file input element.
- The browser opens the dialog only during a user action, such as a click or a key press.
Cappuccino handles events synchronously inside the DOM event handler, so an action method
may open the dialog. A request made later, from a timer or after any asynchronous step, is
refused. The browser gives no error for a refused click(), so showPicker() is used where
it exists, because it reports the refusal.
- The dialog reports its result from outside the run loop: "change" with the chosen files,
or "cancel". The completion handler is called inside a run loop pass.
- The dialog is modal to the page, so only one can be open. A new request ends an earlier
one that never reported a result, as cancelled.
*/
/*
TODO: Browsers that do not send "cancel" to a file input, such as Safari before 16.4 and
Chrome before 113, never report a cancelled dialog. The run then stays pending until the next
run ends it. Decide whether to support them with a fallback, such as watching for the window
to regain focus.
*/
@implementation CPPlatformFilePanel : CPObject
{
}
/*!
Returns the value of the file input's accept attribute for a list of file types. A file
type that contains a slash is a MIME type, or a wildcard such as "image/*", and is used as
it is. Any other file type is a file name extension, with or without its leading dot.
@ignore
*/
+ (CPString)_acceptStringForFileTypes:(CPArray)someFileTypes
{
if (!someFileTypes)
return @"";
var accept = [];
for (var i = 0, count = [someFileTypes count]; i < count; i++)
{
var fileType = someFileTypes[i];
if (!fileType)
continue;
if (fileType.indexOf("/") >= 0 || fileType.charAt(0) === ".")
accept.push(fileType);
else
accept.push("." + fileType);
}
return accept.join(",");
}
/*!
Opens the browser's file dialog. When it closes, \c aCompletionHandler is called with an
array of item providers for the chosen files, in the order the browser gives them, or with
nil when the user cancels.
This method must be called during a user action. Where no DOM is available, the completion
handler is called at once with nil.
@param someFileTypes MIME types, wildcards such as "image/*", or file name extensions. This
is a hint to the dialog: the user may still choose other files.
@param allowsMultipleSelection YES to let the user choose more than one file
@param choosesDirectory YES to let the user choose a folder. The result then holds every
file in the folder, each named by its path inside the folder.
@param aCompletionHandler a function that takes an array of item providers, or nil
*/
+ (void)runWithFileTypes:(CPArray)someFileTypes allowsMultipleSelection:(BOOL)allowsMultipleSelection choosesDirectory:(BOOL)choosesDirectory completionHandler:(Function /*(CPArray itemProviders)*/)aCompletionHandler
{
if (!CPDOMAvailable)
{
aCompletionHandler(nil);
return;
}
if (CPPlatformFilePanelPendingRun)
[self _finishRun:CPPlatformFilePanelPendingRun withFiles:nil];
var input = document.createElement("input"),
style = input.style,
run = { input: input, completionHandler: aCompletionHandler };
input.type = "file";
input.accept = [self _acceptStringForFileTypes:someFileTypes];
input.multiple = !!allowsMultipleSelection;
if (choosesDirectory)
input.webkitdirectory = YES;
/*
The input is placed in the document, outside the visible area, rather than hidden with
display: none. Some browsers do not open the dialog for an input that is not rendered,
and do not send "cancel" to an input that is not in the document.
*/
style.position = "absolute";
style.left = "-10000px";
style.top = "0px";
style.width = "1px";
style.height = "1px";
style.opacity = "0";
input.addEventListener("change", function()
{
[CPPlatformFilePanel _finishRun:run withFiles:input.files];
});
input.addEventListener("cancel", function()
{
[CPPlatformFilePanel _finishRun:run withFiles:nil];
});
CPPlatformFilePanelPendingRun = run;
document.body.appendChild(input);
if (typeof input.showPicker === "function")
{
try
{
input.showPicker();
}
catch (anException)
{
CPLog.warn("CPOpenPanel: the browser refused to open its file dialog. It must be opened during a user action, such as a click. " + anException);
[self _finishRun:run withFiles:nil];
}
}
else
input.click();
}
/*!
Ends a run of the file dialog, and calls its completion handler with item providers for the
files, or with nil when there are none. A run ends only once.
@ignore
*/
+ (void)_finishRun:(JSObject)aRun withFiles:(FileList)someFiles
{
if (aRun.isFinished)
return;
aRun.isFinished = YES;
if (CPPlatformFilePanelPendingRun === aRun)
CPPlatformFilePanelPendingRun = nil;
if (aRun.input.parentNode)
aRun.input.parentNode.removeChild(aRun.input);
var itemProviders = someFiles && someFiles.length ? [CPItemProvider _itemProvidersWithFiles:someFiles] : nil;
aRun.completionHandler(itemProviders);
[[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode];
}
@end