From d8a700d9be8165b82e67da7ce2782d205e68c545 Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Fri, 1 Dec 2023 06:51:01 -0500 Subject: [PATCH] LibWebView: Implement a JavaScript console tab in the Inspector This adds a JS console to the bottom section of the Inspector WebView. Much of this code is based on the existing WebView::ConsoleClient, but ported to fit the inspector model. That is, much of the code from that class is now handled in the Inspector's JS. --- Base/res/html/inspector/inspector.css | 52 +++++++ Base/res/html/inspector/inspector.js | 144 +++++++++++++++++- .../Libraries/LibWebView/InspectorClient.cpp | 131 ++++++++++++++++ .../Libraries/LibWebView/InspectorClient.h | 17 ++- 4 files changed, 341 insertions(+), 3 deletions(-) diff --git a/Base/res/html/inspector/inspector.css b/Base/res/html/inspector/inspector.css index 5774c4d5ca..8520d1aca8 100644 --- a/Base/res/html/inspector/inspector.css +++ b/Base/res/html/inspector/inspector.css @@ -138,6 +138,58 @@ details > :not(:first-child) { } } +.console { + font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + width: 100%; + height: 100%; +} + +.console-output { + height: calc(100% - 75px); + overflow: scroll; +} + +.console-input { + width: 100%; + height: 24px; + padding: 4px; + position: absolute; + bottom: 0; + left: 0; +} + +.console-input input { + width: calc(100% - 60px); +} + +@media (prefers-color-scheme: dark) { + .console-prompt { + color: cyan; + } + + .console-input { + background-color: rgb(57, 57, 57); + } + + .console-input input:focus { + outline: 1px dashed cyan; + } +} + +@media (prefers-color-scheme: light) { + .console-prompt { + color: blue; + } + + .console-input { + background-color: rgb(229, 229, 229); + } + + .console-input input:focus { + outline: 1px dashed blue; + } +} + .property-table { width: 100%; diff --git a/Base/res/html/inspector/inspector.js b/Base/res/html/inspector/inspector.js index 8ea0cb06f4..5a8b789c53 100644 --- a/Base/res/html/inspector/inspector.js +++ b/Base/res/html/inspector/inspector.js @@ -6,6 +6,12 @@ let selectedBottomTabButton = null; let selectedDOMNode = null; +let consoleGroupStack = []; +let consoleGroupNextID = 0; + +let consoleHistory = []; +let consoleHistoryIndex = 0; + const selectTab = (tabButton, tabID, selectedTab, selectedTabButton) => { let tab = document.getElementById(tabID); @@ -36,8 +42,8 @@ const selectBottomTab = (tabButton, tabID) => { let initialTopTabButton = document.getElementById("dom-tree-button"); selectTopTab(initialTopTabButton, "dom-tree"); -let initialBottomTabButton = document.getElementById("computed-style-button"); -selectBottomTab(initialBottomTabButton, "computed-style"); +let initialBottomTabButton = document.getElementById("console-button"); +selectBottomTab(initialBottomTabButton, "console"); const scrollToElement = element => { // Include an offset to prevent the element being placed behind the fixed `tab-controls` header. @@ -131,6 +137,140 @@ const inspectDOMNode = domNode => { inspector.inspectDOMNode(domNode.dataset.id, domNode.dataset.pseudoElement); }; +const executeConsoleScript = consoleInput => { + const script = consoleInput.value; + + if (!/\S/.test(script)) { + return; + } + + if (consoleHistory.length === 0 || consoleHistory[consoleHistory.length - 1] !== script) { + consoleHistory.push(script); + } + + consoleHistoryIndex = consoleHistory.length; + + inspector.executeConsoleScript(script); + consoleInput.value = ""; +}; + +const setConsoleInputToPreviousHistoryItem = consoleInput => { + if (consoleHistoryIndex === 0) { + return; + } + + --consoleHistoryIndex; + + const script = consoleHistory[consoleHistoryIndex]; + consoleInput.value = script; +}; + +const setConsoleInputToNextHistoryItem = consoleInput => { + if (consoleHistory.length === 0) { + return; + } + + const lastIndex = consoleHistory.length - 1; + + if (consoleHistoryIndex < lastIndex) { + ++consoleHistoryIndex; + + consoleInput.value = consoleHistory[consoleHistoryIndex]; + return; + } + + if (consoleHistoryIndex === lastIndex) { + ++consoleHistoryIndex; + + consoleInput.value = ""; + return; + } +}; + +const consoleParentGroup = () => { + if (consoleGroupStack.length === 0) { + return document.getElementById("console-output"); + } + + const lastConsoleGroup = consoleGroupStack[consoleGroupStack.length - 1]; + return document.getElementById(`console-group-${lastConsoleGroup.id}`); +}; + +const scrollConsoleToBottom = () => { + let consoleOutput = document.getElementById("console-output"); + + // FIXME: It should be sufficient to scrollTo a y value of document.documentElement.offsetHeight, + // but due to an unknown bug offsetHeight seems to not be properly updated after spamming + // a lot of document changes. + // + // The setTimeout makes the scrollTo async and allows the DOM to be updated. + setTimeout(function () { + consoleOutput.scrollTo(0, 1_000_000_000); + }, 0); +}; + +inspector.appendConsoleOutput = output => { + let parent = consoleParentGroup(); + + let element = document.createElement("p"); + element.innerHTML = atob(output); + + parent.appendChild(element); + scrollConsoleToBottom(); +}; + +inspector.clearConsoleOutput = () => { + let consoleOutput = document.getElementById("console-output"); + consoleOutput.innerHTML = ""; + + consoleGroupStack = []; +}; + +inspector.beginConsoleGroup = (label, startExpanded) => { + let parent = consoleParentGroup(); + + const group = { + id: ++consoleGroupNextID, + label: label, + }; + consoleGroupStack.push(group); + + let details = document.createElement("details"); + details.id = `console-group-${group.id}`; + details.open = startExpanded; + + let summary = document.createElement("summary"); + summary.innerHTML = atob(label); + + details.appendChild(summary); + parent.appendChild(details); + scrollConsoleToBottom(); +}; + +inspector.endConsoleGroup = () => { + consoleGroupStack.pop(); +}; + document.addEventListener("DOMContentLoaded", () => { + let consoleInput = document.getElementById("console-input"); + consoleInput.focus(); + + consoleInput.addEventListener("keydown", event => { + const UP_ARROW_KEYCODE = 38; + const DOWN_ARROW_KEYCODE = 40; + const RETURN_KEYCODE = 13; + + if (event.keyCode === UP_ARROW_KEYCODE) { + setConsoleInputToPreviousHistoryItem(consoleInput); + event.preventDefault(); + } else if (event.keyCode === DOWN_ARROW_KEYCODE) { + setConsoleInputToNextHistoryItem(consoleInput); + event.preventDefault(); + } else if (event.keyCode === RETURN_KEYCODE) { + executeConsoleScript(consoleInput); + event.preventDefault(); + } + }); + inspector.inspectorLoaded(); }); diff --git a/Userland/Libraries/LibWebView/InspectorClient.cpp b/Userland/Libraries/LibWebView/InspectorClient.cpp index 0c47de1739..f1baf300a2 100644 --- a/Userland/Libraries/LibWebView/InspectorClient.cpp +++ b/Userland/Libraries/LibWebView/InspectorClient.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -63,11 +64,21 @@ InspectorClient::InspectorClient(ViewImplementation& content_web_view, ViewImple m_inspector_web_view.run_javascript(script); }; + m_content_web_view.on_received_console_message = [this](auto message_index) { + handle_console_message(message_index); + }; + + m_content_web_view.on_received_console_messages = [this](auto start_index, auto const& message_types, auto const& messages) { + handle_console_messages(start_index, message_types, messages); + }; + m_inspector_web_view.enable_inspector_prototype(); m_inspector_web_view.use_native_user_style_sheet(); m_inspector_web_view.on_inspector_loaded = [this]() { inspect(); + + m_content_web_view.js_console_request_messages(0); }; m_inspector_web_view.on_inspector_selected_dom_node = [this](auto node_id, auto const& pseudo_element) { @@ -98,6 +109,12 @@ InspectorClient::InspectorClient(ViewImplementation& content_web_view, ViewImple m_inspector_web_view.run_javascript(builder.string_view()); }; + m_inspector_web_view.on_inspector_executed_console_script = [this](auto const& script) { + append_console_source(script); + + m_content_web_view.js_console_input(script.to_deprecated_string()); + }; + load_inspector(); } @@ -105,6 +122,8 @@ InspectorClient::~InspectorClient() { m_content_web_view.on_received_dom_tree = nullptr; m_content_web_view.on_received_accessibility_tree = nullptr; + m_content_web_view.on_received_console_message = nullptr; + m_content_web_view.on_received_console_messages = nullptr; } void InspectorClient::inspect() @@ -119,6 +138,11 @@ void InspectorClient::reset() m_pending_selection.clear(); m_dom_tree_loaded = false; + + clear_console_output(); + m_highest_notified_message_index = -1; + m_highest_received_message_index = -1; + m_waiting_for_messages = false; } void InspectorClient::select_hovered_node() @@ -189,11 +213,22 @@ void InspectorClient::load_inspector()
+
+
+
+
+
+ + + +
+
+
)~~~"sv); auto generate_property_table = [&](auto name) { @@ -384,4 +419,100 @@ String InspectorClient::generate_accessibility_tree(JsonObject const& accessibil return MUST(builder.to_string()); } +void InspectorClient::request_console_messages() +{ + VERIFY(!m_waiting_for_messages); + + m_content_web_view.js_console_request_messages(m_highest_received_message_index + 1); + m_waiting_for_messages = true; +} + +void InspectorClient::handle_console_message(i32 message_index) +{ + if (message_index <= m_highest_received_message_index) { + dbgln("Notified about console message we already have"); + return; + } + if (message_index <= m_highest_notified_message_index) { + dbgln("Notified about console message we're already aware of"); + return; + } + + m_highest_notified_message_index = message_index; + + if (!m_waiting_for_messages) + request_console_messages(); +} + +void InspectorClient::handle_console_messages(i32 start_index, ReadonlySpan message_types, ReadonlySpan messages) +{ + auto end_index = start_index + static_cast(message_types.size()) - 1; + if (end_index <= m_highest_received_message_index) { + dbgln("Received old console messages"); + return; + } + + for (size_t i = 0; i < message_types.size(); ++i) { + auto const& type = message_types[i]; + auto const& message = messages[i]; + + if (type == "html"sv) + append_console_output(message); + else if (type == "clear"sv) + clear_console_output(); + else if (type == "group"sv) + begin_console_group(message, true); + else if (type == "groupCollapsed"sv) + begin_console_group(message, false); + else if (type == "groupEnd"sv) + end_console_group(); + else + VERIFY_NOT_REACHED(); + } + + m_highest_received_message_index = end_index; + m_waiting_for_messages = false; + + if (m_highest_received_message_index < m_highest_notified_message_index) + request_console_messages(); +} + +void InspectorClient::append_console_source(StringView source) +{ + StringBuilder builder; + + builder.append(""sv); + builder.append(MUST(JS::MarkupGenerator::html_from_source(source))); + + append_console_output(builder.string_view()); +} + +void InspectorClient::append_console_output(StringView html) +{ + auto html_base64 = MUST(encode_base64(html.bytes())); + + auto script = MUST(String::formatted("inspector.appendConsoleOutput(\"{}\");", html_base64)); + m_inspector_web_view.run_javascript(script); +} + +void InspectorClient::clear_console_output() +{ + static constexpr auto script = "inspector.clearConsoleOutput();"sv; + m_inspector_web_view.run_javascript(script); +} + +void InspectorClient::begin_console_group(StringView label, bool start_expanded) +{ + auto label_base64 = MUST(encode_base64(label.bytes())); + + auto script = MUST(String::formatted("inspector.beginConsoleGroup(\"{}\", {});", label_base64, start_expanded)); + m_inspector_web_view.run_javascript(script); +} + +void InspectorClient::end_console_group() +{ + static constexpr auto script = "inspector.endConsoleGroup();"sv; + m_inspector_web_view.run_javascript(script); +} + } diff --git a/Userland/Libraries/LibWebView/InspectorClient.h b/Userland/Libraries/LibWebView/InspectorClient.h index 042048d2ac..a2fa997bfd 100644 --- a/Userland/Libraries/LibWebView/InspectorClient.h +++ b/Userland/Libraries/LibWebView/InspectorClient.h @@ -26,11 +26,22 @@ public: private: void load_inspector(); + String generate_dom_tree(JsonObject const&); String generate_accessibility_tree(JsonObject const&); - void select_node(i32 node_id); + void request_console_messages(); + void handle_console_message(i32 message_index); + void handle_console_messages(i32 start_index, ReadonlySpan message_types, ReadonlySpan messages); + + void append_console_source(StringView); + void append_console_output(StringView); + void clear_console_output(); + + void begin_console_group(StringView label, bool start_expanded); + void end_console_group(); + ViewImplementation& m_content_web_view; ViewImplementation& m_inspector_web_view; @@ -38,6 +49,10 @@ private: Optional m_pending_selection; bool m_dom_tree_loaded { false }; + + i32 m_highest_notified_message_index { -1 }; + i32 m_highest_received_message_index { -1 }; + bool m_waiting_for_messages { false }; }; }