From 42ecdf4d024d4eae9cf652e772001a2a686e87e5 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Mon, 8 Jun 2026 22:46:59 +0200 Subject: [PATCH] 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 --- src/browser/js/bridge.zig | 1 + src/browser/tests/data_transfer.html | 128 +++++++++ src/browser/webapi/DataTransfer.zig | 291 ++++++++++++++++++++ src/browser/webapi/DataTransferItem.zig | 90 ++++++ src/browser/webapi/DataTransferItemList.zig | 104 +++++++ 5 files changed, 614 insertions(+) create mode 100644 src/browser/tests/data_transfer.html create mode 100644 src/browser/webapi/DataTransfer.zig create mode 100644 src/browser/webapi/DataTransferItem.zig create mode 100644 src/browser/webapi/DataTransferItemList.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 6bcf3a3a..4d7ad032 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -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"), diff --git a/src/browser/tests/data_transfer.html b/src/browser/tests/data_transfer.html new file mode 100644 index 00000000..61ea259a --- /dev/null +++ b/src/browser/tests/data_transfer.html @@ -0,0 +1,128 @@ + + + Test DataTransfer Web API + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/DataTransfer.zig b/src/browser/webapi/DataTransfer.zig new file mode 100644 index 00000000..28bc78a6 --- /dev/null +++ b/src/browser/webapi/DataTransfer.zig @@ -0,0 +1,291 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +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 ``). +_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", .{}); +} diff --git a/src/browser/webapi/DataTransferItem.zig b/src/browser/webapi/DataTransferItem.zig new file mode 100644 index 00000000..2ee1eaa8 --- /dev/null +++ b/src/browser/webapi/DataTransferItem.zig @@ -0,0 +1,90 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +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, .{}); +}; diff --git a/src/browser/webapi/DataTransferItemList.zig b/src/browser/webapi/DataTransferItemList.zig new file mode 100644 index 00000000..a69af1ea --- /dev/null +++ b/src/browser/webapi/DataTransferItemList.zig @@ -0,0 +1,104 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +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; + } +};