webapi: add DataTransfer, DataTransferItem, DataTransferItemList

Implements the constructible DataTransfer interface and its item views over a
single drag-data-store: string- and file-kind items, with .files (FileList) and
.types derived from the file items so items.add(file) is visible immediately.
File refs are acquired on add and released on remove/clear; the backing FileList
is frame-tracked and the DataTransfer arena is refcounted so the GC finalizer
reclaims it, leaving no leaks.

Refs #2043
This commit is contained in:
Navid EMAD
2026-06-08 22:46:59 +02:00
parent 6d8c4e9ca6
commit 42ecdf4d02
5 changed files with 614 additions and 0 deletions

View File

@@ -980,6 +980,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/File.zig"),
@import("../webapi/FileList.zig"),
@import("../webapi/FileReader.zig"),
@import("../webapi/DataTransfer.zig"),
@import("../webapi/Screen.zig"),
@import("../webapi/VisualViewport.zig"),
@import("../webapi/PerformanceObserver.zig"),

View File

@@ -0,0 +1,128 @@
<!DOCTYPE html>
<head>
<title>Test DataTransfer Web API</title>
<script src="./testing.js"></script>
</head>
<script id=construction>
{
const dt = new DataTransfer();
testing.expectEqual(true, dt instanceof DataTransfer);
testing.expectEqual(0, dt.items.length);
testing.expectEqual(0, dt.files.length);
testing.expectEqual("none", dt.dropEffect);
testing.expectEqual("uninitialized", dt.effectAllowed);
testing.expectEqual(true, dt.items instanceof DataTransferItemList);
testing.expectEqual(true, dt.files instanceof FileList);
}
</script>
<script id=effects>
{
const dt = new DataTransfer();
dt.dropEffect = "copy";
testing.expectEqual("copy", dt.dropEffect);
dt.dropEffect = "bogus"; // invalid ignored
testing.expectEqual("copy", dt.dropEffect);
dt.effectAllowed = "copyMove";
testing.expectEqual("copyMove", dt.effectAllowed);
dt.effectAllowed = "nope"; // invalid ignored
testing.expectEqual("copyMove", dt.effectAllowed);
}
</script>
<script id=string_data>
{
const dt = new DataTransfer();
dt.setData("text/plain", "hello");
testing.expectEqual("hello", dt.getData("text/plain"));
// "text" normalizes to "text/plain"
testing.expectEqual("hello", dt.getData("text"));
// setData via the "text" alias overwrites the same item
dt.setData("text", "world");
testing.expectEqual("world", dt.getData("text/plain"));
testing.expectEqual(1, dt.items.length);
// "url" normalizes to "text/uri-list"
dt.setData("url", "https://example.com");
testing.expectEqual("https://example.com", dt.getData("text/uri-list"));
testing.expectEqual(true, dt.types.indexOf("text/plain") !== -1);
testing.expectEqual(true, dt.types.indexOf("text/uri-list") !== -1);
// getData miss returns ""
testing.expectEqual("", dt.getData("application/json"));
}
</script>
<script id=items_add_file>
{
const dt = new DataTransfer();
const f = new File(["abc"], "a.txt", { type: "text/plain" });
const item = dt.items.add(f);
testing.expectEqual("file", item.kind);
testing.expectEqual("text/plain", item.type);
// file is reflected in .files immediately
testing.expectEqual(1, dt.files.length);
testing.expectEqual("a.txt", dt.files[0].name);
testing.expectEqual(true, dt.files[0] instanceof File);
// "Files" appears in types when a file item exists
testing.expectEqual(true, dt.types.indexOf("Files") !== -1);
testing.expectEqual(f, item.getAsFile());
}
</script>
<script id=items_add_string>
{
const dt = new DataTransfer();
const item = dt.items.add("payload", "text/custom");
testing.expectEqual("string", item.kind);
testing.expectEqual("text/custom", item.type);
testing.expectEqual("payload", dt.getData("text/custom"));
testing.expectEqual(null, item.getAsFile());
}
</script>
<script id=get_as_string>
{
const dt = new DataTransfer();
dt.setData("text/plain", "world");
let got = null;
dt.items[0].getAsString(function (s) { got = s; });
testing.expectEqual("world", got);
}
</script>
<script id=remove_and_clear>
{
const dt = new DataTransfer();
dt.setData("text/plain", "s");
dt.items.add(new File(["x"], "f.txt", { type: "text/plain" }));
testing.expectEqual(2, dt.items.length);
testing.expectEqual(1, dt.files.length);
// remove the string item (index 0)
dt.items.remove(0);
testing.expectEqual(1, dt.items.length);
testing.expectEqual("", dt.getData("text/plain"));
testing.expectEqual(1, dt.files.length);
// clearData() drops string items, keeps files
dt.setData("text/plain", "again");
dt.clearData();
testing.expectEqual("", dt.getData("text/plain"));
testing.expectEqual(1, dt.files.length);
// items.clear() drops everything
dt.items.clear();
testing.expectEqual(0, dt.items.length);
testing.expectEqual(0, dt.files.length);
}
</script>
<script id=iterate_items>
{
const dt = new DataTransfer();
dt.items.add("a", "text/a");
dt.items.add("b", "text/b");
const kinds = [];
for (const it of dt.items) {
kinds.push(it.type);
}
testing.expectEqual("text/a,text/b", kinds.join(","));
}
</script>

View File

@@ -0,0 +1,291 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const Page = @import("../Page.zig");
const File = @import("File.zig");
const FileList = @import("FileList.zig");
const DataTransferItem = @import("DataTransferItem.zig");
const DataTransferItemList = @import("DataTransferItemList.zig");
const Allocator = std.mem.Allocator;
// https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface
//
// The canonical drag-data-store: one ordered list of items (string- or
// file-kind). `.items` is a live DataTransferItemList view; `.files` is a
// FileList rebuilt from the file-kind items so the two stay in sync. Per v1
// scope the store is always read/write (no event-phase mode gating).
const DataTransfer = @This();
pub fn registerTypes() []const type {
return &.{
DataTransfer,
DataTransferItem,
DataTransferItemList,
DataTransferItemList.Iterator,
};
}
_arena: Allocator,
// Refcounted so the GC weak-finalizer (or page teardown) releases the pooled
// arena exactly once; mirrors Blob's lifecycle.
_rc: lp.RC(u32) = .{},
_items: std.ArrayList(*DataTransferItem) = .{},
_item_list: *DataTransferItemList,
// FileList lives on the factory slab and is frame-tracked, so each File ref it
// holds is released at frame teardown (same path as `<input type=file>`).
_files: *FileList,
_drop_effect: []const u8 = "none",
_effect_allowed: []const u8 = "uninitialized",
pub fn init(frame: *Frame) !*DataTransfer {
const arena = try frame.getArena(.medium, "DataTransfer");
errdefer frame.releaseArena(arena);
const fl = try frame._factory.create(FileList{});
try frame.trackFileList(fl);
const self = try arena.create(DataTransfer);
const list = try arena.create(DataTransferItemList);
self.* = .{
._arena = arena,
._item_list = list,
._files = fl,
};
list.* = .{ ._data_transfer = self };
return self;
}
pub fn deinit(self: *DataTransfer, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn acquireRef(self: *DataTransfer) void {
self._rc.acquire();
}
pub fn releaseRef(self: *DataTransfer, page: *Page) void {
self._rc.release(self, page);
}
// https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransfer-getdata
// "text" and "url" are shorthands the spec maps onto MIME types.
fn normalizeFormat(arena: Allocator, format: []const u8) ![]const u8 {
if (std.ascii.eqlIgnoreCase(format, "text")) {
return "text/plain";
}
if (std.ascii.eqlIgnoreCase(format, "url")) {
return "text/uri-list";
}
const buf = try arena.dupe(u8, format);
return std.ascii.lowerString(buf, buf);
}
pub fn getData(self: *const DataTransfer, format: []const u8, frame: *Frame) ![]const u8 {
const norm = try normalizeFormat(frame.call_arena, format);
for (self._items.items) |it| {
if (it._kind == .string and std.mem.eql(u8, it._type, norm)) {
return it._payload.string;
}
}
return "";
}
pub fn setData(self: *DataTransfer, format: []const u8, data: []const u8) !void {
const norm = try normalizeFormat(self._arena, format);
const dup = try self._arena.dupe(u8, data);
for (self._items.items) |it| {
if (it._kind == .string and std.mem.eql(u8, it._type, norm)) {
it._payload = .{ .string = dup };
return;
}
}
const it = try self._arena.create(DataTransferItem);
it.* = .{ ._kind = .string, ._type = norm, ._payload = .{ .string = dup } };
try self._items.append(self._arena, it);
}
pub fn clearData(self: *DataTransfer, format_: ?[]const u8, frame: *Frame) !void {
if (format_) |format| {
const norm = try normalizeFormat(frame.call_arena, format);
var i: usize = 0;
while (i < self._items.items.len) {
const it = self._items.items[i];
if (it._kind == .string and std.mem.eql(u8, it._type, norm)) {
_ = self._items.orderedRemove(i);
} else {
i += 1;
}
}
return;
}
// No format: remove every string item, leave file items in place.
var i: usize = 0;
while (i < self._items.items.len) {
if (self._items.items[i]._kind == .string) {
_ = self._items.orderedRemove(i);
} else {
i += 1;
}
}
}
// --- DataTransferItemList delegation ---
// add(File) -> file item ; add(DOMString, DOMString) -> string item.
pub fn addItem(self: *DataTransfer, data: js.Value, type_: ?[]const u8, frame: *Frame) !?*DataTransferItem {
if (data.toZig(*File)) |file| {
return try self.addFileItem(file, frame);
} else |_| {}
const s = try data.toZig([]const u8);
const norm = try normalizeFormat(self._arena, type_ orelse "");
const dup = try self._arena.dupe(u8, s);
const it = try self._arena.create(DataTransferItem);
it.* = .{ ._kind = .string, ._type = norm, ._payload = .{ .string = dup } };
try self._items.append(self._arena, it);
return it;
}
fn addFileItem(self: *DataTransfer, file: *File, frame: *Frame) !*DataTransferItem {
file._proto.acquireRef();
const it = try self._arena.create(DataTransferItem);
it.* = .{ ._kind = .file, ._type = file._proto.getType(), ._payload = .{ .file = file } };
try self._items.append(self._arena, it);
try self.rebuildFiles(frame);
return it;
}
pub fn removeItem(self: *DataTransfer, index: u32, frame: *Frame) !void {
if (index >= self._items.items.len) {
return;
}
const it = self._items.orderedRemove(index);
if (it._kind == .file) {
it._payload.file._proto.releaseRef(frame._page);
try self.rebuildFiles(frame);
}
}
pub fn clearItems(self: *DataTransfer, frame: *Frame) !void {
for (self._items.items) |it| {
if (it._kind == .file) {
it._payload.file._proto.releaseRef(frame._page);
}
}
self._items.clearRetainingCapacity();
try self.rebuildFiles(frame);
}
// Rebuild the FileList slice from the current file-kind items, in order.
fn rebuildFiles(self: *DataTransfer, frame: *Frame) !void {
var files: std.ArrayList(*File) = .{};
for (self._items.items) |it| {
if (it._kind == .file) {
try files.append(frame.arena, it._payload.file);
}
}
self._files._files = try files.toOwnedSlice(frame.arena);
}
// --- accessors ---
pub fn getFiles(self: *DataTransfer) *FileList {
return self._files;
}
pub fn getItems(self: *DataTransfer) *DataTransferItemList {
return self._item_list;
}
pub fn getTypes(self: *DataTransfer, frame: *Frame) ![][]const u8 {
var out: std.ArrayList([]const u8) = .{};
var has_files = false;
for (self._items.items) |it| {
switch (it._kind) {
.string => try out.append(frame.call_arena, it._type),
.file => has_files = true,
}
}
if (has_files) {
try out.append(frame.call_arena, "Files");
}
return out.toOwnedSlice(frame.call_arena);
}
pub fn getDropEffect(self: *const DataTransfer) []const u8 {
return self._drop_effect;
}
pub fn setDropEffect(self: *DataTransfer, value: []const u8) !void {
inline for (.{ "none", "copy", "link", "move" }) |valid| {
if (std.mem.eql(u8, value, valid)) {
self._drop_effect = valid;
return;
}
}
}
pub fn getEffectAllowed(self: *const DataTransfer) []const u8 {
return self._effect_allowed;
}
pub fn setEffectAllowed(self: *DataTransfer, value: []const u8) !void {
inline for (.{ "none", "copy", "copyLink", "copyMove", "link", "linkMove", "move", "all", "uninitialized" }) |valid| {
if (std.mem.eql(u8, value, valid)) {
self._effect_allowed = valid;
return;
}
}
}
// https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransfer-setdragimage
// No-op: Lightpanda has no rendered drag feedback.
pub fn setDragImage(_: *DataTransfer, _: js.Value, _: i32, _: i32) void {}
pub const JsApi = struct {
pub const bridge = js.Bridge(DataTransfer);
pub const Meta = struct {
pub const name = "DataTransfer";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(DataTransfer.init, .{});
pub const dropEffect = bridge.accessor(DataTransfer.getDropEffect, DataTransfer.setDropEffect, .{});
pub const effectAllowed = bridge.accessor(DataTransfer.getEffectAllowed, DataTransfer.setEffectAllowed, .{});
pub const files = bridge.accessor(DataTransfer.getFiles, null, .{});
pub const items = bridge.accessor(DataTransfer.getItems, null, .{});
pub const types = bridge.accessor(DataTransfer.getTypes, null, .{});
pub const getData = bridge.function(DataTransfer.getData, .{});
pub const setData = bridge.function(DataTransfer.setData, .{});
pub const clearData = bridge.function(DataTransfer.clearData, .{});
pub const setDragImage = bridge.function(DataTransfer.setDragImage, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: DataTransfer" {
try testing.htmlRunner("data_transfer.html", .{});
}

View File

@@ -0,0 +1,90 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const File = @import("File.zig");
const log = lp.log;
// https://html.spec.whatwg.org/multipage/dnd.html#the-datatransferitem-interface
const DataTransferItem = @This();
pub const Kind = enum { string, file };
_kind: Kind,
// For string items: the normalized format (e.g. "text/plain").
// For file items: the File's MIME type.
_type: []const u8,
_payload: Payload,
pub const Payload = union(Kind) {
string: []const u8,
file: *File,
};
pub fn getKind(self: *const DataTransferItem) []const u8 {
return switch (self._kind) {
.string => "string",
.file => "file",
};
}
pub fn getType(self: *const DataTransferItem) []const u8 {
return self._type;
}
pub fn getAsFile(self: *const DataTransferItem) ?*File {
return switch (self._payload) {
.file => |f| f,
.string => null,
};
}
// https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransferitem-getasstring
// v1 invokes the callback synchronously with the string value. File items and a
// missing callback are no-ops, per spec.
pub fn getAsString(self: *const DataTransferItem, cb_: ?js.Function) !void {
const cb = cb_ orelse return;
const s = switch (self._payload) {
.string => |str| str,
.file => return,
};
var caught: js.TryCatch.Caught = undefined;
cb.tryCall(void, .{s}, &caught) catch {
log.debug(.js, "getAsString callback", .{ .caught = caught, .source = "DataTransferItem" });
};
}
pub const JsApi = struct {
pub const bridge = js.Bridge(DataTransferItem);
pub const Meta = struct {
pub const name = "DataTransferItem";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const kind = bridge.accessor(DataTransferItem.getKind, null, .{});
pub const @"type" = bridge.accessor(DataTransferItem.getType, null, .{});
pub const getAsFile = bridge.function(DataTransferItem.getAsFile, .{});
pub const getAsString = bridge.function(DataTransferItem.getAsString, .{});
};

View File

@@ -0,0 +1,104 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const DataTransfer = @import("DataTransfer.zig");
const DataTransferItem = @import("DataTransferItem.zig");
// https://html.spec.whatwg.org/multipage/dnd.html#the-datatransferitemlist-interface
//
// A live view over the owning DataTransfer's item list; all mutations are
// delegated to the DataTransfer so `.files` stays in sync.
const DataTransferItemList = @This();
_data_transfer: *DataTransfer,
pub fn getLength(self: *const DataTransferItemList) u32 {
return @intCast(self._data_transfer._items.items.len);
}
pub fn item(self: *const DataTransferItemList, index: u32) ?*DataTransferItem {
const items = self._data_transfer._items.items;
if (index >= items.len) {
return null;
}
return items[index];
}
// add(DOMString data, DOMString type) | add(File data)
// The overload is resolved by inspecting the first argument: a File yields a
// file item (the `type` argument is ignored), anything else a string item.
pub fn add(self: *DataTransferItemList, data: js.Value, type_: ?[]const u8, frame: *Frame) !?*DataTransferItem {
return self._data_transfer.addItem(data, type_, frame);
}
pub fn remove(self: *DataTransferItemList, index: u32, frame: *Frame) !void {
return self._data_transfer.removeItem(index, frame);
}
pub fn clear(self: *DataTransferItemList, frame: *Frame) !void {
return self._data_transfer.clearItems(frame);
}
pub fn iterator(self: *DataTransferItemList, exec: *const js.Execution) !*Iterator {
return Iterator.init(.{
.index = 0,
.list = self,
}, exec);
}
const GenericIterator = @import("collections/iterator.zig").Entry;
pub const Iterator = GenericIterator(struct {
index: u32,
list: *DataTransferItemList,
pub fn next(self: *@This(), _: *const js.Execution) ?*DataTransferItem {
const index = self.index;
const it = self.list.item(index) orelse return null;
self.index = index + 1;
return it;
}
}, null);
pub const JsApi = struct {
pub const bridge = js.Bridge(DataTransferItemList);
pub const Meta = struct {
pub const name = "DataTransferItemList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const length = bridge.accessor(DataTransferItemList.getLength, null, .{});
pub const add = bridge.function(DataTransferItemList.add, .{});
pub const remove = bridge.function(DataTransferItemList.remove, .{});
pub const clear = bridge.function(DataTransferItemList.clear, .{});
pub const @"[]" = bridge.indexed(DataTransferItemList.item, getIndexes, .{ .null_as_undefined = true });
pub const symbol_iterator = bridge.iterator(DataTransferItemList.iterator, .{});
fn getIndexes(self: *DataTransferItemList, exec: *const js.Execution) !js.Array {
const len = self.getLength();
var arr = exec.js.local.?.newArray(len);
for (0..len) |i| {
_ = try arr.set(@intCast(i), i, .{});
}
return arr;
}
};