(*--------------------------------------------------------------------------- Copyright (c) 2025 Anil Madhavapeddy . All rights reserved. SPDX-License-Identifier: MIT ---------------------------------------------------------------------------*) open Brr open Htmlrw_js_types module Data_attr = struct let severity = Jstr.v "data-html5rw-severity" let message = Jstr.v "data-html5rw-message" let code = Jstr.v "data-html5rw-code" let count = Jstr.v "data-html5rw-count" end module Css_class = struct let error = Jstr.v "html5rw-error" let warning = Jstr.v "html5rw-warning" let info = Jstr.v "html5rw-info" let has_issues = Jstr.v "html5rw-has-issues" let highlighted = Jstr.v "html5rw-highlighted" let tooltip = Jstr.v "html5rw-tooltip" let tooltip_visible = Jstr.v "html5rw-tooltip-visible" end type tooltip = { container : El.t; _target : El.t; } let severity_class = function | Htmlrw_check.Error -> Css_class.error | Htmlrw_check.Warning -> Css_class.warning | Htmlrw_check.Info -> Css_class.info let annotate_element ~config el msg = if config.add_data_attrs then begin El.set_at Data_attr.severity (Some (Jstr.v (Htmlrw_check.severity_to_string msg.Htmlrw_check.severity))) el; El.set_at Data_attr.message (Some (Jstr.v msg.Htmlrw_check.text)) el; El.set_at Data_attr.code (Some (Jstr.v (Htmlrw_check.error_code_to_string msg.Htmlrw_check.error_code))) el end; if config.add_classes then begin El.set_class (severity_class msg.Htmlrw_check.severity) true el; El.set_class Css_class.has_issues true el end let rec create_tooltip ~position target messages = let doc = El.document target in (* Create tooltip container *) let container = El.v (Jstr.v "div") ~at:[At.class' Css_class.tooltip] [] in (* Add messages to tooltip *) let msg_els = List.map (fun msg -> let sev = Htmlrw_check.severity_to_string msg.Htmlrw_check.severity in let sev_class = Jstr.v ("html5rw-tooltip-" ^ sev) in El.v (Jstr.v "div") ~at:[At.class' sev_class] [ El.v (Jstr.v "span") ~at:[At.class' (Jstr.v "html5rw-tooltip-severity")] [ El.txt' (String.uppercase_ascii sev) ]; El.v (Jstr.v "span") ~at:[At.class' (Jstr.v "html5rw-tooltip-text")] [ El.txt' msg.Htmlrw_check.text ] ] ) messages in El.set_children container msg_els; (* Position the tooltip *) let pos_class = match position with | `Above -> "html5rw-tooltip-above" | `Below -> "html5rw-tooltip-below" | `Auto -> "html5rw-tooltip-auto" in El.set_class (Jstr.v pos_class) true container; (* Add to body for proper z-index handling *) El.append_children (Document.body doc) [container]; (* Set up hover events *) let hide () = El.set_class Css_class.tooltip_visible false container in let show () = (* Hide any other visible tooltips first *) let doc = El.document target in let visible = El.fold_find_by_selector (fun el acc -> el :: acc) (Jstr.v ".html5rw-tooltip-visible") [] ~root:(Document.body doc) in List.iter (fun el -> El.set_class Css_class.tooltip_visible false el) visible; (* Position and show this tooltip *) let x = El.bound_x target in let y = El.bound_y target in let h = El.bound_h target in let tooltip_y = match position with | `Below | `Auto -> y +. h +. 4.0 | `Above -> y -. 4.0 in El.set_inline_style (Jstr.v "left") (Jstr.v (Printf.sprintf "%.0fpx" x)) container; El.set_inline_style (Jstr.v "top") (Jstr.v (Printf.sprintf "%.0fpx" tooltip_y)) container; El.set_class Css_class.tooltip_visible true container in ignore (Ev.listen Ev.mouseenter (fun _ -> show ()) (El.as_target target)); ignore (Ev.listen Ev.mouseleave (fun _ -> hide ()) (El.as_target target)); (* Also hide on mouseout for better reliability *) ignore (Ev.listen Ev.mouseout (fun ev -> let related = Jv.get (Ev.to_jv ev) "relatedTarget" in (* Hide if mouse moved to something outside the target *) if Jv.is_null related then hide () else (* Use JS contains method directly *) let contains = Jv.call (El.to_jv target) "contains" [| related |] |> Jv.to_bool in if not contains then hide () ) (El.as_target target)); { container; _target = target } and annotate ~config ~root:_ messages = (* Group messages by element - use a list since we can't hash elements *) let el_messages : (El.t * Htmlrw_check.message list) list ref = ref [] in List.iter (fun bm -> match bm.element_ref with | Some { element = Some el; _ } -> let found = ref false in el_messages := List.map (fun (e, msgs) -> if Jv.strict_equal (El.to_jv e) (El.to_jv el) then begin found := true; (e, bm.message :: msgs) end else (e, msgs) ) !el_messages; if not !found then el_messages := (el, [bm.message]) :: !el_messages | _ -> () ) messages; (* Annotate each element *) List.iter (fun (el, msgs) -> (* Use highest severity *) let highest = List.fold_left (fun acc msg -> match acc, msg.Htmlrw_check.severity with | Htmlrw_check.Error, _ -> Htmlrw_check.Error | _, Htmlrw_check.Error -> Htmlrw_check.Error | Htmlrw_check.Warning, _ -> Htmlrw_check.Warning | _, Htmlrw_check.Warning -> Htmlrw_check.Warning | _ -> Htmlrw_check.Info ) Htmlrw_check.Info msgs in let primary_msg = { Htmlrw_check.severity = highest; text = (match msgs with m :: _ -> m.Htmlrw_check.text | [] -> ""); error_code = (match msgs with m :: _ -> m.Htmlrw_check.error_code | [] -> Htmlrw_check.Conformance (`Misc `Multiple_h1)); location = None; element = None; attribute = None; extract = None; } in annotate_element ~config el primary_msg; if config.add_data_attrs then El.set_at Data_attr.count (Some (Jstr.v (string_of_int (List.length msgs)))) el; if config.show_tooltips then ignore (create_tooltip ~position:config.tooltip_position el msgs) ) !el_messages let show_tooltip t = El.set_class Css_class.tooltip_visible true t.container let hide_tooltip t = El.set_class Css_class.tooltip_visible false t.container let remove_tooltip t = El.remove t.container let tooltips_in root = let doc = El.document root in let tooltip_els = El.fold_find_by_selector (fun el acc -> el :: acc) (Jstr.v ".html5rw-tooltip") [] ~root:(Document.body doc) in List.map (fun container -> { container; _target = root }) tooltip_els let clear_element el = El.set_at Data_attr.severity None el; El.set_at Data_attr.message None el; El.set_at Data_attr.code None el; El.set_at Data_attr.count None el; El.set_class Css_class.error false el; El.set_class Css_class.warning false el; El.set_class Css_class.info false el; El.set_class Css_class.has_issues false el; El.set_class Css_class.highlighted false el let clear root = Htmlrw_js_dom.iter_elements clear_element root; List.iter remove_tooltip (tooltips_in root) let highlight_element el = El.set_class Css_class.highlighted true el; (* Call scrollIntoView directly with options object *) let opts = Jv.obj [| "behavior", Jv.of_string "smooth"; "block", Jv.of_string "center" |] in ignore (Jv.call (El.to_jv el) "scrollIntoView" [| opts |]) let unhighlight_element el = El.set_class Css_class.highlighted false el let _highlighted_elements : El.t list ref = ref [] let clear_highlights () = List.iter unhighlight_element !_highlighted_elements; _highlighted_elements := [] let inject_default_styles ~theme = let theme_vars = match theme with | `Light -> {| --html5rw-error-color: #e74c3c; --html5rw-warning-color: #f39c12; --html5rw-info-color: #3498db; --html5rw-bg: #ffffff; --html5rw-text: #333333; --html5rw-border: #dddddd; |} | `Dark -> {| --html5rw-error-color: #ff6b6b; --html5rw-warning-color: #feca57; --html5rw-info-color: #54a0ff; --html5rw-bg: #2d3436; --html5rw-text: #dfe6e9; --html5rw-border: #636e72; |} | `Auto -> {| --html5rw-error-color: #e74c3c; --html5rw-warning-color: #f39c12; --html5rw-info-color: #3498db; --html5rw-bg: #ffffff; --html5rw-text: #333333; --html5rw-border: #dddddd; |} in let css = Printf.sprintf {| :root { %s } @media (prefers-color-scheme: dark) { :root { --html5rw-error-color: #ff6b6b; --html5rw-warning-color: #feca57; --html5rw-info-color: #54a0ff; --html5rw-bg: #2d3436; --html5rw-text: #dfe6e9; --html5rw-border: #636e72; } } .html5rw-error { outline: 2px solid var(--html5rw-error-color) !important; outline-offset: 2px; } .html5rw-warning { outline: 2px solid var(--html5rw-warning-color) !important; outline-offset: 2px; } .html5rw-info { outline: 2px solid var(--html5rw-info-color) !important; outline-offset: 2px; } .html5rw-highlighted { background-color: rgba(52, 152, 219, 0.3) !important; animation: html5rw-pulse 1s ease-in-out; } @keyframes html5rw-pulse { 0%%, 100%% { background-color: rgba(52, 152, 219, 0.3); } 50%% { background-color: rgba(52, 152, 219, 0.5); } } .html5rw-tooltip { position: fixed; z-index: 100000; background: var(--html5rw-bg); border: 1px solid var(--html5rw-border); border-radius: 6px; padding: 8px 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); max-width: 400px; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; color: var(--html5rw-text); opacity: 0; visibility: hidden; transition: opacity 0.2s, visibility 0.2s; pointer-events: none; } .html5rw-tooltip-visible { opacity: 1; visibility: visible; } .html5rw-tooltip-error .html5rw-tooltip-severity { color: var(--html5rw-error-color); font-weight: 600; margin-right: 8px; } .html5rw-tooltip-warning .html5rw-tooltip-severity { color: var(--html5rw-warning-color); font-weight: 600; margin-right: 8px; } .html5rw-tooltip-info .html5rw-tooltip-severity { color: var(--html5rw-info-color); font-weight: 600; margin-right: 8px; } .html5rw-tooltip > div { margin-bottom: 4px; } .html5rw-tooltip > div:last-child { margin-bottom: 0; } |} theme_vars in let doc = G.document in let style_el = El.v (Jstr.v "style") [] in El.set_children style_el [El.txt' css]; El.set_at (Jstr.v "data-html5rw-styles") (Some (Jstr.v "true")) style_el; El.append_children (Document.head doc) [style_el]; style_el let remove_injected_styles style_el = El.remove style_el