(*--------------------------------------------------------------------------- Copyright (c) 2025 Anil Madhavapeddy . All rights reserved. SPDX-License-Identifier: MIT ---------------------------------------------------------------------------*) open Brr open Htmlrw_js_types module Css_class = struct let panel = Jstr.v "html5rw-panel" let panel_header = Jstr.v "html5rw-panel-header" let panel_content = Jstr.v "html5rw-panel-content" let panel_collapsed = Jstr.v "html5rw-panel-collapsed" let panel_dragging = Jstr.v "html5rw-panel-dragging" let warning_list = Jstr.v "html5rw-warning-list" let warning_row = Jstr.v "html5rw-warning-row" let warning_row_error = Jstr.v "html5rw-warning-row-error" let warning_row_warning = Jstr.v "html5rw-warning-row-warning" let warning_row_info = Jstr.v "html5rw-warning-row-info" let severity_badge = Jstr.v "html5rw-severity-badge" let message_text = Jstr.v "html5rw-message-text" let selector_path = Jstr.v "html5rw-selector-path" let collapse_btn = Jstr.v "html5rw-collapse-btn" let close_btn = Jstr.v "html5rw-close-btn" let summary_badge = Jstr.v "html5rw-summary-badge" let error_count = Jstr.v "html5rw-error-count" let warning_count = Jstr.v "html5rw-warning-count" let theme_light = Jstr.v "html5rw-theme-light" let theme_dark = Jstr.v "html5rw-theme-dark" end type t = { root : El.t; header : El.t; content : El.t; badge : El.t; config : panel_config; mutable result : result; mutable collapsed : bool; mutable highlighted : El.t option; mutable on_warning_click : (browser_message -> unit) option; mutable on_collapse_toggle : (bool -> unit) option; mutable on_close : (unit -> unit) option; mutable on_move : (int * int -> unit) option; } let _current_panel : t option ref = ref None let current () = !_current_panel let root_element t = t.root let header_element t = t.header let content_element t = t.content let badge_element t = t.badge let is_visible t = let display = El.computed_style (Jstr.v "display") t.root in not (Jstr.equal display (Jstr.v "none")) let is_collapsed t = t.collapsed let position t = let x = int_of_float (El.bound_x t.root) in let y = int_of_float (El.bound_y t.root) in (x, y) let set_position t x y = El.set_inline_style (Jstr.v "left") (Jstr.v (Printf.sprintf "%dpx" x)) t.root; El.set_inline_style (Jstr.v "top") (Jstr.v (Printf.sprintf "%dpx" y)) t.root; El.set_inline_style (Jstr.v "right") (Jstr.v "auto") t.root let highlighted_element t = t.highlighted let clear_highlight t = match t.highlighted with | Some el -> Htmlrw_js_annotate.unhighlight_element el; t.highlighted <- None | None -> () let navigate_to_element t bm = clear_highlight t; match bm.element_ref with | Some { element = Some el; _ } -> Htmlrw_js_annotate.highlight_element el; t.highlighted <- Some el | _ -> () let severity_row_class = function | Htmlrw_check.Error -> Css_class.warning_row_error | Htmlrw_check.Warning -> Css_class.warning_row_warning | Htmlrw_check.Info -> Css_class.warning_row_info let create_warning_row ~config t bm = let msg = bm.message in let sev = Htmlrw_check.severity_to_string msg.Htmlrw_check.severity in let badge = El.v (Jstr.v "span") ~at:[At.class' Css_class.severity_badge] [ El.txt' (String.uppercase_ascii sev) ] in let text = El.v (Jstr.v "span") ~at:[At.class' Css_class.message_text] [ El.txt' msg.Htmlrw_check.text ] in let children = [badge; text] in let children = if config.show_selector_path then match bm.element_ref with | Some ref -> let path = El.v (Jstr.v "span") ~at:[At.class' Css_class.selector_path] [ El.txt' ref.selector ] in children @ [path] | None -> children else children in let row = El.v (Jstr.v "div") ~at:[ At.class' Css_class.warning_row; At.class' (severity_row_class msg.Htmlrw_check.severity); ] children in if config.click_to_highlight then begin ignore (Ev.listen Ev.click (fun _ -> navigate_to_element t bm; match t.on_warning_click with | Some f -> f bm | None -> () ) (El.as_target row)) end; row let build_content ~config t = let messages = if config.group_by_severity then let errors, warnings, infos = List.fold_left (fun (e, w, i) bm -> match bm.message.Htmlrw_check.severity with | Htmlrw_check.Error -> (bm :: e, w, i) | Htmlrw_check.Warning -> (e, bm :: w, i) | Htmlrw_check.Info -> (e, w, bm :: i) ) ([], [], []) t.result.messages in List.rev errors @ List.rev warnings @ List.rev infos else t.result.messages in let rows = List.map (create_warning_row ~config t) messages in let list = El.v (Jstr.v "div") ~at:[At.class' Css_class.warning_list] rows in (match config.max_height with | Some h -> El.set_inline_style (Jstr.v "maxHeight") (Jstr.v (Printf.sprintf "%dpx" h)) list; El.set_inline_style (Jstr.v "overflowY") (Jstr.v "auto") list | None -> ()); list let update t result = t.result <- result; let list = build_content ~config:t.config t in El.set_children t.content [list]; let error_count = List.length (List.filter (fun bm -> bm.message.Htmlrw_check.severity = Htmlrw_check.Error ) result.messages) in let warning_count = List.length (List.filter (fun bm -> bm.message.Htmlrw_check.severity = Htmlrw_check.Warning ) result.messages) in El.set_children t.badge [ El.txt' (Printf.sprintf "%d errors, %d warnings" error_count warning_count) ] let collapse t = t.collapsed <- true; El.set_class Css_class.panel_collapsed true t.root; match t.on_collapse_toggle with Some f -> f true | None -> () let expand t = t.collapsed <- false; El.set_class Css_class.panel_collapsed false t.root; match t.on_collapse_toggle with Some f -> f false | None -> () let toggle_collapsed t = if t.collapsed then expand t else collapse t let show t = El.set_inline_style (Jstr.v "display") (Jstr.v "block") t.root let hide t = El.set_inline_style (Jstr.v "display") (Jstr.v "none") t.root let destroy t = El.remove t.root; if !_current_panel = Some t then _current_panel := None let hide_current () = match !_current_panel with Some t -> destroy t | None -> () let create ~config result = hide_current (); let _doc = G.document in let title = El.v (Jstr.v "span") [El.txt' "HTML5 Validation"] in let collapse_btn = El.v (Jstr.v "button") ~at:[At.class' Css_class.collapse_btn] [ El.txt' "_" ] in let close_btn = El.v (Jstr.v "button") ~at:[At.class' Css_class.close_btn] [ El.txt' "x" ] in let header = El.v (Jstr.v "div") ~at:[At.class' Css_class.panel_header] [ title; collapse_btn; close_btn ] in let error_count = List.length (List.filter (fun bm -> bm.message.Htmlrw_check.severity = Htmlrw_check.Error ) result.messages) in let warning_count = List.length (List.filter (fun bm -> bm.message.Htmlrw_check.severity = Htmlrw_check.Warning ) result.messages) in let badge = El.v (Jstr.v "div") ~at:[At.class' Css_class.summary_badge] [ El.txt' (Printf.sprintf "%d errors, %d warnings" error_count warning_count) ] in let content = El.v (Jstr.v "div") ~at:[At.class' Css_class.panel_content] [] in let theme_class = match config.theme with | `Light -> Css_class.theme_light | `Dark -> Css_class.theme_dark | `Auto -> Css_class.theme_light in let root = El.v (Jstr.v "div") ~at:[ At.class' Css_class.panel; At.class' theme_class; ] [header; badge; content] in (match config.initial_position with | `TopRight -> El.set_inline_style (Jstr.v "top") (Jstr.v "20px") root; El.set_inline_style (Jstr.v "right") (Jstr.v "20px") root | `TopLeft -> El.set_inline_style (Jstr.v "top") (Jstr.v "20px") root; El.set_inline_style (Jstr.v "left") (Jstr.v "20px") root | `BottomRight -> El.set_inline_style (Jstr.v "bottom") (Jstr.v "20px") root; El.set_inline_style (Jstr.v "right") (Jstr.v "20px") root | `BottomLeft -> El.set_inline_style (Jstr.v "bottom") (Jstr.v "20px") root; El.set_inline_style (Jstr.v "left") (Jstr.v "20px") root | `Custom (x, y) -> El.set_inline_style (Jstr.v "left") (Jstr.v (Printf.sprintf "%dpx" x)) root; El.set_inline_style (Jstr.v "top") (Jstr.v (Printf.sprintf "%dpx" y)) root); let t = { root; header; content; badge; config; result; collapsed = config.start_collapsed; highlighted = None; on_warning_click = None; on_collapse_toggle = None; on_close = None; on_move = None; } in update t result; ignore (Ev.listen Ev.click (fun _ -> toggle_collapsed t) (El.as_target collapse_btn)); ignore (Ev.listen Ev.click (fun _ -> destroy t; match t.on_close with Some f -> f () | None -> () ) (El.as_target close_btn)); if config.draggable then begin let dragging = ref false in let offset_x = ref 0.0 in let offset_y = ref 0.0 in ignore (Ev.listen Ev.mousedown (fun ev -> let m = Ev.as_type ev in dragging := true; offset_x := Ev.Mouse.client_x m -. El.bound_x root; offset_y := Ev.Mouse.client_y m -. El.bound_y root; El.set_class Css_class.panel_dragging true root ) (El.as_target header)); ignore (Ev.listen Ev.mousemove (fun ev -> if !dragging then begin let m = Ev.as_type ev in let x = int_of_float (Ev.Mouse.client_x m -. !offset_x) in let y = int_of_float (Ev.Mouse.client_y m -. !offset_y) in set_position t x y; match t.on_move with Some f -> f (x, y) | None -> () end ) (Window.as_target G.window)); ignore (Ev.listen Ev.mouseup (fun _ -> dragging := false; El.set_class Css_class.panel_dragging false root ) (Window.as_target G.window)) end; if config.start_collapsed then El.set_class Css_class.panel_collapsed true root; El.append_children (Document.body G.document) [root]; _current_panel := Some t; t let on_warning_click t f = t.on_warning_click <- Some f let on_collapse_toggle t f = t.on_collapse_toggle <- Some f let on_close t f = t.on_close <- Some f let on_move t f = t.on_move <- Some f let inject_default_styles ~theme = let theme_vars = match theme with | `Light -> {| --html5rw-panel-bg: #ffffff; --html5rw-panel-text: #333333; --html5rw-panel-border: #dddddd; --html5rw-panel-header-bg: #f5f5f5; |} | `Dark -> {| --html5rw-panel-bg: #2d3436; --html5rw-panel-text: #dfe6e9; --html5rw-panel-border: #636e72; --html5rw-panel-header-bg: #1e272e; |} | `Auto -> {| --html5rw-panel-bg: #ffffff; --html5rw-panel-text: #333333; --html5rw-panel-border: #dddddd; --html5rw-panel-header-bg: #f5f5f5; |} in let css = Printf.sprintf {| :root { %s } @media (prefers-color-scheme: dark) { :root { --html5rw-panel-bg: #2d3436; --html5rw-panel-text: #dfe6e9; --html5rw-panel-border: #636e72; --html5rw-panel-header-bg: #1e272e; } } .html5rw-panel { position: fixed; z-index: 99999; width: 400px; background: var(--html5rw-panel-bg); border: 1px solid var(--html5rw-panel-border); border-radius: 8px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); font-family: system-ui, -apple-system, sans-serif; font-size: 13px; color: var(--html5rw-panel-text); } .html5rw-panel-header { display: flex; align-items: center; padding: 12px 16px; background: var(--html5rw-panel-header-bg); border-bottom: 1px solid var(--html5rw-panel-border); border-radius: 8px 8px 0 0; cursor: move; user-select: none; } .html5rw-panel-header span { flex: 1; font-weight: 600; } .html5rw-panel-header button { width: 24px; height: 24px; margin-left: 8px; border: none; border-radius: 4px; background: transparent; color: var(--html5rw-panel-text); cursor: pointer; font-size: 14px; line-height: 1; } .html5rw-panel-header button:hover { background: rgba(0, 0, 0, 0.1); } .html5rw-panel-content { padding: 0; } .html5rw-panel-collapsed .html5rw-panel-content { display: none; } .html5rw-panel-collapsed .html5rw-summary-badge { display: block; } .html5rw-summary-badge { display: none; padding: 12px 16px; text-align: center; font-weight: 500; } .html5rw-warning-list { max-height: 400px; overflow-y: auto; } .html5rw-warning-row { display: flex; flex-direction: column; padding: 10px 16px; border-bottom: 1px solid var(--html5rw-panel-border); cursor: pointer; transition: background 0.15s; } .html5rw-warning-row:hover { background: rgba(0, 0, 0, 0.05); } .html5rw-warning-row:last-child { border-bottom: none; } .html5rw-severity-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; text-transform: uppercase; margin-right: 8px; } .html5rw-warning-row-error .html5rw-severity-badge { background: #e74c3c; color: white; } .html5rw-warning-row-warning .html5rw-severity-badge { background: #f39c12; color: white; } .html5rw-warning-row-info .html5rw-severity-badge { background: #3498db; color: white; } .html5rw-message-text { flex: 1; line-height: 1.4; } .html5rw-selector-path { display: block; margin-top: 4px; font-size: 11px; color: #888; font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .html5rw-panel-dragging { opacity: 0.9; } |} theme_vars in let doc = G.document in let style_el = El.v (Jstr.v "style") [El.txt' css] in El.set_at (Jstr.v "data-html5rw-panel-styles") (Some (Jstr.v "true")) style_el; El.append_children (Document.head doc) [style_el]; style_el