From 6aa4d50b33b62a54b63cfee0694830fd4e58a6d5 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 19 Jun 2026 06:58:10 +0800 Subject: [PATCH] webapi: Improve innerText, add outerText Aimed at improving correctness of the innerText getter and setter. Driven by a number of WPT tests, largely in html/dom/elements/the-innertext-and-outertext-properties/ Meant as a replacement for (1). Though, unlike that PR, this commit does not consider CSS at all (todo in a follow up PR). The change just to get correct space handling is big enough on its own. The short version is that, as we traverse the children, we need to keep various state in order to (for example) avoid double whitespace. Table cells and rows need their own special handling (tabs and newlines inserted between items), pre needs to preserve whitespace, etc. This also adds outerText which, for the getter, is identical to innerText and for the setter, can reuse Element.replaceWith, except for special whitespace handling at its edges. (1) https://github.com/lightpanda-io/browser/pull/2741 --- src/browser/webapi/element/Html.zig | 411 +++++++++++++++++++++++----- 1 file changed, 345 insertions(+), 66 deletions(-) diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 610c7afcd..6549384ab 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -216,80 +216,55 @@ pub fn asEventTarget(self: *HtmlElement) *@import("../EventTarget.zig") { return self._proto._proto._proto; } -// innerText represents the **rendered** text content of a node and its -// descendants. pub fn getInnerText(self: *HtmlElement, writer: *std.Io.Writer) !void { - var state = innerTextState{}; - return try self._getInnerText(writer, &state); -} - -const innerTextState = struct { - pre_w: bool = false, - trim_left: bool = true, -}; - -fn _getInnerText(self: *HtmlElement, writer: *std.Io.Writer, state: *innerTextState) !void { - var it = self.asElement().asNode().childrenIterator(); - while (it.next()) |child| { - switch (child._type) { - .element => |e| switch (e._type) { - .html => |he| switch (he._type) { - .br => { - try writer.writeByte('\n'); - state.pre_w = false; // prevent a next pre space. - state.trim_left = true; - }, - .script, .style, .template => { - state.pre_w = false; // prevent a next pre space. - state.trim_left = true; - }, - else => try he._getInnerText(writer, state), // TODO check if elt is hidden. - }, - .svg => {}, - }, - .cdata => |c| switch (c._type) { - .comment => { - state.pre_w = false; // prevent a next pre space. - state.trim_left = true; - }, - .text => { - if (state.pre_w) try writer.writeByte(' '); - state.pre_w = try c.render(writer, .{ .trim_left = state.trim_left }); - // if we had a pre space, trim left next one. - state.trim_left = state.pre_w; - }, - // CDATA sections should not be used within HTML. They are - // considered comments and are not displayed. - .cdata_section => {}, - // Processing instructions are not displayed in innerText - .processing_instruction => {}, - }, - .document => {}, - .document_type => {}, - .document_fragment => {}, - .attribute => |attr| try writer.writeAll(attr._value.str()), - } + const tag = self.asElement().getTag(); + switch (innerTextDisplay(self, tag)) { + .skip, .replaced => return, + else => {}, } + var state = InnerTextState{ .writer = writer, .preserve = tag == .pre }; + try self.collectInnerText(&state); } pub fn setInnerText(self: *HtmlElement, text: []const u8, frame: *Frame) !void { - const parent = self.asElement().asNode(); + const items = try renderedTextFragment(text, frame); + try self.asElement().replaceChildren(items, frame); +} - // Remove all existing children - frame.domChanged(); - var it = parent.childrenIterator(); - while (it.next()) |child| { - frame.removeNode(parent, child, .{ .will_be_reconnected = false }); +pub fn setOuterText(self: *HtmlElement, text: []const u8, frame: *Frame) !void { + const el = self.asElement(); + const node = el.asNode(); + const parent = node.parentNode() orelse return error.NoModificationAllowed; + + const prev = node.previousSibling(); + const next = node.nextSibling(); + + var items: []const Node.NodeOrText = try renderedTextFragment(text, frame); + if (items.len == 0) { + // A fragment with no node still replaces the element with an empty Text + // node so surrounding text can merge with it. + items = &.{.{ .text = "" }}; + } + try el.replaceWith(items, frame); + + // Merge with the immediately neighbouring Text nodes (only those two; the + // tree is not fully normalised, per spec). + const first = if (prev) |p| p.nextSibling() else parent.firstChild(); + var last = if (next) |n| n.previousSibling() else parent.lastChild(); + + if (prev) |p| { + if (first) |f| { + if (mergeTextNodes(p, f, frame) catch false) { + if (f == last) last = p; + } + } } - // Fast path: skip if text is empty - if (text.len == 0) { - return; + if (next) |n| { + if (last) |l| { + _ = mergeTextNodes(l, n, frame) catch false; + } } - - // Create and append text node - const text_node = try Frame.node_factory.createTextNode(frame, text); - try frame.appendNode(parent, text_node, .{ .child_already_connected = false }); } pub fn insertAdjacentHTML( @@ -1349,6 +1324,302 @@ pub fn reflectEnumerated( return invalid; } +const InnerTextState = struct { + writer: *std.Io.Writer, + // number of line breaks we've accumulated for the block. Emitted lazily that + // leading/trailing breaks aren't written and so that we can emit the max + // requested, which can change as we render children. + pending_breaks: u8 = 0, + + // suppresses leading line breaks if nothing has been written yet + wrote_any: bool = false, + + // A collapsible space is pending from a previous text run, it should only + // be written if there's more to write + pre_w: bool = false, + + // Trim leading whitespace of the next text run. + trim_left: bool = true, + + // preserve whitespace (pre tag, in the future white-space: pre) + preserve: bool = false, + + fn requireBreaks(self: *InnerTextState, n: u8) void { + if (n > self.pending_breaks) { + self.pending_breaks = n; + } + } + + fn flushBreaks(self: *InnerTextState) !void { + if (self.pending_breaks == 0) { + return; + } + if (self.wrote_any) { + for (0..self.pending_breaks) |_| { + try self.writer.writeByte('\n'); + } + } + self.pending_breaks = 0; + // A block boundary trims the whitespace on either side of it. + self.pre_w = false; + self.trim_left = true; + } + + // Emit a literal separator (table cell tab / row newline). + fn writeSeparator(self: *InnerTextState, ch: u8) !void { + try self.flushBreaks(); + try self.writer.writeByte(ch); + self.wrote_any = true; + self.pre_w = false; + self.trim_left = true; + } +}; + +const ChildFilter = enum { none, select, optgroup }; + +fn collectInnerText(self: *HtmlElement, state: *InnerTextState) std.Io.Writer.Error!void { + const el = self.asElement(); + const self_tag = el.getTag(); + + // special filtering type applied to children based on the tag + const child_filter: ChildFilter = switch (self_tag) { + .select => .select, + .optgroup => .optgroup, + else => .none, + }; + + // Inside table containers, inter-cell/row whitespace text is not rendered. + const table_ctx = switch (self_tag) { + .table, .tbody, .thead, .tfoot, .tr => true, + else => false, + }; + + // Track whether we've already emitted a cell/row in this parent so we can + // insert tab/newline separators *between* them. + var saw_row = false; + var saw_cell = false; + + var it = el.asNode().childrenIterator(); + while (it.next()) |child| { + switch (child._type) { + .element => |e| switch (e._type) { + .svg => {}, + .html => |he| { + const tag = e.getTag(); + switch (child_filter) { + .none => {}, + .select => if (tag != .option and tag != .optgroup) continue, + .optgroup => if (tag != .option) continue, + } + try handleChildElement(he, tag, state, &saw_cell, &saw_row); + }, + }, + .cdata => |c| switch (c._type) { + .text => { + if (child_filter != .none) { + // Text directly inside