webapi: moveBefore

Add Element/DocuemntFragment/Document moveBefore. The API is relatively
complicated compared to other node-moving APIs, so there are still some edge
cases. But this implementation should be at a level where having the API fixes
more things than it breaks.
This commit is contained in:
Karl Seguin
2026-06-23 08:12:29 +08:00
parent f61d16aa49
commit ad9bbef2b8
7 changed files with 261 additions and 13 deletions

View File

@@ -110,6 +110,10 @@ pub fn enqueueConnected(self: *Self, frame: *Frame, element: *Element) !void {
try self.route(frame, .{ .connected = element });
}
pub fn enqueueMove(self: *Self, frame: *Frame, element: *Element) !void {
try self.route(frame, .{ .move = element });
}
pub fn enqueueDisconnected(self: *Self, frame: *Frame, element: *Element) !void {
try self.route(frame, .{ .disconnected = element });
}
@@ -143,6 +147,7 @@ pub fn enqueueAttributeChanged(
pub const Reaction = union(enum) {
connected: *Element,
disconnected: *Element,
move: *Element,
adopted: Adopted,
attribute_changed: AttributeChanged,

View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id=parent>
<div id=a></div>
<div id=b></div>
<div id=c></div>
</div>
<script id=moveBefore_reorder>
{
function ids(parent) {
return Array.from(parent.children).map((e) => e.id);
}
const parent = $('#parent');
const a = $('#a');
const b = $('#b');
const c = $('#c');
// moveBefore returns undefined and reorders in place
testing.expectEqual(undefined, parent.moveBefore(c, a));
testing.expectEqual(['c', 'a', 'b'], ids(parent));
// null reference child appends
parent.moveBefore(c, null);
testing.expectEqual(['a', 'b', 'c'], ids(parent));
// moving a node before itself is a no-op
parent.moveBefore(a, a);
testing.expectEqual(['a', 'b', 'c'], ids(parent));
parent.moveBefore(c, c);
testing.expectEqual(['a', 'b', 'c'], ids(parent));
}
</script>
<script id=moveBefore_validation>
{
const parent = $('#parent');
const a = $('#a');
// Missing second argument is a TypeError (child is required but nullable).
testing.expectError('TypeError', () => parent.moveBefore(a));
testing.expectError('TypeError', () => parent.moveBefore(null, null));
testing.expectError('TypeError', () => parent.moveBefore({}, null));
// Reference child whose parent is not `parent` -> NotFoundError.
const other = document.createElement('div');
document.body.appendChild(other);
testing.withError((err) => {
testing.expectEqual('NotFoundError', err.name);
}, () => parent.moveBefore(a, other));
other.remove();
// Moving a node into its own descendant -> HierarchyRequestError.
testing.withError((err) => {
testing.expectEqual('HierarchyRequestError', err.name);
}, () => a.moveBefore(parent, null));
// Connected target into a disconnected parent (different roots) -> throws.
const disconnected = document.createElement('div');
testing.withError((err) => {
testing.expectEqual('HierarchyRequestError', err.name);
}, () => disconnected.moveBefore(a, null));
// A DocumentFragment is neither Element nor CharacterData -> throws.
testing.withError((err) => {
testing.expectEqual('HierarchyRequestError', err.name);
}, () => parent.moveBefore(new DocumentFragment(), null));
}
</script>
<script id=moveBefore_disconnected_same_tree>
{
// A move entirely within one disconnected tree is allowed.
const root = document.createElement('div');
const dest = root.appendChild(document.createElement('div'));
const p = root.appendChild(document.createElement('p'));
dest.moveBefore(p, null);
testing.expectEqual(p, dest.firstChild);
testing.expectEqual(dest, p.parentNode);
}
</script>
<script id=moveBefore_connected_move_callback>
{
// When connectedMoveCallback is defined, an atomic move fires it instead of
// disconnectedCallback/connectedCallback.
let log = [];
class MoveAware extends HTMLElement {
connectedMoveCallback() { log.push('move'); }
connectedCallback() { log.push('connected'); }
disconnectedCallback() { log.push('disconnected'); }
}
customElements.define('mb-move-aware', MoveAware);
const from = document.body.appendChild(document.createElement('div'));
const to = document.body.appendChild(document.createElement('div'));
const el = from.appendChild(document.createElement('mb-move-aware'));
log = [];
to.moveBefore(el, null);
testing.expectEqual(['move'], log);
testing.expectEqual(to, el.parentNode);
from.remove();
to.remove();
}
</script>

View File

@@ -619,6 +619,10 @@ pub fn replaceChildren(self: *Document, nodes: []const Node.NodeOrText, frame: *
return self.asNode().replaceChildren(nodes, frame);
}
pub fn moveBefore(self: *Document, node: js.Value, child: js.Value, frame: *Frame) !void {
return self.asNode().moveBefore(node, child, frame);
}
pub fn elementFromPoint(self: *Document, x: f64, y: f64, frame: *Frame) !?*Element {
// DFS in document order; topmost = last visited element whose rect contains (x, y).
//
@@ -1213,6 +1217,7 @@ pub const JsApi = struct {
pub const importNode = bridge.function(Document.importNode, .{ .dom_exception = true, .ce_reactions = true });
pub const append = bridge.function(Document.append, .{ .dom_exception = true, .ce_reactions = true });
pub const prepend = bridge.function(Document.prepend, .{ .dom_exception = true, .ce_reactions = true });
pub const moveBefore = bridge.function(Document.moveBefore, .{ .dom_exception = true, .ce_reactions = true });
pub const replaceChildren = bridge.function(Document.replaceChildren, .{ .dom_exception = true, .ce_reactions = true });
pub const elementFromPoint = bridge.function(Document.elementFromPoint, .{});
pub const elementsFromPoint = bridge.function(Document.elementsFromPoint, .{});

View File

@@ -146,6 +146,10 @@ pub fn replaceChildren(self: *DocumentFragment, nodes: []const Node.NodeOrText,
return self.asNode().replaceChildren(nodes, frame);
}
pub fn moveBefore(self: *DocumentFragment, node: js.Value, child: js.Value, frame: *Frame) !void {
return self.asNode().moveBefore(node, child, frame);
}
pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, frame: *Frame) !void {
const dump = @import("../dump.zig");
return dump.children(self.asNode(), .{ .shadow = .complete }, writer, frame);
@@ -213,6 +217,7 @@ pub const JsApi = struct {
pub const lastElementChild = bridge.accessor(DocumentFragment.lastElementChild, null, .{});
pub const append = bridge.function(DocumentFragment.append, .{ .dom_exception = true, .ce_reactions = true });
pub const prepend = bridge.function(DocumentFragment.prepend, .{ .dom_exception = true, .ce_reactions = true });
pub const moveBefore = bridge.function(DocumentFragment.moveBefore, .{ .dom_exception = true, .ce_reactions = true });
pub const replaceChildren = bridge.function(DocumentFragment.replaceChildren, .{ .dom_exception = true, .ce_reactions = true });
pub const innerHTML = bridge.accessor(_getInnerHTML, _setInnerHTML, .{ .ce_reactions = true });

View File

@@ -1045,6 +1045,10 @@ pub fn prepend(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !v
}
}
pub fn moveBefore(self: *Element, node: js.Value, child: js.Value, frame: *Frame) !void {
return self.asNode().moveBefore(node, child, frame);
}
pub fn before(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !void {
const node = self.asNode();
const parent = node.parentNode() orelse return;
@@ -2014,6 +2018,7 @@ pub const JsApi = struct {
pub const remove = bridge.function(Element.remove, .{ .ce_reactions = true });
pub const append = bridge.function(Element.append, .{ .dom_exception = true, .ce_reactions = true });
pub const prepend = bridge.function(Element.prepend, .{ .dom_exception = true, .ce_reactions = true });
pub const moveBefore = bridge.function(Element.moveBefore, .{ .dom_exception = true, .ce_reactions = true });
pub const before = bridge.function(Element.before, .{ .dom_exception = true, .ce_reactions = true });
pub const after = bridge.function(Element.after, .{ .dom_exception = true, .ce_reactions = true });
pub const firstElementChild = bridge.accessor(Element.firstElementChild, null, .{});

View File

@@ -692,6 +692,99 @@ pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, frame: *Fra
return old_child;
}
// `node` and `child` are taken as raw js.Values rather than `*Node`/`?*Node`
// because both must be present, and `child` is nullable.
pub fn moveBefore(self: *Node, node_val: js.Value, child_val: js.Value, frame: *Frame) !void {
const node = try node_val.toZig(*Node);
const child: ?*Node = if (child_val.isNullOrUndefined()) null else try child_val.toZig(*Node);
// parent must be a Document, DocumentFragment, or Element node.
switch (self._type) {
.document, .document_fragment, .element => {},
else => return error.HierarchyError,
}
if (node.contains(self)) {
return error.HierarchyError;
}
if (self.getRootNode(.{ .composed = true }) != node.getRootNode(.{ .composed = true })) {
return error.HierarchyError;
}
// node must be an Element or a CharacterData node.
switch (node._type) {
.element, .cdata => {},
else => return error.HierarchyError,
}
if (self._type == .document) {
switch (node._type) {
.cdata => |cd| {
if (cd._type == .text) {
// A Text node cannot be a child of a document.
return error.HierarchyError;
}
},
.element => {
var it = self.childrenIterator();
while (it.next()) |existing| {
if (existing._type == .element and existing != node) {
// A document can have at most one element child.
return error.HierarchyError;
}
}
},
else => {},
}
}
if (child) |c| {
if (c._parent != self) {
// If child is non-null, its parent must be parent.
return error.NotFound;
}
}
// Moving a node before itself is a relative no-op: the reference child
// becomes the node's own next sibling.
var ref = child;
if (ref) |r| {
if (r == node) {
ref = node.nextSibling();
}
}
frame.domChanged();
// selfand node share a root, so the connectedness won't change. This API
// should appear atomic as much as possible. We can skip the id-map
// management (because it won't change) and custom elements shouldn't fire
// disconnect/connected callbacks. But MutationObservers and ranges still
// fire
const connected = node.isConnected();
if (node._parent) |old_parent| {
frame.removeNode(old_parent, node, .{ .will_be_reconnected = connected });
}
if (ref) |r| {
try frame.insertNodeRelative(self, node, .{ .before = r }, .{ .child_already_connected = connected });
} else {
try frame.appendNode(self, node, .{ .child_already_connected = connected });
}
if (connected) {
// Enqueue on a move callback (if we're connected) for any nested
// custom element
const TreeWalker = @import("TreeWalker.zig");
var tw = TreeWalker.Full.Elements.init(node, .{});
while (tw.next()) |el| {
Element.Html.Custom.enqueueMoveCallbackOnElement(el, frame);
}
}
}
pub fn getNodeValue(self: *const Node) ?String {
return switch (self._type) {
.cdata => |c| c.getData(),

View File

@@ -16,6 +16,7 @@
// 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");
@@ -132,6 +133,20 @@ pub fn enqueueDisconnectedCallbackOnElement(element: *Element, frame: *Frame) vo
};
}
// Enqueues an atomic-move reaction (moveBefore). Unlike connect/disconnect there
// is no dedup state to flip: a move always fires, and the element's connected
// state is unchanged by the move.
pub fn enqueueMoveCallbackOnElement(element: *Element, frame: *Frame) void {
if (element.is(Custom)) |custom| {
if (custom._definition == null) return;
} else {
if (frame.getCustomizedBuiltInDefinition(element) == null) return;
}
frame._ce_reactions.enqueueMove(frame, element) catch |err| {
log.warn(.bug, "ce_reactions enqueue fail", .{ .err = err });
};
}
pub fn enqueueAdoptedCallbackOnElement(element: *Element, old_document: *Document, new_document: *Document, frame: *Frame) void {
if (element.is(Custom)) |custom| {
if (custom._definition == null) return;
@@ -163,37 +178,36 @@ pub fn fireReaction(reaction: Reaction, frame: *Frame) void {
.connected => |el| {
if (el.is(Custom)) |custom| {
custom.invokeCallback("connectedCallback", .{}, frame);
} else if (frame.getCustomizedBuiltInDefinition(el)) |definition| {
invokeCallbackOnElement(el, definition, "connectedCallback", .{}, frame);
} else if (frame.getCustomizedBuiltInDefinition(el)) |_| {
invokeCallbackOnElement(el, "connectedCallback", .{}, frame);
}
},
.disconnected => |el| {
if (el.is(Custom)) |custom| {
custom.invokeCallback("disconnectedCallback", .{}, frame);
} else if (frame.getCustomizedBuiltInDefinition(el)) |definition| {
invokeCallbackOnElement(el, definition, "disconnectedCallback", .{}, frame);
} else if (frame.getCustomizedBuiltInDefinition(el)) |_| {
invokeCallbackOnElement(el, "disconnectedCallback", .{}, frame);
}
},
.adopted => |a| {
if (a.element.is(Custom)) |custom| {
custom.invokeCallback("adoptedCallback", .{ a.old_document, a.new_document }, frame);
} else if (frame.getCustomizedBuiltInDefinition(a.element)) |definition| {
invokeCallbackOnElement(a.element, definition, "adoptedCallback", .{ a.old_document, a.new_document }, frame);
} else if (frame.getCustomizedBuiltInDefinition(a.element)) |_| {
invokeCallbackOnElement(a.element, "adoptedCallback", .{ a.old_document, a.new_document }, frame);
}
},
.attribute_changed => |a| {
if (a.element.is(Custom)) |custom| {
custom.invokeCallback("attributeChangedCallback", .{ a.name, a.old_value, a.new_value, a.namespace }, frame);
} else if (frame.getCustomizedBuiltInDefinition(a.element)) |definition| {
invokeCallbackOnElement(a.element, definition, "attributeChangedCallback", .{ a.name, a.old_value, a.new_value, a.namespace }, frame);
} else if (frame.getCustomizedBuiltInDefinition(a.element)) |_| {
invokeCallbackOnElement(a.element, "attributeChangedCallback", .{ a.name, a.old_value, a.new_value, a.namespace }, frame);
}
},
.move => |el| invokeCallbackOnElement(el, "move", .{}, frame),
}
}
fn invokeCallbackOnElement(element: *Element, definition: *CustomElementDefinition, comptime callback_name: [:0]const u8, args: anytype, frame: *Frame) void {
_ = definition;
fn invokeCallbackOnElement(element: *Element, comptime callback_name: [:0]const u8, args: anytype, frame: *Frame) void {
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
@@ -202,8 +216,20 @@ fn invokeCallbackOnElement(element: *Element, definition: *CustomElementDefiniti
const js_val = ls.local.zigValueToJs(element, .{}) catch return;
const js_element = js_val.toObject();
// Call the callback method if it exists
js_element.callMethod(void, callback_name, args) catch return;
if (comptime std.mem.eql(u8, callback_name, "move") == false) {
// Call the callback method if it exists
js_element.callMethod(void, callback_name, args) catch {};
return;
}
// for "move", we call "connectedMoveCallback" if it exists, else we fallback
// to "disconnectedCallback" + "connectedCallback"
if (js_element.has("connectedMoveCallback")) {
js_element.callMethod(void, "connectedMoveCallback", .{}) catch return;
} else {
js_element.callMethod(void, "disconnectedCallback", .{}) catch return;
js_element.callMethod(void, "connectedCallback", .{}) catch return;
}
}
// Check if element has "is" attribute and attach customized built-in definition