mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 09:46:05 -04:00
Merge pull request #2785 from lightpanda-io/innerText
webapi: Improve innerText, add outerText
This commit is contained in:
@@ -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 <select>/<optgroup> is skipped
|
||||
continue;
|
||||
}
|
||||
if (table_ctx and isAllAsciiWhitespace(c.getData().str())) {
|
||||
continue;
|
||||
}
|
||||
try writeText(c, state);
|
||||
},
|
||||
.comment, .cdata_section, .processing_instruction => {},
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Display = enum { skip, replaced, @"inline", block, block_p, transparent, table_cell, table_row };
|
||||
|
||||
fn handleChildElement(
|
||||
he: *HtmlElement,
|
||||
tag: Element.Tag,
|
||||
state: *InnerTextState,
|
||||
saw_cell: *bool,
|
||||
saw_row: *bool,
|
||||
) !void {
|
||||
if (he._type == .br) {
|
||||
try state.flushBreaks();
|
||||
try state.writer.writeByte('\n');
|
||||
state.wrote_any = true;
|
||||
state.pre_w = false;
|
||||
state.trim_left = true;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (innerTextDisplay(he, tag)) {
|
||||
// Not rendered: skip the subtree entirely (script/style/metadata/...).
|
||||
.skip => {},
|
||||
// Replaced/embedded content: the subtree is not rendered.
|
||||
.replaced => {},
|
||||
.@"inline", .transparent => try he.collectInnerText(state),
|
||||
.block => {
|
||||
state.requireBreaks(1);
|
||||
try enterBlock(he, tag, state);
|
||||
state.requireBreaks(1);
|
||||
},
|
||||
.block_p => {
|
||||
state.requireBreaks(2);
|
||||
try he.collectInnerText(state);
|
||||
state.requireBreaks(2);
|
||||
},
|
||||
.table_cell => {
|
||||
if (saw_cell.*) {
|
||||
try state.writeSeparator('\t');
|
||||
}
|
||||
saw_cell.* = true;
|
||||
try he.collectInnerText(state);
|
||||
},
|
||||
.table_row => {
|
||||
if (saw_row.*) {
|
||||
try state.writeSeparator('\n');
|
||||
}
|
||||
saw_row.* = true;
|
||||
try he.collectInnerText(state);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into a block, enabling whitespace preservation for <pre> subtrees.
|
||||
fn enterBlock(he: *HtmlElement, tag: Element.Tag, state: *InnerTextState) std.Io.Writer.Error!void {
|
||||
if (tag == .pre and !state.preserve) {
|
||||
state.preserve = true;
|
||||
defer state.preserve = false;
|
||||
return he.collectInnerText(state);
|
||||
}
|
||||
return he.collectInnerText(state);
|
||||
}
|
||||
|
||||
fn innerTextDisplay(he: *HtmlElement, tag: Element.Tag) Display {
|
||||
return switch (he._type) {
|
||||
.br => .@"inline", // handled before this point
|
||||
.p => .block_p,
|
||||
.table_cell => .table_cell,
|
||||
.table_row => .table_row,
|
||||
.table_caption => .block,
|
||||
// Row groups / column groups are transparent: no break of their own.
|
||||
.table_section => .transparent,
|
||||
.table_col => .skip,
|
||||
.option, .optgroup => .block,
|
||||
// Replaced / embedded content whose subtree is not part of innerText.
|
||||
.img, .canvas, .iframe, .embed, .object, .media, .input, .textarea => .replaced,
|
||||
// Never-rendered elements (UA display:none / not parsed as content).
|
||||
.script, .style, .template, .head, .base, .link, .meta, .title, .source, .track, .param, .area, .datalist => .skip,
|
||||
else => if (tag.isHiddenByUaStylesheet())
|
||||
.skip
|
||||
else if (isInnerTextBlockTag(tag))
|
||||
.block
|
||||
else
|
||||
.@"inline",
|
||||
};
|
||||
}
|
||||
|
||||
fn isInnerTextBlockTag(tag: Element.Tag) bool {
|
||||
if (tag.isBlock()) {
|
||||
return true;
|
||||
}
|
||||
return switch (tag) {
|
||||
.summary, .details, .dialog, .menu, .dd, .dt, .form, .li => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn isAllAsciiWhitespace(s: []const u8) bool {
|
||||
for (s) |c| {
|
||||
if (!std.ascii.isWhitespace(c)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn writeText(c: *Node.CData, state: *InnerTextState) !void {
|
||||
const writer = state.writer;
|
||||
|
||||
const s = c.getData().str();
|
||||
if (state.preserve) {
|
||||
if (s.len == 0) {
|
||||
return;
|
||||
}
|
||||
try state.flushBreaks();
|
||||
// Preserve whitespace verbatim, normalising CR / CRLF to LF.
|
||||
var i: usize = 0;
|
||||
while (i < s.len) : (i += 1) {
|
||||
const ch = s[i];
|
||||
if (ch == '\r') {
|
||||
try writer.writeByte('\n');
|
||||
if (i + 1 < s.len and s[i + 1] == '\n') {
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
try writer.writeByte(ch);
|
||||
}
|
||||
}
|
||||
state.wrote_any = true;
|
||||
state.pre_w = false;
|
||||
state.trim_left = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAllAsciiWhitespace(s)) {
|
||||
state.pre_w = state.wrote_any and state.trim_left == false;
|
||||
return;
|
||||
}
|
||||
|
||||
try state.flushBreaks();
|
||||
if (state.pre_w) {
|
||||
try writer.writeByte(' ');
|
||||
}
|
||||
state.pre_w = try c.render(writer, .{ .trim_left = state.trim_left });
|
||||
state.wrote_any = true;
|
||||
state.trim_left = state.pre_w;
|
||||
}
|
||||
|
||||
fn mergeTextNodes(left_node: *Node, right_node: *Node, frame: *Frame) !bool {
|
||||
const left = left_node.is(Node.CData) orelse return false;
|
||||
const right = right_node.is(Node.CData) orelse return false;
|
||||
if (left._type != .text or right._type != .text) return false;
|
||||
|
||||
// both nodes are Text nodes
|
||||
|
||||
const merged = try std.mem.concat(frame.call_arena, u8, &.{ left.getData().str(), right.getData().str() });
|
||||
// set the left node to the merged value
|
||||
try left.setData(merged, frame);
|
||||
|
||||
if (right_node.parentNode()) |p| {
|
||||
// remove right node
|
||||
frame.removeNode(p, right_node, .{ .will_be_reconnected = false });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn renderedTextFragment(value: []const u8, frame: *Frame) ![]Node.NodeOrText {
|
||||
const arena = frame.call_arena;
|
||||
var nodes: std.ArrayList(Node.NodeOrText) = .empty;
|
||||
|
||||
var rest = value;
|
||||
while (true) {
|
||||
const text_end = std.mem.indexOfAny(u8, rest, "\r\n") orelse rest.len;
|
||||
if (text_end > 0) {
|
||||
try nodes.append(arena, .{ .text = rest[0..text_end] });
|
||||
}
|
||||
rest = rest[text_end..];
|
||||
if (rest.len == 0) {
|
||||
return nodes.items;
|
||||
}
|
||||
|
||||
// Each line break becomes one <br>; a CR/LF pair counts as a single
|
||||
// break (so "\r\n" is one <br> but "\n\n" is two).
|
||||
const break_len: usize = if (rest[0] == '\r' and rest.len > 1 and rest[1] == '\n') 2 else 1;
|
||||
|
||||
try nodes.append(arena, .{ .node = try Frame.node_factory.createElementNS(frame, .html, "br", null) });
|
||||
rest = rest[break_len..];
|
||||
}
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(HtmlElement);
|
||||
|
||||
@@ -1360,12 +1631,20 @@ pub const JsApi = struct {
|
||||
|
||||
pub const constructor = bridge.constructor(HtmlElement.construct, .{ .new_target = true });
|
||||
|
||||
pub const innerText = bridge.accessor(_innerText, HtmlElement.setInnerText, .{ .ce_reactions = true });
|
||||
pub const innerText = bridge.accessor(_innerText, _setInnerText, .{ .ce_reactions = true });
|
||||
fn _innerText(self: *HtmlElement, frame: *const Frame) ![]const u8 {
|
||||
var buf = std.Io.Writer.Allocating.init(frame.call_arena);
|
||||
try self.getInnerText(&buf.writer);
|
||||
return buf.written();
|
||||
}
|
||||
fn _setInnerText(self: *HtmlElement, value: js.Value, frame: *Frame) !void {
|
||||
// `[LegacyNullToEmptyString]`: a JS null becomes "", not "null".
|
||||
return self.setInnerText(if (value.isNull()) "" else try value.toZig([]const u8), frame);
|
||||
}
|
||||
pub const outerText = bridge.accessor(_innerText, _setOuterText, .{ .ce_reactions = true, .dom_exception = true });
|
||||
fn _setOuterText(self: *HtmlElement, value: js.Value, frame: *Frame) !void {
|
||||
return self.setOuterText(if (value.isNull()) "" else try value.toZig([]const u8), frame);
|
||||
}
|
||||
pub const insertAdjacentHTML = bridge.function(HtmlElement.insertAdjacentHTML, .{ .dom_exception = true, .ce_reactions = true });
|
||||
pub const click = bridge.function(HtmlElement.click, .{});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user