mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-07 03:41:34 -04:00
Merge branch 'main' into agent
# Conflicts: # src/mcp/Server.zig
This commit is contained in:
commit
314e3c58ea
37 files changed
+1926
-248
No files matched your search
@@ -176,12 +176,21 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
|
||||
const activation_state = try ActivationState.create(event, target, frame);
|
||||
|
||||
var path_len: usize = 0;
|
||||
var node_path_len: usize = 0;
|
||||
var path_buffer: [128]*EventTarget = undefined;
|
||||
|
||||
// Defer runs even on early return - ensures event phase is reset
|
||||
// and default actions execute (unless prevented)
|
||||
defer {
|
||||
event._event_phase = .none;
|
||||
event._current_target = null;
|
||||
event._stop_propagation = false;
|
||||
event._stop_immediate_propagation = false;
|
||||
if (event._needs_retargeting and node_path_len > 0) {
|
||||
const adjusted = getAdjustedTarget(event._dispatch_target, path_buffer[node_path_len - 1]);
|
||||
event._target = if (rootIsShadowRoot(adjusted)) null else adjusted;
|
||||
}
|
||||
// Handle checkbox/radio activation rollback or commit
|
||||
if (activation_state) |state| {
|
||||
state.restore(event, frame);
|
||||
@@ -201,9 +210,6 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
}
|
||||
}
|
||||
|
||||
var path_len: usize = 0;
|
||||
var path_buffer: [128]*EventTarget = undefined;
|
||||
|
||||
var node: ?*Node = target;
|
||||
while (node) |n| {
|
||||
if (path_len >= path_buffer.len) break;
|
||||
@@ -227,11 +233,18 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
node = n._parent;
|
||||
}
|
||||
|
||||
node_path_len = path_len;
|
||||
|
||||
// Even though the window isn't part of the DOM, most events propagate
|
||||
// through it in the capture phase (unless we stopped at a shadow boundary)
|
||||
// The only explicit exception is "load"
|
||||
if (event._type_string.eql(comptime .wrap("load")) == false) {
|
||||
if (path_len < path_buffer.len) {
|
||||
// through it in the capture phase. It only participates when the tree's
|
||||
// root is the document (not for detached trees, and not when propagation
|
||||
// stopped at a shadow boundary). The only explicit exception is "load".
|
||||
if (event._type_string.eql(comptime .wrap("load")) == false and path_len < path_buffer.len) {
|
||||
const root_is_document = path_len > 0 and switch (path_buffer[path_len - 1]._type) {
|
||||
.node => |n| n._type == .document,
|
||||
else => false,
|
||||
};
|
||||
if (root_is_document) {
|
||||
path_buffer[path_len] = frame.window.asEventTarget();
|
||||
path_len += 1;
|
||||
}
|
||||
@@ -454,6 +467,22 @@ fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarge
|
||||
return original_target;
|
||||
}
|
||||
|
||||
// Whether the target's tree root (without crossing shadow boundaries) is a
|
||||
// shadow root. Used for the spec's post-dispatch "clear targets" step.
|
||||
fn rootIsShadowRoot(target_: ?*EventTarget) bool {
|
||||
const ShadowRoot = @import("webapi/ShadowRoot.zig");
|
||||
|
||||
const target = target_ orelse return false;
|
||||
var current: *Node = switch (target._type) {
|
||||
.node => |n| n,
|
||||
else => return false,
|
||||
};
|
||||
while (current._parent) |p| {
|
||||
current = p;
|
||||
}
|
||||
return current.is(ShadowRoot) != null;
|
||||
}
|
||||
|
||||
// Check if ancestor is an ancestor of (or the same as) node
|
||||
// WITHOUT crossing shadow boundaries (just regular DOM tree)
|
||||
fn isAncestorOrSelf(ancestor: *Node, node: *Node) bool {
|
||||
|
||||
@@ -253,6 +253,9 @@ pub fn dispatchDirect(
|
||||
ls.deinit();
|
||||
}
|
||||
|
||||
// Per spec, currentTarget is only set while listeners are being invoked
|
||||
defer event._current_target = null;
|
||||
|
||||
// Call the property handler (e.g., onmessage) if present
|
||||
if (getFunction(handler, &ls.local)) |func| {
|
||||
event._current_target = target;
|
||||
|
||||
+18
-1
@@ -1703,6 +1703,23 @@ pub fn queueLoad(self: *Frame, html: *Element.Html) !void {
|
||||
// splitting by route anyway).
|
||||
const MAX_STYLESHEET_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
// start prefetching <link rel="preload" as="script" href=...>`
|
||||
pub fn preloadScriptHint(self: *Frame, href: []const u8) void {
|
||||
if (self.isGoingAway() or self._parse_mode == .fragment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const arena = self.getArena(.small, "Frame.preloadScriptHint") catch return;
|
||||
defer self.releaseArena(arena);
|
||||
|
||||
const resolved = URL.resolve(arena, self.base(), href, .{ .encoding = self.charset }) catch return;
|
||||
if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) {
|
||||
// data:/blob: are synthesized locally — no round-trip to hide.
|
||||
return;
|
||||
}
|
||||
self._script_manager.preloadScript(resolved) catch {};
|
||||
}
|
||||
|
||||
// Synchronously fetch and parse an external `<link rel=stylesheet>`.
|
||||
// href is passed in as an optimization since the [currently] only callsite has
|
||||
// it, so why look it up again?
|
||||
@@ -1751,7 +1768,7 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co
|
||||
const sm = &self._script_manager.base;
|
||||
const was_evaluating = sm.is_evaluating;
|
||||
sm.is_evaluating = true;
|
||||
defer sm.is_evaluating = was_evaluating;
|
||||
defer sm.endEvaluationWindow(was_evaluating);
|
||||
|
||||
var response = http_client.syncRequest(arena, .{
|
||||
.url = resolved,
|
||||
|
||||
+163
-20
@@ -46,25 +46,41 @@ frame: *Frame,
|
||||
// "load" event).
|
||||
frame_notified_of_completion: bool,
|
||||
|
||||
// scripts loaded based on a <link rel=preload as=script href=...> found during parsing
|
||||
preloaded_scripts: std.StringHashMapUnmanaged(PreloadedScript),
|
||||
|
||||
pub fn init(allocator: Allocator, http_client: *HttpClient, frame: *Frame) ScriptManager {
|
||||
var base = ScriptManagerBase.init(allocator, http_client, .{ .frame = frame });
|
||||
base.tail_hook = tailHook;
|
||||
return .{
|
||||
.frame = frame,
|
||||
.base = base,
|
||||
.frame = frame,
|
||||
.preloaded_scripts = .empty,
|
||||
.frame_notified_of_completion = false,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *ScriptManager) void {
|
||||
self.freePreloads();
|
||||
self.base.deinit();
|
||||
self.preloaded_scripts.deinit(self.base.allocator);
|
||||
}
|
||||
|
||||
pub fn reset(self: *ScriptManager) void {
|
||||
self.freePreloads();
|
||||
self.preloaded_scripts.clearRetainingCapacity();
|
||||
self.base.reset();
|
||||
self.frame_notified_of_completion = false;
|
||||
}
|
||||
|
||||
// Frees every preloaded Script
|
||||
fn freePreloads(self: *ScriptManager) void {
|
||||
var it = self.preloaded_scripts.valueIterator();
|
||||
while (it.next()) |preload_script| {
|
||||
preload_script.deinit();
|
||||
}
|
||||
}
|
||||
|
||||
// Frame wrapper uses this to fire documentIsLoaded and scriptsCompletedLoading
|
||||
// once Base has finished processing its ready / defer queues.
|
||||
pub fn tailHook(base: *ScriptManagerBase) void {
|
||||
@@ -86,6 +102,81 @@ fn getHeaders(self: *ScriptManager) !HttpClient.Headers {
|
||||
return self.base.getHeaders();
|
||||
}
|
||||
|
||||
pub fn preloadScript(self: *ScriptManager, url: []const u8) !void {
|
||||
if (self.preloaded_scripts.contains(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = self.frame;
|
||||
const arena = try frame.getArena(.large, "SM.preloadScript");
|
||||
errdefer frame.releaseArena(arena);
|
||||
|
||||
const owned_url = try arena.dupeZ(u8, url);
|
||||
|
||||
const script = try arena.create(Script);
|
||||
script.* = .{
|
||||
.arena = arena,
|
||||
.url = owned_url,
|
||||
.node = .{},
|
||||
.manager = &self.base,
|
||||
.complete = false,
|
||||
.source = .{ .remote = .{} },
|
||||
.extra = .preload,
|
||||
};
|
||||
|
||||
try self.preloaded_scripts.putNoClobber(self.base.allocator, owned_url, .{ .state = .{ .loading = script } });
|
||||
errdefer _ = self.preloaded_scripts.remove(owned_url);
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.http, "script queue", .{ .url = owned_url, .ctx = "preload" });
|
||||
}
|
||||
|
||||
try frame.makeRequest(.{
|
||||
.ctx = script,
|
||||
.url = owned_url,
|
||||
.method = .GET,
|
||||
.frame_id = frame._frame_id,
|
||||
.loader_id = frame._loader_id,
|
||||
.headers = try self.base.getHeaders(),
|
||||
.cookie_jar = &frame._session.cookie_jar,
|
||||
.cookie_origin = frame.url,
|
||||
.resource_type = .script,
|
||||
.notification = frame._session.notification,
|
||||
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
||||
.header_callback = Script.headerCallback,
|
||||
.data_callback = Script.dataCallback,
|
||||
.done_callback = PreloadedScript.doneCallback,
|
||||
.error_callback = PreloadedScript.errorCallback,
|
||||
});
|
||||
}
|
||||
|
||||
fn waitForPreload(self: *ScriptManager, url: [:0]const u8) ?*Script {
|
||||
if (self.preloaded_scripts.getPtr(url) == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const was_evaluating = self.base.is_evaluating;
|
||||
self.base.is_evaluating = true;
|
||||
defer self.base.endEvaluationWindow(was_evaluating);
|
||||
|
||||
var client = self.base.client;
|
||||
while (true) {
|
||||
const entry = self.preloaded_scripts.getPtr(url) orelse return null;
|
||||
switch (entry.state) {
|
||||
.loading => {
|
||||
_ = client.tick(200, .sync_wait) catch return null;
|
||||
continue;
|
||||
},
|
||||
.done => |script| {
|
||||
// Preload scripts are single-use. We return it and it becomes
|
||||
// the caller's responsibility to free.
|
||||
_ = self.preloaded_scripts.remove(url);
|
||||
return script;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_element: *Element.Html.Script, comptime ctx: []const u8) !void {
|
||||
if (script_element._executed) {
|
||||
// If a script tag gets dynamically created and added to the dom:
|
||||
@@ -132,6 +223,13 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
var handover = false;
|
||||
const frame = self.frame;
|
||||
|
||||
// A consumed preload (waitForPreload below) is owned by us: its buffer is
|
||||
// borrowed by `script`, so it must outlive eval.
|
||||
var consumed_preload: ?*Script = null;
|
||||
defer if (consumed_preload) |p| {
|
||||
p.deinit();
|
||||
};
|
||||
|
||||
const arena = try frame.getArena(.large, "SM.addFromElement");
|
||||
errdefer if (!handover) {
|
||||
frame.releaseArena(arena);
|
||||
@@ -235,26 +333,32 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
|
||||
const was_evaluating = self.base.is_evaluating;
|
||||
self.base.is_evaluating = true;
|
||||
defer self.base.is_evaluating = was_evaluating;
|
||||
|
||||
const headers = try self.getHeaders();
|
||||
defer self.base.endEvaluationWindow(was_evaluating);
|
||||
|
||||
if (is_blocking) {
|
||||
const response = try self.base.client.syncRequest(arena, .{
|
||||
.url = url,
|
||||
.method = .GET,
|
||||
.frame_id = frame._frame_id,
|
||||
.loader_id = frame._loader_id,
|
||||
.headers = headers,
|
||||
.cookie_jar = &frame._session.cookie_jar,
|
||||
.cookie_origin = frame.url,
|
||||
.resource_type = .script,
|
||||
.notification = frame._session.notification,
|
||||
});
|
||||
if (self.waitForPreload(url)) |pre| {
|
||||
// There was a preloaded script, we borrow it's source and status
|
||||
consumed_preload = pre;
|
||||
script.source = pre.source;
|
||||
script.status = pre.status;
|
||||
script.complete = true;
|
||||
} else {
|
||||
const response = try self.base.client.syncRequest(arena, .{
|
||||
.url = url,
|
||||
.method = .GET,
|
||||
.frame_id = frame._frame_id,
|
||||
.loader_id = frame._loader_id,
|
||||
.headers = try self.getHeaders(),
|
||||
.cookie_jar = &frame._session.cookie_jar,
|
||||
.cookie_origin = frame.url,
|
||||
.resource_type = .script,
|
||||
.notification = frame._session.notification,
|
||||
});
|
||||
|
||||
script.source = .{ .remote = response.body };
|
||||
script.status = response.status;
|
||||
script.complete = true;
|
||||
script.source = .{ .remote = response.body };
|
||||
script.status = response.status;
|
||||
script.complete = true;
|
||||
}
|
||||
} else {
|
||||
errdefer {
|
||||
self.base.scriptList(script).remove(&script.node);
|
||||
@@ -267,7 +371,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
.method = .GET,
|
||||
.frame_id = frame._frame_id,
|
||||
.loader_id = frame._loader_id,
|
||||
.headers = headers,
|
||||
.headers = try self.getHeaders(),
|
||||
.cookie_jar = &frame._session.cookie_jar,
|
||||
.cookie_origin = frame.url,
|
||||
.resource_type = .script,
|
||||
@@ -301,8 +405,8 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
const was_evaluating = self.base.is_evaluating;
|
||||
self.base.is_evaluating = true;
|
||||
defer {
|
||||
self.base.is_evaluating = was_evaluating;
|
||||
script.deinit();
|
||||
self.base.endEvaluationWindow(was_evaluating);
|
||||
}
|
||||
|
||||
script.eval();
|
||||
@@ -311,3 +415,42 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
pub fn staticScriptsDone(self: *ScriptManager) void {
|
||||
self.base.staticScriptsDone();
|
||||
}
|
||||
|
||||
const PreloadedScript = struct {
|
||||
state: State,
|
||||
|
||||
const State = union(enum) {
|
||||
loading: *Script,
|
||||
done: *Script,
|
||||
};
|
||||
|
||||
pub fn deinit(self: PreloadedScript) void {
|
||||
switch (self.state) {
|
||||
inline else => |script| script.deinit(),
|
||||
}
|
||||
}
|
||||
|
||||
fn doneCallback(ctx: *anyopaque) !void {
|
||||
const script: *Script = @ptrCast(@alignCast(ctx));
|
||||
script.complete = true;
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.http, "script fetch complete", .{ .req = script.url });
|
||||
}
|
||||
|
||||
const self: *ScriptManager = @fieldParentPtr("base", script.manager);
|
||||
self.preloaded_scripts.getPtr(script.url).?.state = .{ .done = script };
|
||||
}
|
||||
|
||||
fn errorCallback(ctx: *anyopaque, err: anyerror) void {
|
||||
const script: *Script = @ptrCast(@alignCast(ctx));
|
||||
if (script.status == 404) {
|
||||
log.info(.http, "script 404", .{ .req = script.url, .extra = "preload" });
|
||||
} else {
|
||||
log.warn(.http, "script fetch error", .{ .err = err, .req = script.url, .extra = "preload", .status = script.status });
|
||||
}
|
||||
|
||||
const self: *ScriptManager = @fieldParentPtr("base", script.manager);
|
||||
_ = self.preloaded_scripts.remove(script.url);
|
||||
script.deinit();
|
||||
}
|
||||
};
|
||||
@@ -95,6 +95,10 @@ owner: Owner,
|
||||
// used to prevent recursive evaluation
|
||||
is_evaluating: bool,
|
||||
|
||||
// an evaluate() arrived while is_evaluating was set, signal to call evaluate()
|
||||
// when is_evaluating becomes false (to prevent re-entrancy)
|
||||
evaluate_pending: bool = false,
|
||||
|
||||
// Only once this is true can deferred scripts be run
|
||||
static_scripts_done: bool,
|
||||
|
||||
@@ -167,6 +171,7 @@ pub fn reset(self: *ScriptManagerBase) void {
|
||||
clearList(&self.async_scripts);
|
||||
clearList(&self.ready_scripts);
|
||||
self.static_scripts_done = false;
|
||||
self.evaluate_pending = false;
|
||||
}
|
||||
|
||||
fn clearList(list: *std.DoublyLinkedList) void {
|
||||
@@ -193,6 +198,7 @@ fn releaseArena(self: *ScriptManagerBase, arena: Allocator) void {
|
||||
pub fn scriptList(self: *ScriptManagerBase, script: *const Script) *std.DoublyLinkedList {
|
||||
return switch (script.extra) {
|
||||
.import, .import_async => &self.async_scripts,
|
||||
.preload => unreachable, // done/error are handled directly, never via scriptList
|
||||
.frame => |fe| switch (fe.mode) {
|
||||
.normal => unreachable, // not added to a list, executed immediately
|
||||
.@"defer" => &self.defer_scripts,
|
||||
@@ -287,7 +293,7 @@ pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource
|
||||
|
||||
const was_evaluating = self.is_evaluating;
|
||||
self.is_evaluating = true;
|
||||
defer self.is_evaluating = was_evaluating;
|
||||
defer self.endEvaluationWindow(was_evaluating);
|
||||
|
||||
var client = self.client;
|
||||
while (true) {
|
||||
@@ -350,13 +356,12 @@ pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsy
|
||||
}
|
||||
|
||||
// It's possible, but unlikely, for client.request to immediately finish
|
||||
// a request, thus calling our callback. We generally don't want a call
|
||||
// from v8 (which is why we're here), to result in a new script evaluation.
|
||||
// So we block even the slightest change that `client.request` immediately
|
||||
// executes a callback.
|
||||
// a request, thus calling our callback. We don't want that to trigger a
|
||||
// script evaluation while we're still setting the request up (the script
|
||||
// isn't fully linked into our lists yet).
|
||||
const was_evaluating = self.is_evaluating;
|
||||
self.is_evaluating = true;
|
||||
defer self.is_evaluating = was_evaluating;
|
||||
defer self.endEvaluationWindow(was_evaluating);
|
||||
|
||||
const owner = self.owner;
|
||||
const session = self.owner.session();
|
||||
@@ -405,62 +410,97 @@ pub fn scriptCreatedParseDone(self: *ScriptManagerBase) void {
|
||||
pub fn evaluate(self: *ScriptManagerBase) void {
|
||||
if (self.is_evaluating) {
|
||||
// It's possible for a script.eval to cause evaluate to be called again.
|
||||
// Signal that this happened so that, once the outer evaluate finishes,
|
||||
// we evaluate() again to catch what we're skipping now.
|
||||
self.evaluate_pending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
self.is_evaluating = true;
|
||||
defer self.is_evaluating = false;
|
||||
defer self.endEvaluationWindow(false);
|
||||
|
||||
while (self.ready_scripts.popFirst()) |n| {
|
||||
var script: *Script = @fieldParentPtr("node", n);
|
||||
switch (script.extra) {
|
||||
.frame => {
|
||||
// Only .async mode reaches ready_scripts (defer stays in
|
||||
// defer_scripts, normal is sync and never queued).
|
||||
defer script.deinit();
|
||||
script.eval();
|
||||
},
|
||||
.import_async => |ia| {
|
||||
if (script.status < 200 or script.status > 299) {
|
||||
while (true) {
|
||||
self.evaluate_pending = false;
|
||||
|
||||
while (self.ready_scripts.popFirst()) |n| {
|
||||
var script: *Script = @fieldParentPtr("node", n);
|
||||
switch (script.extra) {
|
||||
.frame => {
|
||||
// Only .async mode reaches ready_scripts (defer stays in
|
||||
// defer_scripts, normal is sync and never queued).
|
||||
defer script.deinit();
|
||||
script.eval();
|
||||
},
|
||||
.import_async => |ia| {
|
||||
if (script.status < 200 or script.status > 299) {
|
||||
script.deinit();
|
||||
ia.callback(ia.data, error.FailedToLoad);
|
||||
} else {
|
||||
ia.callback(ia.data, .{
|
||||
.shared = false,
|
||||
.script = script,
|
||||
.buffer = script.source.remote,
|
||||
});
|
||||
}
|
||||
},
|
||||
.import => unreachable, // .import doesn't go through ready_scripts
|
||||
.preload => unreachable, // .preload is buffered in the map, never queued
|
||||
}
|
||||
}
|
||||
|
||||
if (self.static_scripts_done) {
|
||||
while (self.defer_scripts.first) |n| {
|
||||
var script: *Script = @fieldParentPtr("node", n);
|
||||
if (script.complete == false) break;
|
||||
defer {
|
||||
_ = self.defer_scripts.popFirst();
|
||||
script.deinit();
|
||||
ia.callback(ia.data, error.FailedToLoad);
|
||||
} else {
|
||||
ia.callback(ia.data, .{
|
||||
.shared = false,
|
||||
.script = script,
|
||||
.buffer = script.source.remote,
|
||||
});
|
||||
}
|
||||
},
|
||||
.import => unreachable, // .import doesn't go through ready_scripts
|
||||
// Only frame scripts populate defer_scripts.
|
||||
script.eval();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (self.static_scripts_done == false) {
|
||||
// We can only execute deferred scripts if
|
||||
// 1 - all the normal scripts are done
|
||||
// 2 - we've finished parsing the HTML and at least queued all the scripts
|
||||
// The last one isn't obvious, but it's possible for self.scripts to
|
||||
// be empty not because we're done executing all the normal scripts
|
||||
// but because we're done executing some (or maybe none), but we're still
|
||||
// parsing the HTML.
|
||||
return;
|
||||
}
|
||||
|
||||
while (self.defer_scripts.first) |n| {
|
||||
var script: *Script = @fieldParentPtr("node", n);
|
||||
if (script.complete == false) return;
|
||||
defer {
|
||||
_ = self.defer_scripts.popFirst();
|
||||
script.deinit();
|
||||
// A script.eval above may have pumped the http client (module
|
||||
// imports, preloads); anything that completed during the pump had
|
||||
// its evaluate() swallowed by the re-entrancy guard, so re-drain
|
||||
// here rather than relying on a future doneCallback that may never
|
||||
// come.
|
||||
if (self.evaluate_pending) {
|
||||
continue;
|
||||
}
|
||||
// Only frame scripts populate defer_scripts.
|
||||
script.eval();
|
||||
|
||||
if (self.static_scripts_done == false) {
|
||||
// We can only execute deferred scripts if
|
||||
// 1 - all the normal scripts are done
|
||||
// 2 - we've finished parsing the HTML and at least queued all the scripts
|
||||
// The last one isn't obvious, but it's possible for self.scripts to
|
||||
// be empty not because we're done executing all the normal scripts
|
||||
// but because we're done executing some (or maybe none), but we're still
|
||||
// parsing the HTML.
|
||||
return;
|
||||
}
|
||||
if (self.defer_scripts.first != null) {
|
||||
// Head of the defer list is still in flight; its doneCallback
|
||||
// re-enters evaluate.
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Frame wrapper uses this to fire documentIsLoaded and
|
||||
// scriptsCompletedLoading. Null for workers.
|
||||
if (self.tail_hook) |hook| hook(self);
|
||||
if (self.tail_hook) |hook| {
|
||||
hook(self);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn endEvaluationWindow(self: *ScriptManagerBase, was_evaluating: bool) void {
|
||||
self.is_evaluating = was_evaluating;
|
||||
if (was_evaluating == false and self.evaluate_pending) {
|
||||
// we have something to evaluate
|
||||
self.evaluate();
|
||||
}
|
||||
}
|
||||
|
||||
pub const Script = struct {
|
||||
@@ -505,6 +545,8 @@ pub const Script = struct {
|
||||
import,
|
||||
// Dynamic JS import() — resolved via ready_scripts callback.
|
||||
import_async: ImportAsync,
|
||||
// <link rel=preload as=script href=...>
|
||||
preload,
|
||||
// <script> tag in a frame.
|
||||
frame: FrameExtra,
|
||||
|
||||
@@ -646,6 +688,7 @@ pub const Script = struct {
|
||||
entry.state = .{ .done = self };
|
||||
entry.buffer = self.source.remote;
|
||||
},
|
||||
.preload => unreachable, // preloads use ScriptManager.PreloadedScript.doneCallback
|
||||
}
|
||||
manager.evaluate();
|
||||
}
|
||||
@@ -688,6 +731,7 @@ pub const Script = struct {
|
||||
entry.state = .err;
|
||||
},
|
||||
.frame => self.executeCallback(comptime .wrap("error")),
|
||||
.preload => unreachable, // preloads use ScriptManager.PreloadedScript.errorCallback
|
||||
}
|
||||
self.deinit();
|
||||
manager.evaluate();
|
||||
|
||||
@@ -129,11 +129,12 @@ pub const Caught = struct {
|
||||
try writer.print("{s}caught: {any}", .{ separator, self.caught });
|
||||
}
|
||||
|
||||
pub fn logFmt(self: Caught, comptime prefix: []const u8, writer: anytype) !void {
|
||||
try writer.write(prefix ++ ".exception", self.exception orelse "???");
|
||||
try writer.write(prefix ++ ".stack", self.stack orelse "na");
|
||||
try writer.write(prefix ++ ".line", self.line);
|
||||
try writer.write(prefix ++ ".caught", self.caught);
|
||||
pub fn logFmt(self: Caught, prefix: []const u8, writer: anytype) !void {
|
||||
var buf: [64]u8 = undefined;
|
||||
try writer.write(try std.fmt.bufPrint(&buf, "{s}.exception", .{prefix}), self.exception orelse "???");
|
||||
try writer.write(try std.fmt.bufPrint(&buf, "{s}.stack", .{prefix}), self.stack orelse "na");
|
||||
try writer.write(try std.fmt.bufPrint(&buf, "{s}.line", .{prefix}), self.line);
|
||||
try writer.write(try std.fmt.bufPrint(&buf, "{s}.caught", .{prefix}), self.caught);
|
||||
}
|
||||
|
||||
pub fn jsonStringify(self: Caught, jw: anytype) !void {
|
||||
|
||||
@@ -926,6 +926,7 @@ pub const PageJsApis = flattenTypes(&.{
|
||||
@import("../webapi/event/KeyboardEvent.zig"),
|
||||
@import("../webapi/event/FocusEvent.zig"),
|
||||
@import("../webapi/event/WheelEvent.zig"),
|
||||
@import("../webapi/event/DragEvent.zig"),
|
||||
@import("../webapi/event/TextEvent.zig"),
|
||||
@import("../webapi/event/InputEvent.zig"),
|
||||
@import("../webapi/event/PromiseRejectionEvent.zig"),
|
||||
@@ -980,6 +981,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"),
|
||||
|
||||
@@ -884,7 +884,7 @@ fn testMarkdownShadow(light: []const u8, shadow: []const u8, expected: []const u
|
||||
try frame.parseHtmlAsChildren(host.asNode(), light);
|
||||
}
|
||||
|
||||
const sr = try host.attachShadow(comptime .wrap("open"), frame);
|
||||
const sr = try host.attachShadow(.{ .mode = .open }, frame);
|
||||
try frame.parseHtmlAsChildren(sr.asNode(), shadow);
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
|
||||
@@ -579,14 +579,19 @@ fn attachDeclarativeShadowCallback(ctx: *anyopaque, host_ref: *anyopaque, templa
|
||||
fn _attachDeclarativeShadowCallback(self: *Parser, host_node: *Node, template_node: *Node, mode_is_open: bool) !u8 {
|
||||
// guaranteed by html5ever
|
||||
const host = host_node.as(Element);
|
||||
const mode: lp.String = if (mode_is_open) comptime .wrap("open") else comptime .wrap("closed");
|
||||
const shadow = host.attachShadow(mode, self.frame) catch |err| switch (err) {
|
||||
// Expected per-spec fall-backs (host can't host a shadow, or already
|
||||
// has one): keep the <template> in the light DOM instead.
|
||||
const template_el = template_node.as(Element);
|
||||
const shadow = host.attachShadow(.{
|
||||
.declarative = true,
|
||||
.mode = if (mode_is_open) .open else .closed,
|
||||
.delegates_focus = template_el.hasAttributeSafe(.wrap("shadowrootdelegatesfocus")),
|
||||
.clonable = template_el.hasAttributeSafe(.wrap("shadowrootclonable")),
|
||||
.serializable = template_el.hasAttributeSafe(.wrap("shadowrootserializable")),
|
||||
}, self.frame) catch |err| switch (err) {
|
||||
error.NotSupported => return 0,
|
||||
else => return err,
|
||||
};
|
||||
const template = template_node.as(Element).is(Element.Html.Template) orelse return 0;
|
||||
|
||||
const template = template_el.is(Element.Html.Template) orelse return 0;
|
||||
template._content = shadow.asDocumentFragment();
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../../../testing.js"></script>
|
||||
|
||||
<!--
|
||||
The preload hint is declared before the <script> that consumes it, so by the
|
||||
time the parser blocks on `preload.js` the fetch is already in flight (or
|
||||
done). ScriptManager.waitForPreload adopts that body instead of issuing a
|
||||
fresh syncRequest, then the consumed entry is freed by the caller. Both halves
|
||||
of that ownership handover run under the leak detector here.
|
||||
-->
|
||||
<link rel="preload" as="script" href="preload.js">
|
||||
|
||||
<!--
|
||||
A preload with no matching <script> is fetched but never executed. It lingers
|
||||
as a `.done` (or still-loading) entry until reset() frees it — exercises
|
||||
freePreloads / map cleanup under the leak detector — and it must not run on
|
||||
its own, nor delay the load event.
|
||||
-->
|
||||
<link rel="preload" as="script" href="preload_unused.js">
|
||||
|
||||
<script id="setup">
|
||||
var order = '';
|
||||
testing.expectEqual('', order);
|
||||
</script>
|
||||
|
||||
<!-- Blocking remote script: its body is adopted from the preload above. -->
|
||||
<script id="preloaded" src="preload.js"></script>
|
||||
|
||||
<script id="after">
|
||||
// The preloaded blocking script executed, in document order, before us.
|
||||
order += 'b';
|
||||
testing.expectEqual('ab', order);
|
||||
</script>
|
||||
|
||||
<script id="unused_check">
|
||||
// A preload hint that nothing consumes must never execute.
|
||||
testing.expectEqual(undefined, window.unused_preload_ran);
|
||||
</script>
|
||||
@@ -0,0 +1,2 @@
|
||||
order += 'a';
|
||||
testing.expectEqual('a', order);
|
||||
@@ -0,0 +1,4 @@
|
||||
// Nothing consumes this preload, so it must never be evaluated. If it runs, the
|
||||
// flag trips the assertion in preload.html (and this fail() fires directly).
|
||||
window.unused_preload_ran = true;
|
||||
testing.fail('an unconsumed <link rel=preload as=script> must not execute');
|
||||
@@ -228,3 +228,25 @@
|
||||
|
||||
testing.expectEqual('<p>hello, world</p>', out.innerHTML);
|
||||
</script>
|
||||
|
||||
<script id=cloneNode_copies_content>
|
||||
{
|
||||
const template = $('#basic');
|
||||
|
||||
// Per spec, template content is only copied on a deep clone.
|
||||
const shallow = template.cloneNode(false);
|
||||
testing.expectEqual(0, shallow.content.childNodes.length);
|
||||
|
||||
const deep = template.cloneNode(true);
|
||||
testing.expectEqual('Hello Template', deep.content.querySelector('h1').textContent);
|
||||
testing.expectTrue(deep.content.querySelector('h1') !== template.content.querySelector('h1'));
|
||||
|
||||
// Cloning an ancestor must also clone nested template content.
|
||||
const wrapper = document.createElement('div');
|
||||
const inner = document.createElement('template');
|
||||
inner.innerHTML = '<span id="in-template">x</span>';
|
||||
wrapper.appendChild(inner);
|
||||
const wrapperClone = wrapper.cloneNode(true);
|
||||
testing.expectEqual('x', wrapperClone.querySelector('template').content.querySelector('#in-template').textContent);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id=default>
|
||||
{
|
||||
const event = new DragEvent('drop');
|
||||
testing.expectEqual('drop', event.type);
|
||||
testing.expectEqual(true, event instanceof DragEvent);
|
||||
testing.expectEqual(true, event instanceof MouseEvent);
|
||||
testing.expectEqual(true, event instanceof UIEvent);
|
||||
testing.expectEqual(true, event instanceof Event);
|
||||
// no dataTransfer supplied -> null
|
||||
testing.expectEqual(null, event.dataTransfer);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=mouse_inherited>
|
||||
{
|
||||
const event = new DragEvent('dragover', { clientX: 5, clientY: 9, button: 0 });
|
||||
testing.expectEqual(5, event.clientX);
|
||||
testing.expectEqual(9, event.clientY);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=carries_data_transfer>
|
||||
{
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File(["x"], "drop.txt", { type: "text/plain" }));
|
||||
|
||||
const event = new DragEvent('drop', { dataTransfer: dt, bubbles: true });
|
||||
testing.expectEqual(dt, event.dataTransfer);
|
||||
testing.expectEqual(1, event.dataTransfer.files.length);
|
||||
testing.expectEqual("drop.txt", event.dataTransfer.files[0].name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=dispatched_drop>
|
||||
{
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File(["x"], "drop.txt", { type: "text/plain" }));
|
||||
|
||||
const el = document.createElement('div');
|
||||
let seen = -1;
|
||||
el.addEventListener('drop', function (e) {
|
||||
testing.expectEqual(true, e instanceof DragEvent);
|
||||
seen = e.dataTransfer.files.length;
|
||||
});
|
||||
el.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true }));
|
||||
testing.expectEqual(1, seen);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=refcount_balance>
|
||||
{
|
||||
// A DragEvent holds a ref on its DataTransfer so the store outlives the event
|
||||
// even if the JS wrapper is dropped first. Build many event/store pairs and
|
||||
// retain none: at teardown every acquire must be matched by a release, or the
|
||||
// leak-checking test runner fails. Encodes the acquire/release contract that a
|
||||
// plain "read the files" assertion wouldn't exercise.
|
||||
let last = 0;
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File(["payload"], "f" + i + ".txt", { type: "text/plain" }));
|
||||
const ev = new DragEvent('drop', { dataTransfer: dt });
|
||||
last = ev.dataTransfer.files.length;
|
||||
}
|
||||
testing.expectEqual(1, last);
|
||||
}
|
||||
</script>
|
||||
@@ -122,3 +122,96 @@
|
||||
testing.expectError('NotSupportedError', () => sd.attachShadow({ mode: 'open' }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=attachShadow_options_reflection>
|
||||
{
|
||||
const host = document.createElement('div');
|
||||
const shadow = host.attachShadow({
|
||||
mode: 'open',
|
||||
delegatesFocus: true,
|
||||
slotAssignment: 'manual',
|
||||
clonable: true,
|
||||
serializable: true,
|
||||
});
|
||||
testing.expectEqual(true, shadow.delegatesFocus);
|
||||
testing.expectEqual('manual', shadow.slotAssignment);
|
||||
testing.expectEqual(true, shadow.clonable);
|
||||
testing.expectEqual(true, shadow.serializable);
|
||||
|
||||
const defaults = document.createElement('div').attachShadow({ mode: 'open' });
|
||||
testing.expectEqual(false, defaults.delegatesFocus);
|
||||
testing.expectEqual('named', defaults.slotAssignment);
|
||||
testing.expectEqual(false, defaults.clonable);
|
||||
testing.expectEqual(false, defaults.serializable);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=attachShadow_declarative_reattach>
|
||||
{
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setHTMLUnsafe('<div><template shadowrootmode="open" shadowrootdelegatesfocus><span>declared</span></template></div>');
|
||||
const host = wrapper.firstElementChild;
|
||||
const declarative = host.shadowRoot;
|
||||
testing.expectEqual(true, declarative.delegatesFocus);
|
||||
testing.expectEqual('declared', declarative.firstElementChild.textContent);
|
||||
|
||||
// Mismatched mode throws.
|
||||
let caught = null;
|
||||
try { host.attachShadow({ mode: 'closed' }); } catch (e) { caught = e.name; }
|
||||
testing.expectEqual('NotSupportedError', caught);
|
||||
|
||||
// Matching mode returns the same root, emptied.
|
||||
const reattached = host.attachShadow({ mode: 'open' });
|
||||
testing.expectEqual(declarative, reattached);
|
||||
testing.expectEqual(0, reattached.childNodes.length);
|
||||
|
||||
// No longer declarative: a second attachShadow now throws.
|
||||
caught = null;
|
||||
try { host.attachShadow({ mode: 'open' }); } catch (e) { caught = e.name; }
|
||||
testing.expectEqual('NotSupportedError', caught);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=clonable_shadow_cloned_with_host>
|
||||
{
|
||||
const host = document.createElement('div');
|
||||
const shadow = host.attachShadow({ mode: 'open', clonable: true });
|
||||
shadow.innerHTML = '<input><div><span></span></div>';
|
||||
|
||||
// Even a shallow host clone deep-clones a clonable shadow root.
|
||||
const shallow = host.cloneNode(false);
|
||||
testing.expectEqual(true, shallow.shadowRoot.clonable);
|
||||
testing.expectEqual(2, shallow.shadowRoot.children.length);
|
||||
testing.expectEqual('SPAN', shallow.shadowRoot.children[1].firstElementChild.tagName);
|
||||
|
||||
// Non-clonable shadow roots are not cloned.
|
||||
const plainHost = document.createElement('div');
|
||||
plainHost.attachShadow({ mode: 'open' });
|
||||
testing.expectEqual(null, plainHost.cloneNode(true).shadowRoot);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=template_shadowroot_reflection>
|
||||
{
|
||||
const t = document.createElement('template');
|
||||
testing.expectEqual('', t.shadowRootMode);
|
||||
testing.expectEqual(false, t.shadowRootDelegatesFocus);
|
||||
|
||||
t.shadowRootMode = 'open';
|
||||
testing.expectEqual('open', t.shadowRootMode);
|
||||
testing.expectEqual('open', t.getAttribute('shadowrootmode'));
|
||||
|
||||
// Limited to known values: invalid reads back as "".
|
||||
t.shadowRootMode = 'blah';
|
||||
testing.expectEqual('', t.shadowRootMode);
|
||||
testing.expectEqual('blah', t.getAttribute('shadowrootmode'));
|
||||
|
||||
t.shadowRootDelegatesFocus = true;
|
||||
testing.expectEqual(true, t.hasAttribute('shadowrootdelegatesfocus'));
|
||||
t.shadowRootClonable = true;
|
||||
testing.expectEqual(true, t.shadowRootClonable);
|
||||
t.shadowRootClonable = false;
|
||||
testing.expectEqual(false, t.hasAttribute('shadowrootclonable'));
|
||||
testing.expectEqual(false, t.shadowRootSerializable);
|
||||
}
|
||||
</script>
|
||||
@@ -281,3 +281,48 @@
|
||||
host.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="composedPath_detached_tree_excludes_window">
|
||||
{
|
||||
// A detached tree's path ends at its root: no document, no window.
|
||||
const root = document.createElement('div');
|
||||
const target = document.createElement('span');
|
||||
root.appendChild(target);
|
||||
|
||||
let capturedPath = null;
|
||||
root.addEventListener('my-event', (e) => {
|
||||
capturedPath = e.composedPath();
|
||||
});
|
||||
target.dispatchEvent(new Event('my-event', { bubbles: true, composed: true }));
|
||||
|
||||
testing.expectEqual(2, capturedPath.length);
|
||||
testing.expectEqual(target, capturedPath[0]);
|
||||
testing.expectEqual(root, capturedPath[1]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="post_dispatch_state">
|
||||
{
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
const target = document.createElement('span');
|
||||
shadow.appendChild(target);
|
||||
shadow.addEventListener('my-event', () => {});
|
||||
|
||||
// composed: crossing the boundary retargets to the host post-dispatch.
|
||||
const composed = new Event('my-event', { bubbles: true, composed: true });
|
||||
target.dispatchEvent(composed);
|
||||
testing.expectEqual(host, composed.target);
|
||||
testing.expectEqual(null, composed.currentTarget);
|
||||
testing.expectEqual(0, composed.eventPhase);
|
||||
testing.expectEqual(0, composed.composedPath().length);
|
||||
|
||||
// non-composed: the event never left the shadow tree, targets are cleared.
|
||||
const scoped = new Event('my-event', { bubbles: true, composed: false });
|
||||
target.dispatchEvent(scoped);
|
||||
testing.expectEqual(null, scoped.target);
|
||||
|
||||
host.remove();
|
||||
}
|
||||
</script>
|
||||
@@ -90,3 +90,19 @@
|
||||
host.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ownerDocument_in_shadow_of_other_document">
|
||||
{
|
||||
const doc = document.implementation.createHTMLDocument('Test');
|
||||
const host = doc.createElement('div');
|
||||
doc.body.appendChild(host);
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
const child = doc.createElement('span');
|
||||
shadow.appendChild(child);
|
||||
|
||||
// Nodes in a shadow tree belong to the host's document, not the
|
||||
// frame's main document.
|
||||
testing.expectEqual(doc, child.ownerDocument);
|
||||
testing.expectEqual(doc, shadow.ownerDocument);
|
||||
}
|
||||
</script>
|
||||
@@ -119,7 +119,8 @@
|
||||
window.reportError(err);
|
||||
|
||||
testing.expectEqual(window, evt.target);
|
||||
testing.expectEqual(window, evt.currentTarget);
|
||||
// currentTarget is only set while listeners are invoked; null post-dispatch
|
||||
testing.expectEqual(null, evt.currentTarget);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// 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";
|
||||
}
|
||||
return std.ascii.allocLowerString(arena, format);
|
||||
}
|
||||
|
||||
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 owned_data = 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 = owned_data };
|
||||
return;
|
||||
}
|
||||
}
|
||||
const it = try self._arena.create(DataTransferItem);
|
||||
it.* = .{ ._data_transfer = self, ._kind = .string, ._type = norm, ._payload = .{ .string = owned_data } };
|
||||
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 owned_data = try data.toStringSliceWithAlloc(self._arena);
|
||||
const norm = try normalizeFormat(self._arena, type_ orelse "");
|
||||
const it = try self._arena.create(DataTransferItem);
|
||||
it.* = .{ ._data_transfer = self, ._kind = .string, ._type = norm, ._payload = .{ .string = owned_data } };
|
||||
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.* = .{ ._data_transfer = self, ._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", .{});
|
||||
}
|
||||
@@ -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 std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const js = @import("../js/js.zig");
|
||||
|
||||
const File = @import("File.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
const DataTransfer = @import("DataTransfer.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 };
|
||||
|
||||
// The owning DataTransfer. The item lives in that DataTransfer's arena and is
|
||||
// handed to JS, so it forwards acquireRef/releaseRef to keep the arena alive as
|
||||
// long as JS holds the item.
|
||||
_data_transfer: *DataTransfer,
|
||||
_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 acquireRef(self: *DataTransferItem) void {
|
||||
self._data_transfer.acquireRef();
|
||||
}
|
||||
|
||||
pub fn releaseRef(self: *DataTransferItem, page: *Page) void {
|
||||
self._data_transfer.releaseRef(page);
|
||||
}
|
||||
|
||||
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, .{});
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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 Page = @import("../Page.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 acquireRef(self: *DataTransferItemList) void {
|
||||
self._data_transfer.acquireRef();
|
||||
}
|
||||
|
||||
pub fn releaseRef(self: *DataTransferItemList, page: *Page) void {
|
||||
self._data_transfer.releaseRef(page);
|
||||
}
|
||||
|
||||
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 acquireRef(self: *@This()) void {
|
||||
self.list.acquireRef();
|
||||
}
|
||||
|
||||
pub fn releaseRef(self: *@This(), page: *Page) void {
|
||||
self.list.releaseRef(page);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -763,10 +763,7 @@ fn isValidShadowHost(self: *const Element) bool {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn attachShadow(self: *Element, mode_str: String, frame: *Frame) !*ShadowRoot {
|
||||
if (frame._element_shadow_roots.get(self)) |_| {
|
||||
return error.NotSupported;
|
||||
}
|
||||
pub fn attachShadow(self: *Element, opts: ShadowRoot.AttachOptions, frame: *Frame) !*ShadowRoot {
|
||||
if (!self.isValidShadowHost()) {
|
||||
return error.NotSupported;
|
||||
}
|
||||
@@ -780,13 +777,20 @@ pub fn attachShadow(self: *Element, mode_str: String, frame: *Frame) !*ShadowRoo
|
||||
}
|
||||
}
|
||||
}
|
||||
const mode: ShadowRoot.Mode = blk: {
|
||||
if (mode_str.eql(comptime .wrap("open"))) break :blk .open;
|
||||
if (mode_str.eql(comptime .wrap("closed"))) break :blk .closed;
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
const shadow_root = try ShadowRoot.init(self, mode, frame);
|
||||
if (frame._element_shadow_roots.get(self)) |existing| {
|
||||
// Imperative attachShadow over a declarative shadow root with a matching
|
||||
// mode empties it and returns the same root. The parser
|
||||
// (opts.declarative) never replaces an existing root.
|
||||
if (opts.declarative or !existing._declarative or existing._mode != opts.mode) {
|
||||
return error.NotSupported;
|
||||
}
|
||||
try existing.asNode().replaceChildren(&.{}, frame);
|
||||
existing._declarative = false;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const shadow_root = try ShadowRoot.init(self, opts, frame);
|
||||
try frame._element_shadow_roots.put(frame.arena, self, shadow_root);
|
||||
return shadow_root;
|
||||
}
|
||||
@@ -1459,10 +1463,33 @@ pub fn clone(self: *Element, deep: bool, frame: *Frame) !*Node {
|
||||
const node = try frame.createElementNS(self._namespace, tag_name, self._attributes);
|
||||
|
||||
// Allow element-specific types to copy their runtime state
|
||||
_ = Element.Build.call(node.as(Element), "cloned", .{ self, node.as(Element), frame }) catch |err| {
|
||||
_ = Element.Build.call(node.as(Element), "cloned", .{ self, node.as(Element), deep, frame }) catch |err| {
|
||||
log.err(.dom, "element.clone.failed", .{ .err = err });
|
||||
};
|
||||
|
||||
// Per spec, a clonable shadow root is cloned along with its host — its
|
||||
// children always deep-cloned, even when the host clone is shallow.
|
||||
if (frame._element_shadow_roots.get(self)) |shadow| {
|
||||
if (shadow._clonable) {
|
||||
const cloned_shadow = node.as(Element).attachShadow(.{
|
||||
.mode = shadow._mode,
|
||||
.clonable = true,
|
||||
.delegates_focus = shadow._delegates_focus,
|
||||
.slot_assignment = shadow._slot_assignment,
|
||||
.serializable = shadow._serializable,
|
||||
.declarative = shadow._declarative,
|
||||
}, frame) catch return error.CloneError;
|
||||
|
||||
const cloned_shadow_node = cloned_shadow.asNode();
|
||||
var shadow_child_it = shadow.asNode().childrenIterator();
|
||||
while (shadow_child_it.next()) |child| {
|
||||
if (try child.cloneNodeForAppending(true, frame)) |cloned_child| {
|
||||
try frame.appendNode(cloned_shadow_node, cloned_child, .{ .child_already_connected = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deep) {
|
||||
var child_it = self.asNode().childrenIterator();
|
||||
while (child_it.next()) |child| {
|
||||
@@ -1915,9 +1942,30 @@ pub const JsApi = struct {
|
||||
|
||||
const ShadowRootInit = struct {
|
||||
mode: String,
|
||||
delegatesFocus: bool = false,
|
||||
slotAssignment: ?String = null,
|
||||
clonable: bool = false,
|
||||
serializable: bool = false,
|
||||
};
|
||||
fn _attachShadow(self: *Element, init: ShadowRootInit, frame: *Frame) !*ShadowRoot {
|
||||
return self.attachShadow(init.mode, frame);
|
||||
const mode: ShadowRoot.Mode = blk: {
|
||||
if (init.mode.eql(comptime .wrap("open"))) break :blk .open;
|
||||
if (init.mode.eql(comptime .wrap("closed"))) break :blk .closed;
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
const slot_assignment: ShadowRoot.SlotAssignment = blk: {
|
||||
const sa = init.slotAssignment orelse break :blk .named;
|
||||
if (sa.eql(comptime .wrap("named"))) break :blk .named;
|
||||
if (sa.eql(comptime .wrap("manual"))) break :blk .manual;
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return self.attachShadow(.{
|
||||
.mode = mode,
|
||||
.delegates_focus = init.delegatesFocus,
|
||||
.slot_assignment = slot_assignment,
|
||||
.clonable = init.clonable,
|
||||
.serializable = init.serializable,
|
||||
}, frame);
|
||||
}
|
||||
pub const replaceChildren = bridge.function(Element.replaceChildren, .{ .dom_exception = true, .ce_reactions = true });
|
||||
pub const replaceWith = bridge.function(Element.replaceWith, .{ .dom_exception = true, .ce_reactions = true });
|
||||
|
||||
@@ -330,15 +330,23 @@ pub fn composedPath(self: *Event, exec: *Execution) ![]const *EventTarget {
|
||||
node = n._parent;
|
||||
}
|
||||
|
||||
// Add window at the end (unless we stopped at shadow boundary)
|
||||
if (!stopped_at_shadow_boundary) {
|
||||
if (path_len < path_buffer.len) {
|
||||
switch (exec.js.global) {
|
||||
.worker => {},
|
||||
.frame => |frame| {
|
||||
path_buffer[path_len] = frame.window.asEventTarget();
|
||||
path_len += 1;
|
||||
},
|
||||
// Add window at the end. It only participates when propagation did not stop
|
||||
// at a shadow boundary...
|
||||
if (stopped_at_shadow_boundary == false) {
|
||||
// ... AND when the tree's root is a document
|
||||
const root_is_document = path_len > 0 and switch (path_buffer[path_len - 1]._type) {
|
||||
.node => |n| n._type == .document,
|
||||
else => false,
|
||||
};
|
||||
if (root_is_document) {
|
||||
if (path_len < path_buffer.len) {
|
||||
switch (exec.js.global) {
|
||||
.worker => {},
|
||||
.frame => |frame| {
|
||||
path_buffer[path_len] = frame.window.asEventTarget();
|
||||
path_len += 1;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@ _root: ?*Element = null,
|
||||
_root_margin: []const u8 = "0px",
|
||||
_threshold: []const f64 = &.{0.0},
|
||||
_pending_entries: std.ArrayList(*IntersectionObserverEntry) = .{},
|
||||
_previous_states: std.AutoHashMapUnmanaged(*Element, bool) = .{},
|
||||
// tracked targets that aren't reported yet
|
||||
_tracked: std.AutoHashMapUnmanaged(*Element, void) = .{},
|
||||
|
||||
// Shared zero DOMRect to avoid repeated allocations for non-intersecting elements
|
||||
var zero_rect: DOMRect = .{
|
||||
@@ -140,8 +141,7 @@ pub fn observe(self: *IntersectionObserver, target: *Element, frame: *Frame) !vo
|
||||
try frame.registerIntersectionObserver(self);
|
||||
}
|
||||
|
||||
// Don't initialize previous state yet - let checkIntersection do it
|
||||
// This ensures we get an entry on first observation
|
||||
try self._tracked.put(self._arena, target, {});
|
||||
|
||||
// Check intersection for this new target and schedule delivery
|
||||
try self.checkIntersection(target, frame);
|
||||
@@ -155,7 +155,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, frame: *Frame) v
|
||||
for (self._observing.items, 0..) |elem, i| {
|
||||
if (elem == target) {
|
||||
_ = self._observing.swapRemove(i);
|
||||
_ = self._previous_states.remove(target);
|
||||
_ = self._tracked.remove(target);
|
||||
|
||||
// Remove any pending entries for this target.
|
||||
// Entries will be cleaned up by V8 GC via the finalizer.
|
||||
@@ -182,7 +182,7 @@ pub fn disconnect(self: *IntersectionObserver, frame: *Frame) void {
|
||||
entry.deinit(frame._page);
|
||||
}
|
||||
self._pending_entries.clearRetainingCapacity();
|
||||
self._previous_states.clearRetainingCapacity();
|
||||
self._tracked.clearRetainingCapacity();
|
||||
|
||||
if (self._observing.items.len > 0) {
|
||||
frame.unregisterIntersectionObserver(self);
|
||||
@@ -199,6 +199,7 @@ pub fn takeRecords(self: *IntersectionObserver, frame: *Frame) ![]*IntersectionO
|
||||
fn calculateIntersection(
|
||||
self: *IntersectionObserver,
|
||||
target: *Element,
|
||||
has_parent: bool,
|
||||
frame: *Frame,
|
||||
) !IntersectionData {
|
||||
const target_rect = target.getBoundingClientRect(frame);
|
||||
@@ -219,15 +220,13 @@ fn calculateIntersection(
|
||||
// This avoids fingerprinting issues (massive viewports) and matches the behavior
|
||||
// scripts expect when querying element visibility.
|
||||
// However, elements without a parent cannot intersect (they have no containing block).
|
||||
const has_parent = target.asNode().parentNode() != null;
|
||||
const is_intersecting = has_parent;
|
||||
const intersection_ratio: f64 = if (has_parent) 1.0 else 0.0;
|
||||
|
||||
// Intersection rect is the same as the target rect if visible, otherwise zero rect
|
||||
const intersection_rect = if (has_parent) target_rect else zero_rect;
|
||||
|
||||
return .{
|
||||
.is_intersecting = is_intersecting,
|
||||
.is_intersecting = has_parent,
|
||||
.intersection_ratio = intersection_ratio,
|
||||
.intersection_rect = intersection_rect,
|
||||
.bounding_client_rect = target_rect,
|
||||
@@ -253,37 +252,40 @@ fn meetsThreshold(self: *IntersectionObserver, ratio: f64) bool {
|
||||
}
|
||||
|
||||
fn checkIntersection(self: *IntersectionObserver, target: *Element, frame: *Frame) !void {
|
||||
const data = try self.calculateIntersection(target, frame);
|
||||
const was_intersecting_opt = self._previous_states.get(target);
|
||||
const is_now_intersecting = data.is_intersecting and self.meetsThreshold(data.intersection_ratio);
|
||||
const tracked = self._tracked.getEntry(target) orelse {
|
||||
// If target isn't tracked then it was already reported. This is an
|
||||
// important optimization which lets us skip the heavy work below.
|
||||
// This function can be called a lot.
|
||||
return;
|
||||
};
|
||||
|
||||
// Create entry if:
|
||||
// 1. First time observing this target AND it's intersecting
|
||||
// 2. State changed
|
||||
const should_report = (was_intersecting_opt == null and is_now_intersecting) or
|
||||
(was_intersecting_opt != null and was_intersecting_opt.? != is_now_intersecting);
|
||||
|
||||
if (should_report) {
|
||||
const arena = try frame.getArena(.tiny, "IntersectionObserverEntry");
|
||||
errdefer frame.releaseArena(arena);
|
||||
|
||||
const entry = try arena.create(IntersectionObserverEntry);
|
||||
entry.* = .{
|
||||
._arena = arena,
|
||||
._target = target,
|
||||
._time = frame.window._performance.now(),
|
||||
._is_intersecting = is_now_intersecting,
|
||||
._root_bounds = try frame._factory.create(data.root_bounds),
|
||||
._intersection_rect = try frame._factory.create(data.intersection_rect),
|
||||
._bounding_client_rect = try frame._factory.create(data.bounding_client_rect),
|
||||
._intersection_ratio = data.intersection_ratio,
|
||||
};
|
||||
try self._pending_entries.append(self._arena, entry);
|
||||
const has_parent = target.asNode().parentNode() != null;
|
||||
const is_now_intersecting = has_parent and self.meetsThreshold(1.0);
|
||||
if (!is_now_intersecting) {
|
||||
// Not intersecting yet (e.g. observed while still orphaned) — keep
|
||||
// tracking so a later attach is reported.
|
||||
return;
|
||||
}
|
||||
|
||||
// Always update the previous state, even if we didn't report
|
||||
// This ensures we can detect state changes on subsequent checks
|
||||
try self._previous_states.put(self._arena, target, is_now_intersecting);
|
||||
// Building the entry is the expensive part — getBoundingClientRect walks the
|
||||
// document to fake a position (O(node count)) — so it only runs here.
|
||||
const data = try self.calculateIntersection(target, has_parent, frame);
|
||||
const arena = try frame.getArena(.tiny, "IntersectionObserverEntry");
|
||||
errdefer frame.releaseArena(arena);
|
||||
|
||||
const entry = try arena.create(IntersectionObserverEntry);
|
||||
entry.* = .{
|
||||
._arena = arena,
|
||||
._target = target,
|
||||
._time = frame.window._performance.now(),
|
||||
._is_intersecting = is_now_intersecting,
|
||||
._root_bounds = try frame._factory.create(data.root_bounds),
|
||||
._intersection_rect = try frame._factory.create(data.intersection_rect),
|
||||
._bounding_client_rect = try frame._factory.create(data.bounding_client_rect),
|
||||
._intersection_ratio = data.intersection_ratio,
|
||||
};
|
||||
try self._pending_entries.append(self._arena, entry);
|
||||
_ = self._tracked.removeByPtr(tracked.key_ptr);
|
||||
}
|
||||
|
||||
pub fn checkIntersections(self: *IntersectionObserver, frame: *Frame) !void {
|
||||
|
||||
@@ -479,7 +479,7 @@ pub fn getRootNode(self: *Node, opts_: ?GetRootNodeOpts) *Node {
|
||||
// If composed is true, traverse through shadow boundaries
|
||||
if (opts.composed) {
|
||||
while (true) {
|
||||
const shadow_root = @constCast(root).is(ShadowRoot) orelse break;
|
||||
const shadow_root = root.is(ShadowRoot) orelse break;
|
||||
root = shadow_root.getHost().asNode();
|
||||
while (root._parent) |parent| {
|
||||
root = parent;
|
||||
@@ -525,6 +525,16 @@ pub fn ownerDocument(self: *const Node, frame: *const Frame) ?*Document {
|
||||
return current._type.document;
|
||||
}
|
||||
|
||||
// A shadow tree's root is a parent-less ShadowRoot fragment; its owner
|
||||
// is the host's owner document.
|
||||
// can't use current.is(ShadowRoot) without @constCast on `current`
|
||||
if (current._type == .document_fragment) {
|
||||
const df = current._type.document_fragment;
|
||||
if (df._type == .shadow_root) {
|
||||
return df._type.shadow_root._host.asNode().ownerDocument(frame);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, this is a detached node. Check if it has a specific owner
|
||||
// document registered (for nodes created via non-main documents).
|
||||
if (frame._node_owner_documents.get(@constCast(self))) |owner| {
|
||||
|
||||
@@ -31,18 +31,42 @@ pub const Mode = enum {
|
||||
closed,
|
||||
};
|
||||
|
||||
pub const SlotAssignment = enum {
|
||||
named,
|
||||
manual,
|
||||
};
|
||||
|
||||
pub const AttachOptions = struct {
|
||||
mode: Mode,
|
||||
delegates_focus: bool = false,
|
||||
slot_assignment: SlotAssignment = .named,
|
||||
clonable: bool = false,
|
||||
serializable: bool = false,
|
||||
declarative: bool = false,
|
||||
};
|
||||
|
||||
_proto: *DocumentFragment,
|
||||
_mode: Mode,
|
||||
_host: *Element,
|
||||
_delegates_focus: bool,
|
||||
_slot_assignment: SlotAssignment,
|
||||
_clonable: bool,
|
||||
_serializable: bool,
|
||||
_declarative: bool,
|
||||
_elements_by_id: std.StringHashMapUnmanaged(*Element) = .{},
|
||||
_removed_ids: std.StringHashMapUnmanaged(void) = .{},
|
||||
_adopted_style_sheets: ?js.Object.Global = null,
|
||||
|
||||
pub fn init(host: *Element, mode: Mode, frame: *Frame) !*ShadowRoot {
|
||||
pub fn init(host: *Element, opts: AttachOptions, frame: *Frame) !*ShadowRoot {
|
||||
return frame._factory.documentFragment(ShadowRoot{
|
||||
._proto = undefined,
|
||||
._mode = mode,
|
||||
._mode = opts.mode,
|
||||
._host = host,
|
||||
._delegates_focus = opts.delegates_focus,
|
||||
._slot_assignment = opts.slot_assignment,
|
||||
._clonable = opts.clonable,
|
||||
._serializable = opts.serializable,
|
||||
._declarative = opts.declarative,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +90,22 @@ pub fn getHost(self: *const ShadowRoot) *Element {
|
||||
return self._host;
|
||||
}
|
||||
|
||||
pub fn getDelegatesFocus(self: *const ShadowRoot) bool {
|
||||
return self._delegates_focus;
|
||||
}
|
||||
|
||||
pub fn getSlotAssignment(self: *const ShadowRoot) []const u8 {
|
||||
return @tagName(self._slot_assignment);
|
||||
}
|
||||
|
||||
pub fn getClonable(self: *const ShadowRoot) bool {
|
||||
return self._clonable;
|
||||
}
|
||||
|
||||
pub fn getSerializable(self: *const ShadowRoot) bool {
|
||||
return self._serializable;
|
||||
}
|
||||
|
||||
pub fn setHTMLUnsafe(self: *ShadowRoot, html: []const u8, frame: *Frame) !void {
|
||||
return self.asDocumentFragment().setHTMLUnsafe(html, frame);
|
||||
}
|
||||
@@ -125,6 +165,10 @@ pub const JsApi = struct {
|
||||
|
||||
pub const mode = bridge.accessor(ShadowRoot.getMode, null, .{});
|
||||
pub const host = bridge.accessor(ShadowRoot.getHost, null, .{});
|
||||
pub const delegatesFocus = bridge.accessor(ShadowRoot.getDelegatesFocus, null, .{});
|
||||
pub const slotAssignment = bridge.accessor(ShadowRoot.getSlotAssignment, null, .{});
|
||||
pub const clonable = bridge.accessor(ShadowRoot.getClonable, null, .{});
|
||||
pub const serializable = bridge.accessor(ShadowRoot.getSerializable, null, .{});
|
||||
pub const getElementById = bridge.function(_getElementById, .{});
|
||||
fn _getElementById(self: *ShadowRoot, value_: ?js.Value, frame: *Frame) !?*Element {
|
||||
const value = value_ orelse return null;
|
||||
|
||||
@@ -1525,7 +1525,8 @@ pub const Build = struct {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cloned(source_element: *Element, cloned_element: *Element, _: *Frame) !void {
|
||||
pub fn cloned(source_element: *Element, cloned_element: *Element, deep: bool, _: *Frame) !void {
|
||||
_ = deep;
|
||||
const source = source_element.as(Input);
|
||||
const clone = cloned_element.as(Input);
|
||||
|
||||
|
||||
@@ -195,21 +195,13 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void {
|
||||
|
||||
const element = self.asElement();
|
||||
|
||||
const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return;
|
||||
const loadable_rels = std.StaticStringMap(void).initComptime(.{
|
||||
.{ "stylesheet", {} },
|
||||
.{ "preload", {} },
|
||||
.{ "modulepreload", {} },
|
||||
});
|
||||
if (loadable_rels.has(rel) == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = element.getAttributeSafe(comptime .wrap("href")) orelse return;
|
||||
if (href.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return;
|
||||
|
||||
// Opt-in fetch for `rel="stylesheet"` — drives `frame.loadExternalStylesheet`,
|
||||
// which fires the load/error event itself. Other rels (preload,
|
||||
// modulepreload) and the disabled case keep the rendering-free stub that
|
||||
@@ -218,6 +210,28 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void {
|
||||
return frame.loadExternalStylesheet(self, href);
|
||||
}
|
||||
|
||||
var queue_load = false;
|
||||
if (std.mem.eql(u8, rel, "preload")) {
|
||||
const as = element.getAttributeSafe(comptime .wrap("as")) orelse "";
|
||||
if (std.ascii.eqlIgnoreCase(as, "script")) {
|
||||
frame.preloadScriptHint(href);
|
||||
}
|
||||
queue_load = true;
|
||||
}
|
||||
|
||||
{
|
||||
// this block just means we don't need to re-check rel for a type we
|
||||
// already processed, e.g. "preload"
|
||||
const loadable_rels = std.StaticStringMap(void).initComptime(.{
|
||||
.{ "stylesheet", {} },
|
||||
.{ "modulepreload", {} },
|
||||
});
|
||||
|
||||
if (queue_load == false and loadable_rels.has(rel) == false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try frame.queueLoad(self._proto);
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,8 @@ pub const Build = struct {
|
||||
|
||||
// Per the HTML spec, the "already started" flag must be propagated to the
|
||||
// clone so that re-inserting a cloned <script> doesn't run it again.
|
||||
pub fn cloned(source_element: *Element, cloned_element: *Element, _: *Frame) !void {
|
||||
pub fn cloned(source_element: *Element, cloned_element: *Element, deep: bool, _: *Frame) !void {
|
||||
_ = deep;
|
||||
const source = source_element.as(Script);
|
||||
const clone = cloned_element.as(Script);
|
||||
clone._executed = source._executed;
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
// 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 Node = @import("../../Node.zig");
|
||||
const Element = @import("../../Element.zig");
|
||||
const HtmlElement = @import("../Html.zig");
|
||||
const DocumentFragment = @import("../../DocumentFragment.zig");
|
||||
|
||||
const HtmlElement = @import("../Html.zig");
|
||||
|
||||
const String = lp.String;
|
||||
|
||||
const Template = @This();
|
||||
|
||||
_proto: *HtmlElement,
|
||||
@@ -15,6 +38,10 @@ _content: *DocumentFragment,
|
||||
pub fn asElement(self: *Template) *Element {
|
||||
return self._proto._proto;
|
||||
}
|
||||
|
||||
pub fn asConstElement(self: *const Template) *const Element {
|
||||
return self._proto._proto;
|
||||
}
|
||||
pub fn asNode(self: *Template) *Node {
|
||||
return self.asElement().asNode();
|
||||
}
|
||||
@@ -27,6 +54,36 @@ pub fn setInnerHTML(self: *Template, html: []const u8, frame: *Frame) !void {
|
||||
return self._content.setInnerHTML(html, frame);
|
||||
}
|
||||
|
||||
pub fn getShadowRootMode(self: *const Template) []const u8 {
|
||||
const value = self.asConstElement().getAttributeSafe(.wrap("shadowrootmode")) orelse return "";
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(value, "open")) {
|
||||
return "open";
|
||||
}
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(value, "closed")) {
|
||||
return "closed";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
pub fn setShadowRootMode(self: *Template, value: []const u8, frame: *Frame) !void {
|
||||
try self.asElement().setAttributeSafe(.wrap("shadowrootmode"), .wrap(value), frame);
|
||||
}
|
||||
|
||||
fn getBoolAttribute(self: *const Template, name: String) bool {
|
||||
return self.asConstElement().getAttributeSafe(name) != null;
|
||||
}
|
||||
|
||||
fn setBoolAttribute(self: *Template, name: String, value: bool, frame: *Frame) !void {
|
||||
if (value) {
|
||||
try self.asElement().setAttributeSafe(name, .wrap(""), frame);
|
||||
} else {
|
||||
try self.asElement().removeAttribute(name, frame);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getOuterHTML(self: *Template, writer: *std.Io.Writer, frame: *Frame) !void {
|
||||
const dump = @import("../../../dump.zig");
|
||||
const el = self.asElement();
|
||||
@@ -50,6 +107,29 @@ pub const JsApi = struct {
|
||||
pub const content = bridge.accessor(Template.getContent, null, .{});
|
||||
pub const innerHTML = bridge.accessor(_getInnerHTML, Template.setInnerHTML, .{ .ce_reactions = true });
|
||||
pub const outerHTML = bridge.accessor(_getOuterHTML, null, .{});
|
||||
pub const shadowRootMode = bridge.accessor(Template.getShadowRootMode, Template.setShadowRootMode, .{ .ce_reactions = true });
|
||||
pub const shadowRootDelegatesFocus = bridge.accessor(_getShadowRootDelegatesFocus, _setShadowRootDelegatesFocus, .{ .ce_reactions = true });
|
||||
pub const shadowRootClonable = bridge.accessor(_getShadowRootClonable, _setShadowRootClonable, .{ .ce_reactions = true });
|
||||
pub const shadowRootSerializable = bridge.accessor(_getShadowRootSerializable, _setShadowRootSerializable, .{ .ce_reactions = true });
|
||||
|
||||
fn _getShadowRootDelegatesFocus(self: *const Template) bool {
|
||||
return self.getBoolAttribute(.wrap("shadowrootdelegatesfocus"));
|
||||
}
|
||||
fn _setShadowRootDelegatesFocus(self: *Template, value: bool, frame: *Frame) !void {
|
||||
try self.setBoolAttribute(.wrap("shadowrootdelegatesfocus"), value, frame);
|
||||
}
|
||||
fn _getShadowRootClonable(self: *const Template) bool {
|
||||
return self.getBoolAttribute(.wrap("shadowrootclonable"));
|
||||
}
|
||||
fn _setShadowRootClonable(self: *Template, value: bool, frame: *Frame) !void {
|
||||
try self.setBoolAttribute(.wrap("shadowrootclonable"), value, frame);
|
||||
}
|
||||
fn _getShadowRootSerializable(self: *const Template) bool {
|
||||
return self.getBoolAttribute(.wrap("shadowrootserializable"));
|
||||
}
|
||||
fn _setShadowRootSerializable(self: *Template, value: bool, frame: *Frame) !void {
|
||||
try self.setBoolAttribute(.wrap("shadowrootserializable"), value, frame);
|
||||
}
|
||||
|
||||
fn _getInnerHTML(self: *Template, frame: *Frame) ![]const u8 {
|
||||
var buf = std.Io.Writer.Allocating.init(frame.call_arena);
|
||||
@@ -70,6 +150,24 @@ pub const Build = struct {
|
||||
// Create the template content DocumentFragment
|
||||
self._content = try DocumentFragment.init(frame);
|
||||
}
|
||||
|
||||
// Per the HTML spec's cloning steps for <template>, a deep clone must
|
||||
// also copy the content fragment (the element itself has no childNodes,
|
||||
// so the generic deep-clone loop won't do it).
|
||||
pub fn cloned(source_element: *Element, cloned_element: *Element, deep: bool, frame: *Frame) !void {
|
||||
if (!deep) {
|
||||
return;
|
||||
}
|
||||
const source = source_element.as(Template);
|
||||
const clone = cloned_element.as(Template);
|
||||
const clone_content = clone._content.asNode();
|
||||
var child_it = source._content.asNode().childrenIterator();
|
||||
while (child_it.next()) |child| {
|
||||
if (try child.cloneNodeForAppending(true, frame)) |cloned_child| {
|
||||
try frame.appendNode(clone_content, cloned_child, .{ .child_already_connected = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../../../testing.zig");
|
||||
|
||||
@@ -421,7 +421,8 @@ pub const JsApi = struct {
|
||||
};
|
||||
|
||||
pub const Build = struct {
|
||||
pub fn cloned(source_element: *Element, cloned_element: *Element, _: *Frame) !void {
|
||||
pub fn cloned(source_element: *Element, cloned_element: *Element, deep: bool, _: *Frame) !void {
|
||||
_ = deep;
|
||||
const source = source_element.as(TextArea);
|
||||
const clone = cloned_element.as(TextArea);
|
||||
clone._value = source._value;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// 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 Event = @import("../Event.zig");
|
||||
const MouseEvent = @import("MouseEvent.zig");
|
||||
const DataTransfer = @import("../DataTransfer.zig");
|
||||
|
||||
const String = lp.String;
|
||||
|
||||
const DragEvent = @This();
|
||||
|
||||
_proto: *MouseEvent,
|
||||
_data_transfer: ?*DataTransfer,
|
||||
|
||||
pub const DragEventOptions = struct {
|
||||
dataTransfer: ?*DataTransfer = null,
|
||||
};
|
||||
|
||||
pub const Options = Event.inheritOptions(DragEvent, DragEventOptions);
|
||||
|
||||
pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*DragEvent {
|
||||
return initWithTrusted(typ, _opts, false, frame);
|
||||
}
|
||||
|
||||
pub fn initTrusted(typ: []const u8, _opts: ?Options, frame: *Frame) !*DragEvent {
|
||||
return initWithTrusted(typ, _opts, true, frame);
|
||||
}
|
||||
|
||||
fn initWithTrusted(typ: []const u8, _opts: ?Options, trusted: bool, frame: *Frame) !*DragEvent {
|
||||
const arena = try frame.getArena(.medium, "DragEvent");
|
||||
errdefer frame.releaseArena(arena);
|
||||
const type_string = try String.init(arena, typ, .{});
|
||||
|
||||
const opts = _opts orelse Options{};
|
||||
|
||||
const event = try frame._factory.mouseEvent(
|
||||
arena,
|
||||
type_string,
|
||||
MouseEvent{
|
||||
._type = .{ .drag_event = undefined },
|
||||
._proto = undefined,
|
||||
._screen_x = opts.screenX,
|
||||
._screen_y = opts.screenY,
|
||||
._client_x = opts.clientX,
|
||||
._client_y = opts.clientY,
|
||||
._ctrl_key = opts.ctrlKey,
|
||||
._shift_key = opts.shiftKey,
|
||||
._alt_key = opts.altKey,
|
||||
._meta_key = opts.metaKey,
|
||||
._button = std.meta.intToEnum(MouseEvent.MouseButton, opts.button) catch return error.TypeError,
|
||||
._buttons = opts.buttons,
|
||||
._related_target = opts.relatedTarget,
|
||||
},
|
||||
DragEvent{
|
||||
._proto = undefined,
|
||||
._data_transfer = opts.dataTransfer,
|
||||
},
|
||||
);
|
||||
|
||||
Event.populatePrototypes(event, opts, trusted);
|
||||
|
||||
// Hold a ref on the DataTransfer so its arena outlives this event even if the
|
||||
// JS wrapper is collected first; released in deinit (mirrors MessageEvent's
|
||||
// Blob handling). The shared refcount lives on the Event base, so reach it
|
||||
// through asEvent() rather than the immediate _proto.
|
||||
if (opts.dataTransfer) |dt| {
|
||||
dt.acquireRef();
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *DragEvent, page: *Page) void {
|
||||
if (self._data_transfer) |dt| {
|
||||
dt.releaseRef(page);
|
||||
}
|
||||
self.asEvent().deinit(page);
|
||||
}
|
||||
|
||||
pub fn acquireRef(self: *DragEvent) void {
|
||||
self.asEvent().acquireRef();
|
||||
}
|
||||
|
||||
pub fn releaseRef(self: *DragEvent, page: *Page) void {
|
||||
self.asEvent()._rc.release(self, page);
|
||||
}
|
||||
|
||||
pub fn asEvent(self: *DragEvent) *Event {
|
||||
return self._proto.asEvent();
|
||||
}
|
||||
|
||||
pub fn getDataTransfer(self: *const DragEvent) ?*DataTransfer {
|
||||
return self._data_transfer;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(DragEvent);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "DragEvent";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const constructor = bridge.constructor(DragEvent.init, .{});
|
||||
pub const dataTransfer = bridge.accessor(DragEvent.getDataTransfer, null, .{});
|
||||
};
|
||||
|
||||
const testing = @import("../../../testing.zig");
|
||||
test "WebApi: DragEvent" {
|
||||
try testing.htmlRunner("event/drag.html", .{});
|
||||
}
|
||||
@@ -21,9 +21,11 @@ const lp = @import("lightpanda");
|
||||
|
||||
const js = @import("../../js/js.zig");
|
||||
const Frame = @import("../../Frame.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
|
||||
const Event = @import("../Event.zig");
|
||||
const UIEvent = @import("UIEvent.zig");
|
||||
const DataTransfer = @import("../DataTransfer.zig");
|
||||
|
||||
const String = lp.String;
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -32,12 +34,13 @@ const InputEvent = @This();
|
||||
|
||||
_proto: *UIEvent,
|
||||
_data: ?[]const u8,
|
||||
// TODO: add dataTransfer
|
||||
_data_transfer: ?*DataTransfer = null,
|
||||
_input_type: []const u8,
|
||||
_is_composing: bool,
|
||||
|
||||
pub const InputEventOptions = struct {
|
||||
data: ?[]const u8 = null,
|
||||
dataTransfer: ?*DataTransfer = null,
|
||||
inputType: ?[]const u8 = null,
|
||||
isComposing: bool = false,
|
||||
};
|
||||
@@ -69,6 +72,7 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool
|
||||
InputEvent{
|
||||
._proto = undefined,
|
||||
._data = if (opts.data) |d| try arena.dupe(u8, d) else null,
|
||||
._data_transfer = opts.dataTransfer,
|
||||
._input_type = if (opts.inputType) |it| try arena.dupe(u8, it) else "",
|
||||
._is_composing = opts.isComposing,
|
||||
},
|
||||
@@ -82,9 +86,31 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool
|
||||
rootevt._cancelable = false;
|
||||
rootevt._composed = true;
|
||||
|
||||
// Hold a ref on the DataTransfer (when present) for this event's lifetime;
|
||||
// released in deinit. Almost always null for input events, but keeps the
|
||||
// refcount protocol intact when it isn't.
|
||||
if (opts.dataTransfer) |dt| {
|
||||
dt.acquireRef();
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *InputEvent, page: *Page) void {
|
||||
if (self._data_transfer) |dt| {
|
||||
dt.releaseRef(page);
|
||||
}
|
||||
self.asEvent().deinit(page);
|
||||
}
|
||||
|
||||
pub fn acquireRef(self: *InputEvent) void {
|
||||
self.asEvent().acquireRef();
|
||||
}
|
||||
|
||||
pub fn releaseRef(self: *InputEvent, page: *Page) void {
|
||||
self.asEvent()._rc.release(self, page);
|
||||
}
|
||||
|
||||
pub fn asEvent(self: *InputEvent) *Event {
|
||||
return self._proto.asEvent();
|
||||
}
|
||||
@@ -93,6 +119,10 @@ pub fn getData(self: *const InputEvent) ?[]const u8 {
|
||||
return self._data;
|
||||
}
|
||||
|
||||
pub fn getDataTransfer(self: *const InputEvent) ?*DataTransfer {
|
||||
return self._data_transfer;
|
||||
}
|
||||
|
||||
pub fn getInputType(self: *const InputEvent) []const u8 {
|
||||
return self._input_type;
|
||||
}
|
||||
@@ -112,6 +142,7 @@ pub const JsApi = struct {
|
||||
|
||||
pub const constructor = bridge.constructor(InputEvent.init, .{});
|
||||
pub const data = bridge.accessor(InputEvent.getData, null, .{});
|
||||
pub const dataTransfer = bridge.accessor(InputEvent.getDataTransfer, null, .{});
|
||||
pub const inputType = bridge.accessor(InputEvent.getInputType, null, .{});
|
||||
pub const isComposing = bridge.accessor(InputEvent.getIsComposing, null, .{});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
@@ -45,6 +45,7 @@ pub const Type = union(enum) {
|
||||
generic,
|
||||
pointer_event: *PointerEvent,
|
||||
wheel_event: *@import("WheelEvent.zig"),
|
||||
drag_event: *@import("DragEvent.zig"),
|
||||
};
|
||||
|
||||
_type: Type,
|
||||
@@ -135,6 +136,7 @@ pub fn is(self: *MouseEvent, comptime T: type) ?*T {
|
||||
.generic => return if (T == MouseEvent) self else null,
|
||||
.pointer_event => |e| return if (T == PointerEvent) e else null,
|
||||
.wheel_event => |e| return if (T == @import("WheelEvent.zig")) e else null,
|
||||
.drag_event => |e| return if (T == @import("DragEvent.zig")) e else null,
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
+190
-82
@@ -60,7 +60,7 @@ pub var sink: ?*const fn (bytes: []const u8) void = null;
|
||||
// synchronizes access to last_log
|
||||
var last_log_lock: Thread.Mutex = .{};
|
||||
|
||||
pub fn enabled(comptime scope: Scope, level: Level) bool {
|
||||
pub fn enabled(scope: Scope, level: Level) bool {
|
||||
if (@intFromEnum(level) < @intFromEnum(opts.level)) {
|
||||
return false;
|
||||
}
|
||||
@@ -96,27 +96,27 @@ pub const Format = enum {
|
||||
pretty,
|
||||
};
|
||||
|
||||
pub fn debug(comptime scope: Scope, comptime msg: []const u8, data: anytype) void {
|
||||
pub fn debug(scope: Scope, msg: []const u8, data: anytype) void {
|
||||
log(scope, .debug, msg, data);
|
||||
}
|
||||
|
||||
pub fn info(comptime scope: Scope, comptime msg: []const u8, data: anytype) void {
|
||||
pub fn info(scope: Scope, msg: []const u8, data: anytype) void {
|
||||
log(scope, .info, msg, data);
|
||||
}
|
||||
|
||||
pub fn warn(comptime scope: Scope, comptime msg: []const u8, data: anytype) void {
|
||||
pub fn warn(scope: Scope, msg: []const u8, data: anytype) void {
|
||||
log(scope, .warn, msg, data);
|
||||
}
|
||||
|
||||
pub fn err(comptime scope: Scope, comptime msg: []const u8, data: anytype) void {
|
||||
pub fn err(scope: Scope, msg: []const u8, data: anytype) void {
|
||||
log(scope, .err, msg, data);
|
||||
}
|
||||
|
||||
pub fn fatal(comptime scope: Scope, comptime msg: []const u8, data: anytype) void {
|
||||
pub fn fatal(scope: Scope, msg: []const u8, data: anytype) void {
|
||||
log(scope, .fatal, msg, data);
|
||||
}
|
||||
|
||||
pub fn log(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype) void {
|
||||
pub fn log(scope: Scope, level: Level, msg: []const u8, data: anytype) void {
|
||||
if (enabled(scope, level) == false) {
|
||||
return;
|
||||
}
|
||||
@@ -144,41 +144,57 @@ pub fn log(comptime scope: Scope, level: Level, comptime msg: []const u8, data:
|
||||
};
|
||||
}
|
||||
|
||||
fn logTo(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype, out: *std.Io.Writer) !void {
|
||||
comptime {
|
||||
// Converts each field of `data` into a runtime Value so that a single copy of
|
||||
// the formatting code (logToErased and below) can do the actual writing.
|
||||
fn logTo(scope: Scope, level: Level, msg: []const u8, data: anytype, out: *std.Io.Writer) !void {
|
||||
const fields = @typeInfo(@TypeOf(data)).@"struct".fields;
|
||||
var kvs: [fields.len]KV = undefined;
|
||||
inline for (fields, 0..) |f, i| {
|
||||
const value = @field(data, f.name);
|
||||
kvs[i] = .{ .key = f.name, .value = Value.init(&value) };
|
||||
}
|
||||
return logToErased(scope, level, msg, &kvs, out);
|
||||
}
|
||||
|
||||
fn logToErased(scope: Scope, level: Level, msg: []const u8, kvs: []const KV, out: *std.Io.Writer) !void {
|
||||
if (builtin.mode == .Debug) {
|
||||
if (msg.len > 30) {
|
||||
@compileError("log msg cannot be more than 30 characters: '" ++ msg ++ "'");
|
||||
std.debug.print("debug-only-panic: log msg cannot be more than 30 characters: {s}", .{msg});
|
||||
@panic("invalid log msg");
|
||||
}
|
||||
for (msg) |b| {
|
||||
switch (b) {
|
||||
'A'...'Z', 'a'...'z', ' ', '0'...'9', '_', '-', '.', '{', '}' => {},
|
||||
else => @compileError("log msg contains an invalid character '" ++ msg ++ "'"),
|
||||
else => {
|
||||
std.debug.print("debug-only-panic: log msg contains an invalid character: {s}", .{msg});
|
||||
@panic("invalid log msg");
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
switch (opts.format) {
|
||||
.logfmt => try logLogfmt(scope, level, msg, data, out),
|
||||
.pretty => try logPretty(scope, level, msg, data, out),
|
||||
.logfmt => try logLogfmt(scope, level, msg, kvs, out),
|
||||
.pretty => try logPretty(scope, level, msg, kvs, out),
|
||||
}
|
||||
out.flush() catch return;
|
||||
}
|
||||
|
||||
fn logLogfmt(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype, writer: *std.Io.Writer) !void {
|
||||
fn logLogfmt(scope: Scope, level: Level, msg: []const u8, kvs: []const KV, writer: *std.Io.Writer) !void {
|
||||
try logLogFmtPrefix(scope, level, msg, writer);
|
||||
inline for (@typeInfo(@TypeOf(data)).@"struct".fields) |f| {
|
||||
const value = @field(data, f.name);
|
||||
if (std.meta.hasMethod(@TypeOf(value), "logFmt")) {
|
||||
try value.logFmt(f.name, LogFormatWriter{ .writer = writer });
|
||||
} else {
|
||||
const key = " " ++ f.name ++ "=";
|
||||
try writer.writeAll(key);
|
||||
try writeValue(.logfmt, value, writer);
|
||||
for (kvs) |kv| {
|
||||
switch (kv.value) {
|
||||
// logFmt implementations write their own complete " key=value" pairs
|
||||
.log_fmt => |f| try f.logFmtFn(f.ptr, kv.key, writer),
|
||||
else => {
|
||||
try writer.print(" {s}=", .{kv.key});
|
||||
try writeErased(.logfmt, kv.value, writer);
|
||||
},
|
||||
}
|
||||
}
|
||||
try writer.writeByte('\n');
|
||||
}
|
||||
|
||||
fn logLogFmtPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8, writer: *std.Io.Writer) !void {
|
||||
fn logLogFmtPrefix(scope: Scope, level: Level, msg: []const u8, writer: *std.Io.Writer) !void {
|
||||
try writer.writeAll("$time=");
|
||||
try writer.print("{d}", .{timestamp(.clock)});
|
||||
|
||||
@@ -188,30 +204,23 @@ fn logLogFmtPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8
|
||||
try writer.writeAll(" $level=");
|
||||
try writer.writeAll(if (level == .err) "error" else @tagName(level));
|
||||
|
||||
const full_msg = comptime blk: {
|
||||
// only wrap msg in quotes if it contains a space
|
||||
const prefix = " $msg=";
|
||||
if (std.mem.indexOfScalar(u8, msg, ' ') == null) {
|
||||
break :blk prefix ++ msg;
|
||||
}
|
||||
break :blk prefix ++ "\"" ++ msg ++ "\"";
|
||||
};
|
||||
try writer.writeAll(full_msg);
|
||||
try writer.writeAll(" $msg=\"");
|
||||
try writer.writeAll(msg);
|
||||
try writer.writeByte('"');
|
||||
}
|
||||
|
||||
fn logPretty(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype, writer: *std.Io.Writer) !void {
|
||||
fn logPretty(scope: Scope, level: Level, msg: []const u8, kvs: []const KV, writer: *std.Io.Writer) !void {
|
||||
try logPrettyPrefix(scope, level, msg, writer);
|
||||
inline for (@typeInfo(@TypeOf(data)).@"struct".fields) |f| {
|
||||
const key = " " ++ f.name ++ " = ";
|
||||
try writer.writeAll(key);
|
||||
try writeValue(.pretty, @field(data, f.name), writer);
|
||||
for (kvs) |kv| {
|
||||
try writer.print(" {s} = ", .{kv.key});
|
||||
try writeErased(.pretty, kv.value, writer);
|
||||
try writer.writeByte('\n');
|
||||
}
|
||||
try writer.writeByte('\n');
|
||||
}
|
||||
|
||||
fn logPrettyPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8, writer: *std.Io.Writer) !void {
|
||||
if (scope == .console and level == .fatal and comptime std.mem.eql(u8, msg, "lightpanda")) {
|
||||
fn logPrettyPrefix(scope: Scope, level: Level, msg: []const u8, writer: *std.Io.Writer) !void {
|
||||
if (scope == .console and level == .fatal) {
|
||||
try writer.writeAll("\x1b[0;104mWARN ");
|
||||
} else {
|
||||
try writer.writeAll(switch (level) {
|
||||
@@ -223,13 +232,15 @@ fn logPrettyPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8
|
||||
});
|
||||
}
|
||||
|
||||
const prefix = @tagName(scope) ++ " : " ++ msg;
|
||||
try writer.writeAll(prefix);
|
||||
try writer.writeAll(@tagName(scope));
|
||||
try writer.writeAll(" : ");
|
||||
try writer.writeAll(msg);
|
||||
|
||||
{
|
||||
// msg.len cannot be > 30, and @tagName(scope).len cannot be > 15
|
||||
// so this is safe
|
||||
const padding = 55 - prefix.len;
|
||||
const prefix_len = @tagName(scope).len + msg.len + 2;
|
||||
const padding = 55 - prefix_len;
|
||||
for (0..padding / 2) |_| {
|
||||
try writer.writeAll(" .");
|
||||
}
|
||||
@@ -242,50 +253,147 @@ fn logPrettyPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8
|
||||
}
|
||||
}
|
||||
|
||||
pub fn writeValue(comptime format: Format, value: anytype, writer: *std.Io.Writer) !void {
|
||||
const T = @TypeOf(value);
|
||||
if (std.meta.hasMethod(T, "format")) {
|
||||
return writer.print("{f}", .{value});
|
||||
}
|
||||
const KV = struct {
|
||||
key: []const u8,
|
||||
value: Value,
|
||||
};
|
||||
|
||||
switch (@typeInfo(T)) {
|
||||
.optional => {
|
||||
if (value) |v| {
|
||||
return writeValue(format, v, writer);
|
||||
}
|
||||
return writer.writeAll("null");
|
||||
},
|
||||
.comptime_int, .int, .comptime_float, .float => {
|
||||
return writer.print("{d}", .{value});
|
||||
},
|
||||
.bool => {
|
||||
return writer.writeAll(if (value) "true" else "false");
|
||||
},
|
||||
.error_set => return writer.writeAll(@errorName(value)),
|
||||
.@"enum" => return writer.writeAll(@tagName(value)),
|
||||
.array => return writeValue(format, &value, writer),
|
||||
.pointer => |ptr| switch (ptr.size) {
|
||||
.slice => switch (ptr.child) {
|
||||
u8 => return writeString(format, value, writer),
|
||||
const Value = union(enum) {
|
||||
null,
|
||||
string: []const u8,
|
||||
int: i64,
|
||||
uint: u64,
|
||||
float32: f32,
|
||||
float64: f64,
|
||||
boolean: bool,
|
||||
formatter: Formatter,
|
||||
log_fmt: LogFmt,
|
||||
|
||||
const Formatter = struct {
|
||||
ptr: *const anyopaque,
|
||||
writeFn: *const fn (ptr: *const anyopaque, writer: *std.Io.Writer) anyerror!void,
|
||||
};
|
||||
|
||||
const LogFmt = struct {
|
||||
ptr: *const anyopaque,
|
||||
// writes one or more complete " key=value" pairs (logfmt only)
|
||||
logFmtFn: *const fn (ptr: *const anyopaque, key: []const u8, writer: *std.Io.Writer) anyerror!void,
|
||||
// value-only formatting, used by the pretty format
|
||||
writeFn: *const fn (ptr: *const anyopaque, writer: *std.Io.Writer) anyerror!void,
|
||||
};
|
||||
|
||||
fn init(vp: anytype) Value {
|
||||
const T = @TypeOf(vp.*);
|
||||
|
||||
if (comptime std.meta.hasMethod(T, "logFmt")) {
|
||||
const Thunk = struct {
|
||||
fn logFmt(ptr: *const anyopaque, key: []const u8, writer: *std.Io.Writer) anyerror!void {
|
||||
const value: *const T = @ptrCast(@alignCast(ptr));
|
||||
return value.logFmt(key, LogFormatWriter{ .writer = writer });
|
||||
}
|
||||
};
|
||||
return .{ .log_fmt = .{
|
||||
.ptr = @ptrCast(vp),
|
||||
.logFmtFn = Thunk.logFmt,
|
||||
.writeFn = writeThunk(T, if (std.meta.hasMethod(T, "format")) "f" else ""),
|
||||
} };
|
||||
}
|
||||
|
||||
if (comptime std.meta.hasMethod(T, "format")) {
|
||||
return formatterValue(vp, "f");
|
||||
}
|
||||
|
||||
switch (@typeInfo(T)) {
|
||||
.optional => {
|
||||
if (vp.*) |_| {
|
||||
return init(&vp.*.?);
|
||||
}
|
||||
return .null;
|
||||
},
|
||||
.comptime_int => {
|
||||
const value = vp.*;
|
||||
if (value >= 0 and value <= std.math.maxInt(u64)) {
|
||||
return .{ .uint = value };
|
||||
}
|
||||
if (value < 0 and value >= std.math.minInt(i64)) {
|
||||
return .{ .int = value };
|
||||
}
|
||||
return .{ .string = std.fmt.comptimePrint("{d}", .{value}) };
|
||||
},
|
||||
.int => |int_info| {
|
||||
if (comptime int_info.bits <= 64) {
|
||||
return if (comptime int_info.signedness == .signed) .{ .int = vp.* } else .{ .uint = vp.* };
|
||||
}
|
||||
return formatterValue(vp, "d");
|
||||
},
|
||||
.comptime_float => return .{ .float64 = vp.* },
|
||||
.float => |float_info| switch (comptime float_info.bits) {
|
||||
32 => return .{ .float32 = vp.* },
|
||||
64 => return .{ .float64 = vp.* },
|
||||
else => return formatterValue(vp, "d"),
|
||||
},
|
||||
.bool => return .{ .boolean = vp.* },
|
||||
.error_set => return .{ .string = @errorName(vp.*) },
|
||||
.@"enum" => return .{ .string = @tagName(vp.*) },
|
||||
.array => |arr| if (comptime arr.child == u8) {
|
||||
return .{ .string = vp };
|
||||
},
|
||||
.pointer => |ptr| switch (comptime ptr.size) {
|
||||
.slice => if (comptime ptr.child == u8) {
|
||||
return .{ .string = vp.* };
|
||||
},
|
||||
.one => switch (@typeInfo(ptr.child)) {
|
||||
.array => |arr| if (comptime arr.child == u8) {
|
||||
return .{ .string = vp.* };
|
||||
},
|
||||
else => return formatterValue(vp, "f"),
|
||||
},
|
||||
else => {},
|
||||
},
|
||||
.one => switch (@typeInfo(ptr.child)) {
|
||||
.array => |arr| if (arr.child == u8) {
|
||||
return writeString(format, value, writer);
|
||||
},
|
||||
else => return writer.print("{f}", .{value}),
|
||||
},
|
||||
.@"union", .@"struct" => return formatterValue(vp, ""),
|
||||
else => {},
|
||||
},
|
||||
.@"union" => return writer.print("{}", .{value}),
|
||||
.@"struct" => return writer.print("{}", .{value}),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
@compileError("cannot log a: " ++ @typeName(T));
|
||||
@compileError("cannot log a: " ++ @typeName(T));
|
||||
}
|
||||
};
|
||||
|
||||
pub fn writeValue(comptime format: Format, value: anytype, writer: *std.Io.Writer) !void {
|
||||
return writeErased(format, Value.init(&value), writer);
|
||||
}
|
||||
|
||||
fn writeString(comptime format: Format, value: []const u8, writer: *std.Io.Writer) !void {
|
||||
fn formatterValue(vp: anytype, comptime spec: []const u8) Value {
|
||||
return .{ .formatter = .{
|
||||
.ptr = @ptrCast(vp),
|
||||
.writeFn = writeThunk(@TypeOf(vp.*), spec),
|
||||
} };
|
||||
}
|
||||
|
||||
// The per-type fallback for values that Value cannot represent as a primitive:
|
||||
fn writeThunk(comptime T: type, comptime spec: []const u8) *const fn (*const anyopaque, *std.Io.Writer) anyerror!void {
|
||||
return struct {
|
||||
fn write(ptr: *const anyopaque, writer: *std.Io.Writer) anyerror!void {
|
||||
const vp: *const T = @ptrCast(@alignCast(ptr));
|
||||
return writer.print("{" ++ spec ++ "}", .{vp.*});
|
||||
}
|
||||
}.write;
|
||||
}
|
||||
|
||||
fn writeErased(format: Format, value: Value, writer: *std.Io.Writer) !void {
|
||||
switch (value) {
|
||||
.null => return writer.writeAll("null"),
|
||||
.string => |s| return writeString(format, s, writer),
|
||||
.int => |n| return writer.print("{d}", .{n}),
|
||||
.uint => |n| return writer.print("{d}", .{n}),
|
||||
.float32 => |n| return writer.print("{d}", .{n}),
|
||||
.float64 => |n| return writer.print("{d}", .{n}),
|
||||
.boolean => |b| return writer.writeAll(if (b) "true" else "false"),
|
||||
.formatter => |f| return f.writeFn(f.ptr, writer),
|
||||
.log_fmt => |f| return f.writeFn(f.ptr, writer),
|
||||
}
|
||||
}
|
||||
|
||||
fn writeString(format: Format, value: []const u8, writer: *std.Io.Writer) !void {
|
||||
if (format == .pretty) {
|
||||
return writer.writeAll(value);
|
||||
}
|
||||
@@ -345,7 +453,7 @@ pub const LogFormatWriter = struct {
|
||||
pub fn write(self: LogFormatWriter, key: []const u8, value: anytype) !void {
|
||||
const writer = self.writer;
|
||||
try writer.print(" {s}=", .{key});
|
||||
try writeValue(.logfmt, value, writer);
|
||||
try writeErased(.logfmt, Value.init(&value), writer);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -385,7 +493,7 @@ test "log: data" {
|
||||
|
||||
{
|
||||
try logTo(.browser, .err, "nope", .{}, &aw.writer);
|
||||
try testing.expectEqual("$time=1739795092929 $scope=browser $level=error $msg=nope\n", aw.written());
|
||||
try testing.expectEqual("$time=1739795092929 $scope=browser $level=error $msg=\"nope\"\n", aw.written());
|
||||
}
|
||||
|
||||
{
|
||||
@@ -422,7 +530,7 @@ test "log: string escape" {
|
||||
var aw = std.Io.Writer.Allocating.init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
|
||||
const prefix = "$time=1739795092929 $scope=app $level=error $msg=test ";
|
||||
const prefix = "$time=1739795092929 $scope=app $level=error $msg=\"test\" ";
|
||||
{
|
||||
try logTo(.app, .err, "test", .{ .string = "hello world" }, &aw.writer);
|
||||
try testing.expectEqual(prefix ++ "string=\"hello world\"\n", aw.written());
|
||||
|
||||
+2
-2
@@ -98,7 +98,7 @@ fn handleOptional(server: anytype, req: protocol.Request, comptime method: []con
|
||||
|
||||
fn handlePing(server: anytype, req: protocol.Request) !void {
|
||||
const id = req.id orelse return;
|
||||
try server.sendResult(id, .{});
|
||||
try server.sendResult(id, struct {}{});
|
||||
}
|
||||
|
||||
const Server = @import("Server.zig");
|
||||
@@ -130,7 +130,7 @@ test "MCP.router - handleMessage - synchronous unit tests" {
|
||||
try handleMessage(server, aa,
|
||||
\\{"jsonrpc":"2.0","id":2,"method":"ping"}
|
||||
);
|
||||
try testing.expectJson(.{ .jsonrpc = "2.0", .id = 2, .result = .{} }, out_alloc.writer.buffered());
|
||||
try testing.expectJson(.{ .jsonrpc = "2.0", .id = 2, .result = struct {}{} }, out_alloc.writer.buffered());
|
||||
out_alloc.writer.end = 0;
|
||||
|
||||
// 3. Tools list
|
||||
|
||||
Reference in new issue
Block a user