LibWeb: Implement the ClipboardItem API

Spec: https://w3c.github.io/clipboard-apis/#clipboard-item-interface
This commit is contained in:
Feng Yu 2024-11-30 20:23:38 -08:00 committed by Tim Ledbetter
commit b3edbd7bf2
Notes: github-actions[bot] 2024-12-20 15:30:21 +00:00
12 changed files with 484 additions and 0 deletions

View file

@ -33,6 +33,7 @@ set(SOURCES
Bindings/SyntheticHostDefined.cpp
Clipboard/Clipboard.cpp
Clipboard/ClipboardEvent.cpp
Clipboard/ClipboardItem.cpp
Compression/CompressionStream.cpp
Compression/DecompressionStream.cpp
Crypto/Crypto.cpp

View file

@ -0,0 +1,208 @@
/*
* Copyright (c) 2024, Feng Yu <f3n67u@outlook.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibJS/Runtime/Realm.h>
#include <LibWeb/Bindings/Intrinsics.h>
#include <LibWeb/Clipboard/ClipboardItem.h>
#include <LibWeb/FileAPI/Blob.h>
#include <LibWeb/MimeSniff/MimeType.h>
#include <LibWeb/WebIDL/Promise.h>
namespace Web::Clipboard {
GC_DEFINE_ALLOCATOR(ClipboardItem);
// https://w3c.github.io/clipboard-apis/#dom-clipboarditem-clipboarditem
WebIDL::ExceptionOr<GC::Ref<ClipboardItem>> ClipboardItem::construct_impl(JS::Realm& realm, OrderedHashMap<String, GC::Root<WebIDL::Promise>> const& items, ClipboardItemOptions const& options)
{
// 1. If items is empty, then throw a TypeError.
if (items.is_empty())
return WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, "Items cannot be empty"sv };
// 2. If options is empty, then set options["presentationStyle"] = "unspecified".
// NOTE: This step is handled by presentationStyle's default value in ClipboardItemOptions.
// 3. Set this's clipboard item to a new clipboard item.
auto clipboard_item = realm.create<ClipboardItem>(realm);
// 4. Set this's clipboard item's presentation style to options["presentationStyle"].
clipboard_item->m_presentation_style = options.presentation_style;
// 5. Let types be a list of DOMString.
Vector<String> types;
// 6. For each (key, value) in items:
for (auto const& [key, value] : items) {
// 2. Let isCustom be false.
bool is_custom = false;
// 3. If key starts with `"web "` prefix, then:
auto key_without_prefix = key;
if (key.starts_with_bytes(WEB_CUSTOM_FORMAT_PREFIX)) {
// 1. Remove `"web "` prefix and assign the remaining string to key.
key_without_prefix = MUST(key.substring_from_byte_offset(WEB_CUSTOM_FORMAT_PREFIX.length()));
// 2. Set isCustom to true.
is_custom = true;
}
// 5. Let mimeType be the result of parsing a MIME type given key.
auto mime_type = MimeSniff::MimeType::parse(key_without_prefix);
// 6. If mimeType is failure, then throw a TypeError.
if (!mime_type.has_value()) {
return WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, MUST(String::formatted("Invalid MIME type: {}", key)) };
}
auto mime_type_serialized = mime_type->serialized();
// 7. If this's clipboard item's list of representations contains a representation whose MIME type
// is mimeType and whose [representation/isCustom] is isCustom, then throw a TypeError.
auto existing = clipboard_item->m_representations.find_if([&](auto const& item) {
return item.mime_type == mime_type_serialized && item.is_custom == is_custom;
});
if (!existing.is_end()) {
return WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, MUST(String::formatted("Duplicate MIME type: {}", key)) };
}
// 11. Let mimeTypeString be the result of serializing a MIME type with mimeType.
// 12. If isCustom is true, prefix mimeTypeString with `"web "`.
auto mime_type_string = is_custom ? MUST(String::formatted("{}{}", WEB_CUSTOM_FORMAT_PREFIX, mime_type_serialized)) : mime_type_serialized;
// 13. Add mimeTypeString to types.
types.append(move(mime_type_string));
// 1. Let representation be a new representation.
// 4. Set representations isCustom flag to isCustom.
// 8. Set representations MIME type to mimeType.
// 9. Set representations data to value.
// 10. Append representation to this's clipboard item's list of representations.
clipboard_item->m_representations.empend(move(mime_type_serialized), is_custom, *value);
}
// 7. Set this's types array to the result of running create a frozen array from types.
clipboard_item->m_types = types;
return clipboard_item;
}
// https://w3c.github.io/clipboard-apis/#dom-clipboarditem-gettype
WebIDL::ExceptionOr<GC::Ref<WebIDL::Promise>> ClipboardItem::get_type(String const& type)
{
// 1. Let realm be this's relevant realm.
auto& realm = HTML::relevant_realm(*this);
// 2. Let isCustom be false.
bool is_custom = false;
// 3. If type starts with `"web "` prefix, then:
auto type_without_prefix = type;
if (type.starts_with_bytes(WEB_CUSTOM_FORMAT_PREFIX)) {
// 1. Remove `"web "` prefix and assign the remaining string to type.
type_without_prefix = MUST(type.substring_from_byte_offset(WEB_CUSTOM_FORMAT_PREFIX.length()));
// 2. Set isCustom to true.
is_custom = true;
}
// 4. Let mimeType be the result of parsing a MIME type given type.
auto mime_type = MimeSniff::MimeType::parse(type_without_prefix);
// 5. If mimeType is failure, then throw a TypeError.
if (!mime_type.has_value()) {
return WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, MUST(String::formatted("Invalid MIME type: {}", type)) };
}
auto mime_type_serialized = mime_type->serialized();
// 6. Let itemTypeList be this's clipboard item's list of representations.
auto const& item_type_list = m_representations;
// 7. Let p be a new promise in realm.
auto promise = WebIDL::create_promise(realm);
// 8. For each representation in itemTypeList:
for (auto const& representation : item_type_list) {
// 1. If representations MIME type is mimeType and representations isCustom is isCustom, then:
if (representation.mime_type == mime_type_serialized && representation.is_custom == is_custom) {
// 1. Let representationDataPromise be the representations data.
auto representation_data_promise = representation.data;
// 2. React to representationDataPromise:
WebIDL::react_to_promise(
*representation_data_promise,
GC::create_function(realm.heap(), [&realm, promise, mime_type_serialized](JS::Value value) -> WebIDL::ExceptionOr<JS::Value> {
// 1. If v is a DOMString, then follow the below steps:
if (value.is_string()) {
// 1. Let dataAsBytes be the result of UTF-8 encoding v.
auto utf8_string = value.as_string().utf8_string();
auto data_as_bytes = MUST(ByteBuffer::copy(utf8_string.bytes()));
// 2. Let blobData be a Blob created using dataAsBytes with its type set to mimeType, serialized.
auto blob_data = FileAPI::Blob::create(realm, data_as_bytes, mime_type_serialized);
// 3. Resolve p with blobData.
WebIDL::resolve_promise(realm, promise, blob_data);
}
// 2. If v is a Blob, then follow the below steps:
if (value.is_object() && is<FileAPI::Blob>(value.as_object())) {
// 1. Resolve p with v.
WebIDL::resolve_promise(realm, promise, value);
}
return JS::js_undefined();
}),
// 2. If representationDataPromise was rejected, then:
GC::create_function(realm.heap(), [&realm, type, promise](JS::Value) -> WebIDL::ExceptionOr<JS::Value> {
// 1. Reject p with "NotFoundError" DOMException in realm.
WebIDL::reject_promise(realm, promise, WebIDL::NotFoundError::create(realm, MUST(String::formatted("No data found for MIME type: {}", type))));
return JS::js_undefined();
}));
// 3. Return p.
return promise;
}
}
// 9. Reject p with "NotFoundError" DOMException in realm.
WebIDL::reject_promise(realm, promise, WebIDL::NotFoundError::create(realm, MUST(String::formatted("No data found for MIME type: {}", type))));
// 10. Return p.
return promise;
}
// https://w3c.github.io/clipboard-apis/#dom-clipboarditem-supports
bool ClipboardItem::supports(JS::VM&, String const& type)
{
// 1. If type is in mandatory data types or optional data types, then return true.
// 2. If not, then return false.
// TODO: Implement optional data types, like web custom formats and image/svg+xml.
return any_of(MANDATORY_DATA_TYPES, [&](auto supported) { return supported == type; });
}
ClipboardItem::ClipboardItem(JS::Realm& realm)
: Bindings::PlatformObject(realm)
{
}
ClipboardItem::~ClipboardItem() = default;
void ClipboardItem::initialize(JS::Realm& realm)
{
Base::initialize(realm);
WEB_SET_PROTOTYPE_FOR_INTERFACE(ClipboardItem);
}
void ClipboardItem::visit_edges(Cell::Visitor& visitor)
{
Base::visit_edges(visitor);
for (auto& representation : m_representations) {
visitor.visit(representation.data);
}
}
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2024, Feng Yu <f3n67u@outlook.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <LibGC/Ptr.h>
#include <LibJS/Runtime/PromiseCapability.h>
#include <LibWeb/Bindings/ClipboardItemPrototype.h>
#include <LibWeb/DOM/Event.h>
#include <LibWeb/Forward.h>
#include <LibWeb/HTML/DataTransfer.h>
#include <LibWeb/MimeSniff/MimeType.h>
namespace Web::Clipboard {
constexpr auto WEB_CUSTOM_FORMAT_PREFIX = "web "sv;
inline constexpr Array MANDATORY_DATA_TYPES = {
"text/plain"sv, "text/html"sv, "image/png"sv
};
struct ClipboardItemOptions {
Bindings::PresentationStyle presentation_style { Bindings::PresentationStyle::Unspecified };
};
// https://w3c.github.io/clipboard-apis/#clipboard-item-interface
class ClipboardItem : public Bindings::PlatformObject {
WEB_PLATFORM_OBJECT(ClipboardItem, Bindings::PlatformObject);
GC_DECLARE_ALLOCATOR(ClipboardItem);
public:
struct Representation {
String mime_type; // The MIME type (e.g., "text/plain").
bool is_custom; // Whether this is a web custom format.
GC::Ref<WebIDL::Promise> data; // The actual data for this representation.
};
static WebIDL::ExceptionOr<GC::Ref<ClipboardItem>> construct_impl(JS::Realm&, OrderedHashMap<String, GC::Root<WebIDL::Promise>> const& items, ClipboardItemOptions const& options = {});
virtual ~ClipboardItem() override;
Bindings::PresentationStyle presentation_style() const { return m_presentation_style; }
Vector<String> const& types() const { return m_types; }
WebIDL::ExceptionOr<GC::Ref<WebIDL::Promise>> get_type(String const& type);
static bool supports(JS::VM&, String const& type);
private:
ClipboardItem(JS::Realm&);
virtual void initialize(JS::Realm&) override;
virtual void visit_edges(Cell::Visitor&) override;
Bindings::PresentationStyle m_presentation_style;
Vector<String> m_types;
Vector<Representation> m_representations;
};
}

View file

@ -0,0 +1,22 @@
typedef Promise<(DOMString or Blob)> ClipboardItemData;
// https://w3c.github.io/clipboard-apis/#clipboard-item-interface
[SecureContext, Exposed=Window]
interface ClipboardItem {
constructor(record<DOMString, ClipboardItemData> items,
optional ClipboardItemOptions options = {});
readonly attribute PresentationStyle presentationStyle;
// FIXME: Should be a FrozenArray<DOMString>
readonly attribute sequence<DOMString> types;
Promise<Blob> getType(DOMString type);
static boolean supports(DOMString type);
};
enum PresentationStyle { "unspecified", "inline", "attachment" };
dictionary ClipboardItemOptions {
PresentationStyle presentationStyle = "unspecified";
};

View file

@ -84,6 +84,7 @@ enum class XMLHttpRequestResponseType;
namespace Web::Clipboard {
class Clipboard;
class ClipboardItem;
}
namespace Web::Compression {

View file

@ -9,6 +9,7 @@ libweb_js_bindings(Animations/DocumentTimeline)
libweb_js_bindings(Animations/KeyframeEffect)
libweb_js_bindings(Clipboard/Clipboard)
libweb_js_bindings(Clipboard/ClipboardEvent)
libweb_js_bindings(Clipboard/ClipboardItem)
libweb_js_bindings(Compression/CompressionStream)
libweb_js_bindings(Compression/DecompressionStream)
libweb_js_bindings(Crypto/Crypto)

View file

@ -45,6 +45,7 @@ static bool is_platform_object(Type const& type)
"CanvasGradient"sv,
"CanvasPattern"sv,
"CanvasRenderingContext2D"sv,
"ClipboardItem"sv,
"CloseWatcher"sv,
"CryptoKey"sv,
"DataTransfer"sv,
@ -247,6 +248,9 @@ CppType idl_type_name_to_cpp_type(Type const& type, Interface const& interface)
if (type.name() == "Function")
return { .name = "GC::Ref<WebIDL::CallbackType>", .sequence_storage_type = SequenceStorageType::MarkedVector };
if (type.name() == "Promise")
return { .name = "GC::Root<WebIDL::Promise>", .sequence_storage_type = SequenceStorageType::MarkedVector };
if (type.name() == "sequence") {
auto& parameterized_type = verify_cast<ParameterizedType>(type);
auto& sequence_type = parameterized_type.parameters().first();
@ -4663,6 +4667,7 @@ void generate_constructor_implementation(IDL::Interface const& interface, String
#include <LibJS/Runtime/DataView.h>
#include <LibJS/Runtime/GlobalObject.h>
#include <LibJS/Runtime/Iterator.h>
#include <LibJS/Runtime/PromiseConstructor.h>
#include <LibJS/Runtime/ValueInlines.h>
#include <LibJS/Runtime/TypedArray.h>
#include <LibWeb/Bindings/@constructor_class@.h>

View file

@ -0,0 +1,3 @@
types: text/plain,text/uri-list
getType('text/plain'): hello
getType('text/uri-list'): https://example.com

View file

@ -60,6 +60,7 @@ ChannelMergerNode
CharacterData
Clipboard
ClipboardEvent
ClipboardItem
CloseEvent
CloseWatcher
Comment

View file

@ -0,0 +1,34 @@
Harness status: OK
Found 28 tests
17 Pass
11 Fail
Fail ClipboardItem({string, Blob}) succeeds with different types
Pass ClipboardItem() succeeds with empty options
Pass ClipboardItem({}) fails with empty dictionary input
Pass ClipboardItem(Blob) fails
Pass ClipboardItem() fails with null input
Pass ClipboardItem() fails with no input
Fail types() returns correct values
Fail getType(DOMString valid type) succeeds with correct output
Fail getType(DOMString invalid type) succeeds with correct output
Fail getType(DOMString type) rejects correctly when querying for missing type
Fail getType(DOMString valid type) converts DOMString to Blob
Fail getType(DOMString invalid type) converts DOMString to Blob
Pass supports(text/plain) returns true
Pass supports(text/html) returns true
Pass supports(image/png) returns true
Fail supports(text/uri-list) returns true
Fail supports(image/svg+xml) returns true
Fail supports(web foo/bar) returns true
Fail supports(web text/html) returns true
Pass supports(web ) returns false
Pass supports(web) returns false
Pass supports(web foo) returns false
Pass supports(foo/bar) returns false
Pass supports(weB text/html) returns false
Pass supports( web text/html) returns false
Pass supports(not a/real type) returns false
Pass supports() returns false
Pass supports( ) returns false

View file

@ -0,0 +1,20 @@
<script src="../include.js"></script>
<script type="text/javascript">
asyncTest(async (done) => {
const blob = new Blob(['https://example.com'], {type: 'text/uri-list'});
const item = new ClipboardItem({'text/plain': 'hello', 'text/uri-list': blob});
println(`types: ${item.types}`);
const blobOutput = await item.getType('text/plain');
const text = await (new Response(blobOutput)).text();
println(`getType('text/plain'): ${text}`);
const blobOutput2 = await item.getType('text/uri-list');
const text2 = await (new Response(blobOutput2)).text();
println(`getType('text/uri-list'): ${text2}`);
done();
});
</script>

View file

@ -0,0 +1,124 @@
<!doctype html>
<meta charset="utf-8">
<title>ClipboardItem tests</title>
<link rel="help" href="https://w3c.github.io/clipboard-apis/#async-clipboard-api">
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script>
const blob = new Blob(['hello'], {type: 'text/plain'});
const blob2 = new Blob(['this should work'], {type: 'not a/real type'});
test(() => {
new ClipboardItem({'text/plain': blob});
new ClipboardItem({'text/plain': blob, 'not a/real type': blob2});
}, "ClipboardItem({string, Blob}) succeeds with different types");
test(() => {
new ClipboardItem({'text/plain': blob}, {});
}, "ClipboardItem() succeeds with empty options");
test(() => {
assert_throws_js(TypeError, () => {new ClipboardItem({});});
}, "ClipboardItem({}) fails with empty dictionary input");
test(() => {
assert_throws_js(TypeError, () => {new ClipboardItem(blob);});
}, "ClipboardItem(Blob) fails");
test(() => {
assert_throws_js(TypeError, () => {new ClipboardItem(null);});
}, "ClipboardItem() fails with null input");
test(() => {
assert_throws_js(TypeError, () => {new ClipboardItem();});
}, "ClipboardItem() fails with no input");
test(() => {
const item = new ClipboardItem({'text/plain': blob});
const types = item.types;
assert_equals(types.length, 1);
assert_equals(types[0], 'text/plain');
const item2 =
new ClipboardItem({'text/plain': blob, 'not a/real type': blob2});
const types2 = item2.types;
assert_equals(types2.length, 2);
assert_equals(types2[0], 'text/plain');
assert_equals(types2[1], 'not a/real type');
}, "types() returns correct values");
promise_test(async () => {
const item =
new ClipboardItem({'text/plain': blob, 'not a/real type': blob2});
const blobOutput = await item.getType('text/plain');
assert_true(blobOutput.type.includes('text/plain'));
const text = await (new Response(blobOutput)).text();
assert_equals('hello', text);
}, "getType(DOMString valid type) succeeds with correct output");
promise_test(async () => {
const item =
new ClipboardItem({'text/plain': blob, 'not a/real type': blob2});
const blobOutput = await item.getType('not a/real type');
assert_true(blobOutput.type.includes('not a/real type'));
const text = await (new Response(blobOutput)).text();
assert_equals('this should work', text);
}, "getType(DOMString invalid type) succeeds with correct output");
promise_test(async t => {
const item =
new ClipboardItem({'text/plain': blob, 'not a/real type': blob2});
promise_rejects_dom(t, "NotFoundError", item.getType('type not in item'));
promise_rejects_dom(t, "NotFoundError", item.getType('text/plain:subtype'));
}, "getType(DOMString type) rejects correctly when querying for missing type");
promise_test(async () => {
const item =
new ClipboardItem({'text/plain': 'abc', 'not a/real type': 'xxx'});
const blob = await item.getType('text/plain');
assert_equals(blob.type, 'text/plain');
const text = await (new Response(blob)).text();
assert_equals(text, 'abc');
}, "getType(DOMString valid type) converts DOMString to Blob");
promise_test(async () => {
const item =
new ClipboardItem({'text/plain': 'abc', 'not a/real type': 'xxx'});
const blob = await item.getType('not a/real type');
assert_equals(blob.type, 'not a/real type');
const text = await (new Response(blob)).text();
assert_equals(text, 'xxx');
}, "getType(DOMString invalid type) converts DOMString to Blob");
[
// mandatory data types
['text/plain', true],
['text/html', true],
['image/png', true],
// optional data types
['text/uri-list', true],
['image/svg+xml', true],
['web foo/bar', true],
['web text/html', true],
// invalid types
['web ', false],
['web', false],
['web foo', false],
['foo/bar', false],
['weB text/html', false],
[' web text/html', false],
['not a/real type', false],
['', false],
[' ', false],
].forEach(([type, result]) => {
promise_test(async () => {
assert_equals(ClipboardItem.supports(type), result);
}, `supports(${type}) returns ${result ? "true" : "false"}`);
});
</script>