diff --git a/src/Config.zig b/src/Config.zig index 39422791..ddef91be 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -182,9 +182,14 @@ pub const Serve = struct { common: Common = .{}, }; +pub const DumpFormat = enum { + html, + markdown, +}; + pub const Fetch = struct { url: [:0]const u8, - dump: bool = false, + dump_mode: ?DumpFormat = null, common: Common = .{}, withbase: bool = false, strip: dump.Opts.Strip = .{}, @@ -321,11 +326,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ \\fetch command \\Fetches the specified URL - \\Example: {s} fetch --dump https://lightpanda.io/ + \\Example: {s} fetch --dump html https://lightpanda.io/ \\ \\Options: \\--dump Dumps document to stdout. - \\ Defaults to false. + \\ Argument must be 'html' or 'markdown'. + \\ Defaults to no dump. \\ \\--strip_mode Comma separated list of tag groups to remove from dump \\ the dump. e.g. --strip_mode js,css @@ -532,7 +538,7 @@ fn parseFetchArgs( allocator: Allocator, args: *std.process.ArgIterator, ) !Fetch { - var fetch_dump: bool = false; + var dump_mode: ?DumpFormat = null; var withbase: bool = false; var url: ?[:0]const u8 = null; var common: Common = .{}; @@ -540,7 +546,17 @@ fn parseFetchArgs( while (args.next()) |opt| { if (std.mem.eql(u8, "--dump", opt)) { - fetch_dump = true; + var peek_args = args.*; + if (peek_args.next()) |next_arg| { + if (std.meta.stringToEnum(DumpFormat, next_arg)) |mode| { + dump_mode = mode; + _ = args.next(); + } else { + dump_mode = .html; + } + } else { + dump_mode = .html; + } continue; } @@ -607,7 +623,7 @@ fn parseFetchArgs( return .{ .url = url.?, - .dump = fetch_dump, + .dump_mode = dump_mode, .strip = strip, .common = common, .withbase = withbase, diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 389cb08b..640d6bf5 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -262,6 +262,8 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: // and default actions execute (unless prevented) defer { event._event_phase = .none; + event._stop_propagation = false; + event._stop_immediate_propagation = false; // Handle checkbox/radio activation rollback or commit if (activation_state) |state| { state.restore(event, page); @@ -322,19 +324,18 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: var i: usize = path_len; while (i > 1) { i -= 1; + if (event._stop_propagation) return; const current_target = path[i]; if (self.lookup.get(.{ .event_target = @intFromPtr(current_target), .type_string = event._type_string, })) |list| { try self.dispatchPhase(list, current_target, event, was_handled, true); - if (event._stop_propagation) { - return; - } } } // Phase 2: At target + if (event._stop_propagation) return; event._event_phase = .at_target; const target_et = target.asEventTarget(); @@ -375,14 +376,12 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: if (event._bubbles) { event._event_phase = .bubbling_phase; for (path[1..]) |current_target| { + if (event._stop_propagation) break; if (self.lookup.get(.{ .type_string = event._type_string, .event_target = @intFromPtr(current_target), })) |list| { try self.dispatchPhase(list, current_target, event, was_handled, false); - if (event._stop_propagation) { - break; - } } } } diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig new file mode 100644 index 00000000..fcbac478 --- /dev/null +++ b/src/browser/markdown.zig @@ -0,0 +1,506 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); + +const Page = @import("Page.zig"); +const Element = @import("webapi/Element.zig"); +const Node = @import("webapi/Node.zig"); + +pub const Opts = struct { + // Options for future customization (e.g., dialect) +}; + +const State = struct { + const ListType = enum { ordered, unordered }; + const ListState = struct { + type: ListType, + index: usize, + }; + + list_depth: usize = 0, + list_stack: [32]ListState = undefined, + in_pre: bool = false, + pre_node: ?*Node = null, + in_code: bool = false, + in_table: bool = false, + table_row_index: usize = 0, + table_col_count: usize = 0, + last_char_was_newline: bool = true, +}; + +fn isBlock(tag: Element.Tag) bool { + return switch (tag) { + .p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => true, + else => false, + }; +} + +fn shouldAddSpacing(tag: Element.Tag) bool { + return switch (tag) { + .p, .h1, .h2, .h3, .h4, .h5, .h6, .blockquote, .pre, .table => true, + else => false, + }; +} + +fn ensureNewline(state: *State, writer: *std.Io.Writer) !void { + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + state.last_char_was_newline = true; + } +} + +pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void { + _ = opts; + var state = State{}; + try render(node, &state, writer, page); + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + } +} + +fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void { + switch (node._type) { + .document, .document_fragment => { + try renderChildren(node, state, writer, page); + }, + .element => |el| { + try renderElement(el, state, writer, page); + }, + .cdata => |cd| { + if (node.is(Node.CData.Text)) |_| { + var text = cd.getData(); + if (state.in_pre) { + if (state.pre_node) |pre| { + if (node.parentNode() == pre and node.nextSibling() == null) { + text = std.mem.trimRight(u8, text, " \t\r\n"); + } + } + } + try renderText(text, state, writer); + } + }, + else => {}, // Ignore other node types + } +} + +fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) !void { + var it = parent.childrenIterator(); + while (it.next()) |child| { + try render(child, state, writer, page); + } +} + +fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) !void { + const tag = el.getTag(); + + // Skip hidden/metadata elements + switch (tag) { + .script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => return, + else => {}, + } + + // --- Opening Tag Logic --- + + // Ensure block elements start on a new line (double newline for paragraphs etc) + if (isBlock(tag)) { + if (!state.in_table) { + try ensureNewline(state, writer); + if (shouldAddSpacing(tag)) { + // Add an extra newline for spacing between blocks + try writer.writeByte('\n'); + } + } + } else if (tag == .li or tag == .tr) { + try ensureNewline(state, writer); + } + + // Prefixes + switch (tag) { + .h1 => try writer.writeAll("# "), + .h2 => try writer.writeAll("## "), + .h3 => try writer.writeAll("### "), + .h4 => try writer.writeAll("#### "), + .h5 => try writer.writeAll("##### "), + .h6 => try writer.writeAll("###### "), + .ul => { + if (state.list_depth < state.list_stack.len) { + state.list_stack[state.list_depth] = .{ .type = .unordered, .index = 0 }; + state.list_depth += 1; + } + }, + .ol => { + if (state.list_depth < state.list_stack.len) { + state.list_stack[state.list_depth] = .{ .type = .ordered, .index = 1 }; + state.list_depth += 1; + } + }, + .li => { + const indent = if (state.list_depth > 0) state.list_depth - 1 else 0; + for (0..indent) |_| try writer.writeAll(" "); + + if (state.list_depth > 0) { + const current_list = &state.list_stack[state.list_depth - 1]; + if (current_list.type == .ordered) { + try writer.print("{d}. ", .{current_list.index}); + current_list.index += 1; + } else { + try writer.writeAll("- "); + } + } else { + try writer.writeAll("- "); + } + state.last_char_was_newline = false; + }, + .table => { + state.in_table = true; + state.table_row_index = 0; + state.table_col_count = 0; + }, + .tr => { + state.table_col_count = 0; + try writer.writeByte('|'); + }, + .td, .th => { + // Note: leading pipe handled by previous cell closing or tr opening + state.last_char_was_newline = false; + try writer.writeByte(' '); + }, + .blockquote => { + try writer.writeAll("> "); + state.last_char_was_newline = false; + }, + .pre => { + try writer.writeAll("```\n"); + state.in_pre = true; + state.pre_node = el.asNode(); + state.last_char_was_newline = true; + }, + .code => { + if (!state.in_pre) { + try writer.writeByte('`'); + state.in_code = true; + state.last_char_was_newline = false; + } + }, + .b, .strong => { + try writer.writeAll("**"); + state.last_char_was_newline = false; + }, + .i, .em => { + try writer.writeAll("*"); + state.last_char_was_newline = false; + }, + .s, .del => { + try writer.writeAll("~~"); + state.last_char_was_newline = false; + }, + .hr => { + try writer.writeAll("---\n"); + state.last_char_was_newline = true; + return; // Void element + }, + .br => { + if (state.in_table) { + try writer.writeByte(' '); + } else { + try writer.writeByte('\n'); + state.last_char_was_newline = true; + } + return; // Void element + }, + .img => { + try writer.writeAll("!["); + if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| { + try escapeMarkdown(writer, alt); + } + try writer.writeAll("]("); + if (el.getAttributeSafe(comptime .wrap("src"))) |src| { + try writer.writeAll(src); + } + try writer.writeAll(")"); + state.last_char_was_newline = false; + return; // Treat as void + }, + .anchor => { + try writer.writeByte('['); + try renderChildren(el.asNode(), state, writer, page); + try writer.writeAll("]("); + if (el.getAttributeSafe(comptime .wrap("href"))) |href| { + try writer.writeAll(href); + } + try writer.writeByte(')'); + state.last_char_was_newline = false; + return; + }, + .input => { + if (el.getAttributeSafe(comptime .wrap("type"))) |type_attr| { + if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) { + if (el.getAttributeSafe(comptime .wrap("checked"))) |_| { + try writer.writeAll("[x] "); + } else { + try writer.writeAll("[ ] "); + } + state.last_char_was_newline = false; + } + } + return; + }, + else => {}, + } + + // --- Render Children --- + try renderChildren(el.asNode(), state, writer, page); + + // --- Closing Tag Logic --- + + // Suffixes + switch (tag) { + .pre => { + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + } + try writer.writeAll("```\n"); + state.in_pre = false; + state.pre_node = null; + state.last_char_was_newline = true; + }, + .code => { + if (!state.in_pre) { + try writer.writeByte('`'); + state.in_code = false; + state.last_char_was_newline = false; + } + }, + .b, .strong => { + try writer.writeAll("**"); + state.last_char_was_newline = false; + }, + .i, .em => { + try writer.writeAll("*"); + state.last_char_was_newline = false; + }, + .s, .del => { + try writer.writeAll("~~"); + state.last_char_was_newline = false; + }, + .blockquote => {}, + .ul, .ol => { + if (state.list_depth > 0) state.list_depth -= 1; + }, + .table => { + state.in_table = false; + }, + .tr => { + try writer.writeByte('\n'); + if (state.table_row_index == 0) { + try writer.writeByte('|'); + var i: usize = 0; + while (i < state.table_col_count) : (i += 1) { + try writer.writeAll("---|"); + } + try writer.writeByte('\n'); + } + state.table_row_index += 1; + state.last_char_was_newline = true; + }, + .td, .th => { + try writer.writeAll(" |"); + state.table_col_count += 1; + state.last_char_was_newline = false; + }, + else => {}, + } + + // Post-block newlines + if (isBlock(tag)) { + if (!state.in_table) { + try ensureNewline(state, writer); + } + } +} + +fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) !void { + if (text.len == 0) return; + + if (state.in_pre) { + try writer.writeAll(text); + if (text.len > 0 and text[text.len - 1] == '\n') { + state.last_char_was_newline = true; + } else { + state.last_char_was_newline = false; + } + return; + } + + // Check for pure whitespace + const is_all_whitespace = for (text) |c| { + if (!std.ascii.isWhitespace(c)) break false; + } else true; + + if (is_all_whitespace) { + if (!state.last_char_was_newline) { + try writer.writeByte(' '); + } + return; + } + + // Collapse whitespace + var it = std.mem.tokenizeAny(u8, text, " \t\n\r"); + var first = true; + while (it.next()) |word| { + if (first) { + if (!state.last_char_was_newline) { + if (text.len > 0 and std.ascii.isWhitespace(text[0])) { + try writer.writeByte(' '); + } + } + } else { + try writer.writeByte(' '); + } + + try escapeMarkdown(writer, word); + state.last_char_was_newline = false; + first = false; + } + + // Handle trailing whitespace from the original text + if (!first and !state.last_char_was_newline) { + if (text.len > 0 and std.ascii.isWhitespace(text[text.len - 1])) { + try writer.writeByte(' '); + } + } +} + +fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void { + for (text) |c| { + switch (c) { + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => { + try writer.writeByte('\\'); + try writer.writeByte(c); + }, + else => try writer.writeByte(c), + } + } +} + +fn testMarkdownHTML(html: []const u8, expected: []const u8) !void { + const testing = @import("../testing.zig"); + const page = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + const doc = page.window._document; + + const div = try doc.createElement("div", null, page); + try page.parseHtmlAsChildren(div.asNode(), html); + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try dump(div.asNode(), .{}, &aw.writer, page); + + try testing.expectString(expected, aw.written()); +} + +test "markdown: basic" { + try testMarkdownHTML("Hello world", "Hello world\n"); +} + +test "markdown: whitespace" { + try testMarkdownHTML("A B", "A B\n"); +} + +test "markdown: escaping" { + try testMarkdownHTML("

# Not a header

", "\n\\# Not a header\n"); +} + +test "markdown: strikethrough" { + try testMarkdownHTML("deleted", "~~deleted~~\n"); +} + +test "markdown: task list" { + try testMarkdownHTML( + \\ + , "[x] [ ] \n"); +} + +test "markdown: ordered list" { + try testMarkdownHTML( + \\
  1. First
  2. Second
+ , "1. First\n2. Second\n"); +} + +test "markdown: table" { + try testMarkdownHTML( + \\ + \\
Head 1Head 2
Cell 1Cell 2
+ , + \\ + \\| Head 1 | Head 2 | + \\|---|---| + \\| Cell 1 | Cell 2 | + \\ + ); +} + +test "markdown: nested lists" { + try testMarkdownHTML( + \\
  • Parent
    • Child
+ , + \\- Parent + \\ - Child + \\ + ); +} + +test "markdown: blockquote" { + try testMarkdownHTML("
Hello world
", "\n> Hello world\n"); +} + +test "markdown: links" { + try testMarkdownHTML("Lightpanda", "[Lightpanda](https://lightpanda.io)\n"); +} + +test "markdown: images" { + try testMarkdownHTML("\"Logo\"", "![Logo](logo.png)\n"); +} + +test "markdown: headings" { + try testMarkdownHTML("

Title

Subtitle

", + \\ + \\# Title + \\ + \\## Subtitle + \\ + ); +} + +test "markdown: code" { + try testMarkdownHTML( + \\

Use git push

+ \\
line 1
+        \\line 2
+ , + \\ + \\Use git push + \\ + \\``` + \\line 1 + \\line 2 + \\``` + \\ + ); +} diff --git a/src/browser/tests/animation/animation.html b/src/browser/tests/animation/animation.html index 27e562a0..97bfe077 100644 --- a/src/browser/tests/animation/animation.html +++ b/src/browser/tests/animation/animation.html @@ -3,13 +3,67 @@ + + + + + + + + diff --git a/src/browser/tests/canvas/canvas_rendering_context_2d.html b/src/browser/tests/canvas/canvas_rendering_context_2d.html index 22bcaa20..701fbdef 100644 --- a/src/browser/tests/canvas/canvas_rendering_context_2d.html +++ b/src/browser/tests/canvas/canvas_rendering_context_2d.html @@ -33,3 +33,58 @@ testing.expectEqual(ctx.fillStyle, "rgba(255, 0, 0, 0.06)"); } + + + + + + diff --git a/src/browser/tests/element/css_style_properties.html b/src/browser/tests/element/css_style_properties.html index 4bbce5e6..ab582132 100644 --- a/src/browser/tests/element/css_style_properties.html +++ b/src/browser/tests/element/css_style_properties.html @@ -121,6 +121,29 @@ } + + - + + diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 569e5f70..1ab895f5 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -129,6 +129,8 @@ pub fn initEvent( self._bubbles = bubbles orelse false; self._cancelable = cancelable orelse false; self._stop_propagation = false; + self._stop_immediate_propagation = false; + self._prevent_default = false; } pub fn deinit(self: *Event, shutdown: bool) void { diff --git a/src/browser/webapi/ImageData.zig b/src/browser/webapi/ImageData.zig index 05fcae6c..c9ced5f1 100644 --- a/src/browser/webapi/ImageData.zig +++ b/src/browser/webapi/ImageData.zig @@ -58,7 +58,10 @@ pub fn constructor( maybe_settings: ?ConstructorSettings, page: *Page, ) !*ImageData { - if (width == 0 or height == 0) { + // Though arguments are unsigned long, these are capped to max. i32 on Chrome. + // https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/html/canvas/image_data.cc#L61 + const max_i32 = std.math.maxInt(i32); + if (width == 0 or width > max_i32 or height == 0 or height > max_i32) { return error.IndexSizeError; } @@ -70,7 +73,11 @@ pub fn constructor( return error.TypeError; } - const size = width * height * 4; + var size, var overflown = @mulWithOverflow(width, height); + if (overflown == 1) return error.IndexSizeError; + size, overflown = @mulWithOverflow(size, 4); + if (overflown == 1) return error.IndexSizeError; + return page._factory.create(ImageData{ ._width = width, ._height = height, diff --git a/src/browser/webapi/animation/Animation.zig b/src/browser/webapi/animation/Animation.zig index 2b6855ac..0ebe396b 100644 --- a/src/browser/webapi/animation/Animation.zig +++ b/src/browser/webapi/animation/Animation.zig @@ -16,40 +16,118 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); +const log = @import("../../../log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Allocator = std.mem.Allocator; + const Animation = @This(); +const PlayState = enum { + idle, + running, + paused, + finished, +}; + +_page: *Page, +_arena: Allocator, + _effect: ?js.Object.Global = null, _timeline: ?js.Object.Global = null, _ready_resolver: ?js.PromiseResolver.Global = null, _finished_resolver: ?js.PromiseResolver.Global = null, +_startTime: ?f64 = null, +_onFinish: ?js.Function.Temp = null, +_playState: PlayState = .idle, +// Fake the animation by passing the states: +// .idle => .running once play() is called. +// .running => .finished after 10ms when update() is callback. +// +// TODO add support for effect and timeline pub fn init(page: *Page) !*Animation { - return page._factory.create(Animation{}); + const arena = try page.getArena(.{ .debug = "Animation" }); + errdefer page.releaseArena(arena); + + const self = try arena.create(Animation); + self.* = .{ + ._page = page, + ._arena = arena, + }; + + return self; } -pub fn play(_: *Animation) void {} -pub fn pause(_: *Animation) void {} -pub fn cancel(_: *Animation) void {} -pub fn finish(_: *Animation) void {} -pub fn reverse(_: *Animation) void {} +pub fn deinit(self: *Animation, _: bool) void { + self._page.releaseArena(self._arena); +} + +pub fn play(self: *Animation, page: *Page) !void { + if (self._playState == .running) { + return; + } + + // transition to running. + self._playState = .running; + + // Schedule the transition from .running => .finished in 10ms. + page.js.strongRef(self); + try page.js.scheduler.add( + self, + Animation.update, + 10, + .{ .name = "animation.update" }, + ); +} + +pub fn pause(self: *Animation) void { + self._playState = .paused; +} + +pub fn cancel(_: *Animation) void { + log.warn(.not_implemented, "Animation.cancel", .{}); +} + +pub fn finish(self: *Animation, page: *Page) void { + if (self._playState == .finished) { + return; + } + + self._playState = .finished; + + // resolve finished + if (self._finished_resolver) |resolver| { + page.js.local.?.toLocal(resolver).resolve("Animation.getFinished", self); + } + // call onfinish + if (self._onFinish) |func| { + page.js.local.?.toLocal(func).call(void, .{}) catch |err| { + log.warn(.js, "Animation._onFinish", .{ .err = err }); + }; + } +} + +pub fn reverse(_: *Animation) void { + log.warn(.not_implemented, "Animation.reverse", .{}); +} pub fn getFinished(self: *Animation, page: *Page) !js.Promise { if (self._finished_resolver == null) { const resolver = page.js.local.?.createPromiseResolver(); - resolver.resolve("Animation.getFinished", self); self._finished_resolver = try resolver.persist(); return resolver.promise(); } return page.js.toLocal(self._finished_resolver).?.promise(); } +// The ready promise is immediately resolved. pub fn getReady(self: *Animation, page: *Page) !js.Promise { - // never resolved, because we're always "finished" if (self._ready_resolver == null) { const resolver = page.js.local.?.createPromiseResolver(); + resolver.resolve("Animation.getReady", self); self._ready_resolver = try resolver.persist(); return resolver.promise(); } @@ -72,6 +150,65 @@ pub fn setTimeline(self: *Animation, timeline: ?js.Object.Global) !void { self._timeline = timeline; } +pub fn getStartTime(self: *const Animation) ?f64 { + return self._startTime; +} + +pub fn setStartTime(self: *Animation, value: ?f64, page: *Page) !void { + self._startTime = value; + + // if the startTime is null, don't play the animation. + if (value == null) { + return; + } + + return self.play(page); +} + +pub fn getOnFinish(self: *const Animation) ?js.Function.Temp { + return self._onFinish; +} + +// callback function transitionning from a state to another +fn update(ctx: *anyopaque) !?u32 { + const self: *Animation = @ptrCast(@alignCast(ctx)); + + switch (self._playState) { + .running => { + // transition to finished. + self._playState = .finished; + + var ls: js.Local.Scope = undefined; + self._page.js.localScope(&ls); + defer ls.deinit(); + + // resolve finished + if (self._finished_resolver) |resolver| { + ls.toLocal(resolver).resolve("Animation.getFinished", self); + } + // call onfinish + if (self._onFinish) |func| { + ls.toLocal(func).call(void, .{}) catch |err| { + log.warn(.js, "Animation._onFinish", .{ .err = err }); + }; + } + }, + .idle, .paused, .finished => {}, + } + + // No future change scheduled, set the object weak for garbage collection. + self._page.js.weakRef(self); + return null; +} + +pub fn setOnFinish(self: *Animation, cb: ?js.Function.Temp) !void { + self._onFinish = cb; +} + +pub fn playState(self: *const Animation) []const u8 { + return @tagName(self._playState); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Animation); @@ -79,6 +216,8 @@ pub const JsApi = struct { pub const name = "Animation"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(Animation.deinit); }; pub const play = bridge.function(Animation.play, .{}); @@ -86,12 +225,14 @@ pub const JsApi = struct { pub const cancel = bridge.function(Animation.cancel, .{}); pub const finish = bridge.function(Animation.finish, .{}); pub const reverse = bridge.function(Animation.reverse, .{}); - pub const playState = bridge.property("finished", .{ .template = false }); + pub const playState = bridge.accessor(Animation.playState, null, .{}); pub const pending = bridge.property(false, .{ .template = false }); pub const finished = bridge.accessor(Animation.getFinished, null, .{}); pub const ready = bridge.accessor(Animation.getReady, null, .{}); pub const effect = bridge.accessor(Animation.getEffect, Animation.setEffect, .{}); pub const timeline = bridge.accessor(Animation.getTimeline, Animation.setTimeline, .{}); + pub const startTime = bridge.accessor(Animation.getStartTime, Animation.setStartTime, .{}); + pub const onfinish = bridge.accessor(Animation.getOnFinish, Animation.setOnFinish, .{}); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/canvas/CanvasRenderingContext2D.zig b/src/browser/webapi/canvas/CanvasRenderingContext2D.zig index 0c5ca1e7..f9aa0b18 100644 --- a/src/browser/webapi/canvas/CanvasRenderingContext2D.zig +++ b/src/browser/webapi/canvas/CanvasRenderingContext2D.zig @@ -23,6 +23,8 @@ const js = @import("../../js/js.zig"); const color = @import("../../color.zig"); const Page = @import("../../Page.zig"); +const ImageData = @import("../ImageData.zig"); + /// This class doesn't implement a `constructor`. /// It can be obtained with a call to `HTMLCanvasElement#getContext`. /// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D @@ -85,6 +87,33 @@ pub fn getTextBaseline(_: *const CanvasRenderingContext2D) []const u8 { return "alphabetic"; } +const WidthOrImageData = union(enum) { + width: u32, + image_data: *ImageData, +}; + +pub fn createImageData( + _: *const CanvasRenderingContext2D, + width_or_image_data: WidthOrImageData, + /// If `ImageData` variant preferred, this is null. + maybe_height: ?u32, + /// Can be used if width and height provided. + maybe_settings: ?ImageData.ConstructorSettings, + page: *Page, +) !*ImageData { + switch (width_or_image_data) { + .width => |width| { + const height = maybe_height orelse return error.TypeError; + return ImageData.constructor(width, height, maybe_settings, page); + }, + .image_data => |image_data| { + return ImageData.constructor(image_data._width, image_data._height, null, page); + }, + } +} + +pub fn putImageData(_: *const CanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {} + pub fn save(_: *CanvasRenderingContext2D) void {} pub fn restore(_: *CanvasRenderingContext2D) void {} pub fn scale(_: *CanvasRenderingContext2D, _: f64, _: f64) void {} @@ -131,6 +160,9 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const createImageData = bridge.function(CanvasRenderingContext2D.createImageData, .{ .dom_exception = true }); + pub const putImageData = bridge.function(CanvasRenderingContext2D.putImageData, .{}); + pub const save = bridge.function(CanvasRenderingContext2D.save, .{}); pub const restore = bridge.function(CanvasRenderingContext2D.restore, .{}); diff --git a/src/browser/webapi/css/CSSStyleProperties.zig b/src/browser/webapi/css/CSSStyleProperties.zig index f5d3a641..1bd51a4a 100644 --- a/src/browser/webapi/css/CSSStyleProperties.zig +++ b/src/browser/webapi/css/CSSStyleProperties.zig @@ -37,6 +37,14 @@ pub fn asCSSStyleDeclaration(self: *CSSStyleProperties) *CSSStyleDeclaration { return self._proto; } +pub fn setNamed(self: *CSSStyleProperties, name: []const u8, value: []const u8, page: *Page) !void { + if (method_names.has(name)) { + return error.NotHandled; + } + const dash_case = camelCaseToDashCase(name, &page.buf); + try self._proto.setProperty(dash_case, value, null, page); +} + pub fn getNamed(self: *CSSStyleProperties, name: []const u8, page: *Page) ![]const u8 { if (method_names.has(name)) { return error.NotHandled; @@ -108,6 +116,9 @@ fn isKnownCSSProperty(dash_case: []const u8) bool { .{ "display", {} }, .{ "visibility", {} }, .{ "opacity", {} }, + .{ "filter", {} }, + .{ "transform", {} }, + .{ "transition", {} }, .{ "position", {} }, .{ "top", {} }, .{ "bottom", {} }, @@ -201,5 +212,5 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const @"[]" = bridge.namedIndexed(CSSStyleProperties.getNamed, null, null, .{}); + pub const @"[]" = bridge.namedIndexed(CSSStyleProperties.getNamed, CSSStyleProperties.setNamed, null, .{}); }; diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 7615c0a4..a26a63c1 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -527,6 +527,7 @@ fn sanitizeValue(self: *Input, value: []const u8, page: *Page) ![]const u8 { if (c >= 'A' and c <= 'F') needs_lower = true; } if (!needs_lower) return value; + // Normalize to lowercase per spec const result = try page.call_arena.alloc(u8, 7); result[0] = '#'; for (value[1..], 0..) |c, j| { @@ -862,8 +863,6 @@ pub const Build = struct { self._default_value = element.getAttributeSafe(comptime .wrap("value")); self._default_checked = element.getAttributeSafe(comptime .wrap("checked")) != null; - // Current state starts equal to default - self._value = self._default_value; self._checked = self._default_checked; self._input_type = if (element.getAttributeSafe(comptime .wrap("type"))) |type_attr| diff --git a/src/lightpanda.zig b/src/lightpanda.zig index d2736689..f97142d0 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -28,6 +28,7 @@ pub const Notification = @import("Notification.zig"); pub const log = @import("log.zig"); pub const js = @import("browser/js/js.zig"); pub const dump = @import("browser/dump.zig"); +pub const markdown = @import("browser/markdown.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); @@ -36,6 +37,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug; pub const FetchOpts = struct { wait_ms: u32 = 5000, dump: dump.RootOpts, + dump_mode: ?Config.DumpFormat = null, writer: ?*std.Io.Writer = null, }; pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { @@ -97,7 +99,12 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { _ = session.wait(opts.wait_ms); const writer = opts.writer orelse return; - try dump.root(page.window._document, opts.dump, writer, page); + if (opts.dump_mode) |mode| { + switch (mode) { + .html => try dump.root(page.window._document, opts.dump, writer, page), + .markdown => try markdown.dump(page.window._document.asNode(), .{}, writer, page), + } + } try writer.flush(); } diff --git a/src/main.zig b/src/main.zig index 33c7fff8..3b2ded5e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -108,10 +108,11 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { }, .fetch => |opts| { const url = opts.url; - log.debug(.app, "startup", .{ .mode = "fetch", .dump = opts.dump, .url = url, .snapshot = app.snapshot.fromEmbedded() }); + log.debug(.app, "startup", .{ .mode = "fetch", .dump_mode = opts.dump_mode, .url = url, .snapshot = app.snapshot.fromEmbedded() }); var fetch_opts = lp.FetchOpts{ .wait_ms = 5000, + .dump_mode = opts.dump_mode, .dump = .{ .strip = opts.strip, .with_base = opts.withbase, @@ -120,7 +121,7 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { var stdout = std.fs.File.stdout(); var writer = stdout.writer(&.{}); - if (opts.dump) { + if (opts.dump_mode != null) { fetch_opts.writer = &writer.interface; }