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