mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-21 09:52:11 +00:00
Merge branch 'main' into feature/bambu_printer_housekeeping
# Conflicts: # tests/slic3rutils/CMakeLists.txt
This commit is contained in:
8
resources/images/param_add.svg
Normal file
8
resources/images/param_add.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="25" height="25" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
|
||||
<g>
|
||||
<title>Layer 1</title>
|
||||
<path id="svg_1" stroke-linecap="round" stroke-width="2" stroke="#262E30" d="m1,12.5l23,0"/>
|
||||
<path id="svg_2" stroke-linecap="round" stroke-width="2" stroke="#262E30" d="m12.5,24l0,-23"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 312 B |
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Qidi",
|
||||
"version": "02.04.00.07",
|
||||
"version": "02.04.00.08",
|
||||
"force_update": "0",
|
||||
"description": "Qidi configurations",
|
||||
"machine_model_list": [
|
||||
|
||||
@@ -10,36 +10,6 @@
|
||||
"machine_pause_gcode": "M0",
|
||||
"support_chamber_temp_control": "1",
|
||||
"wipe_tower_type": "type1",
|
||||
"filament_dev_ams_drying_ams_limitations": [
|
||||
"1"
|
||||
],
|
||||
"filament_dev_ams_drying_temperature": [
|
||||
"40.0",
|
||||
"40.0",
|
||||
"40.0",
|
||||
"40.0"
|
||||
],
|
||||
"filament_dev_ams_drying_time": [
|
||||
"8.0",
|
||||
"8.0",
|
||||
"8.0",
|
||||
"8.0"
|
||||
],
|
||||
"filament_dev_drying_softening_temperature": [
|
||||
"40.0"
|
||||
],
|
||||
"filament_dev_ams_drying_heat_distortion_temperature": [
|
||||
"45.0"
|
||||
],
|
||||
"filament_dev_drying_cooling_temperature": [
|
||||
"35.0"
|
||||
],
|
||||
"filament_dev_chamber_drying_bed_temperature": [
|
||||
"90.0"
|
||||
],
|
||||
"filament_dev_chamber_drying_time": [
|
||||
"12.0"
|
||||
],
|
||||
"retraction_length": [
|
||||
"1"
|
||||
],
|
||||
|
||||
@@ -231,6 +231,8 @@ void main()
|
||||
s = max(s, DetectSilho(fragCoord.xy + vec2(i, 0)));
|
||||
s = max(s, DetectSilho(fragCoord.xy + vec2(0, i)));
|
||||
}
|
||||
if (s < 0.01)
|
||||
discard;
|
||||
gl_FragColor = vec4(mix(color.rgb, getBackfaceColor(color.rgb), s), color.a);
|
||||
}
|
||||
#ifdef ENABLE_ENVIRONMENT_MAP
|
||||
|
||||
@@ -30,6 +30,8 @@ uniform mat4 projection_matrix;
|
||||
uniform mat3 view_normal_matrix;
|
||||
uniform mat4 volume_world_matrix;
|
||||
uniform SlopeDetection slope;
|
||||
uniform bool is_outline;
|
||||
uniform vec2 screen_size;
|
||||
|
||||
// Clipping plane, x = min z, y = max z. Used by the FFF and SLA previews to clip with a top / bottom plane.
|
||||
uniform vec2 z_range;
|
||||
@@ -75,6 +77,15 @@ void main()
|
||||
world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0;
|
||||
|
||||
gl_Position = projection_matrix * position;
|
||||
if (is_outline) {
|
||||
vec3 n = normalize((view_normal_matrix * v_normal).xyz);
|
||||
vec2 dir = normalize(n.xy);
|
||||
if (dot(dir, dir) > 0.0) {
|
||||
//set outline thickness
|
||||
float px = 3.0;
|
||||
gl_Position.xy += dir * (px * 2.0 / screen_size) * gl_Position.w;
|
||||
}
|
||||
}
|
||||
// Fill in the scalars for fragment shader clipping. Fragments with any of these components lower than zero are discarded.
|
||||
clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z);
|
||||
color_clip_plane_dot = dot(world_pos, color_clip_plane);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#version 110
|
||||
|
||||
uniform sampler2D Texture;
|
||||
uniform sampler2D s_texture;
|
||||
|
||||
varying vec2 Frag_UV;
|
||||
varying vec4 Frag_Color;
|
||||
|
||||
void main()
|
||||
{
|
||||
gl_FragColor = Frag_Color * texture2D(Texture, Frag_UV.st);
|
||||
gl_FragColor = Frag_Color * texture2D(s_texture, Frag_UV.st);
|
||||
}
|
||||
@@ -273,6 +273,8 @@ void main()
|
||||
s = max(s, DetectSilho(fragCoord.xy + vec2(i, 0)));
|
||||
s = max(s, DetectSilho(fragCoord.xy + vec2(0, i)));
|
||||
}
|
||||
if (s < 0.01)
|
||||
discard;
|
||||
gl_FragColor = vec4(mix(shaded_color.rgb, getBackfaceColor(shaded_color.rgb), s), shaded_color.a);
|
||||
}
|
||||
#ifdef ENABLE_ENVIRONMENT_MAP
|
||||
|
||||
@@ -14,6 +14,8 @@ uniform mat4 projection_matrix;
|
||||
uniform mat3 view_normal_matrix;
|
||||
uniform mat4 volume_world_matrix;
|
||||
uniform SlopeDetection slope;
|
||||
uniform bool is_outline;
|
||||
uniform vec2 screen_size;
|
||||
|
||||
// Clipping plane, x = min z, y = max z. Used by the FFF and SLA previews to clip with a top / bottom plane.
|
||||
uniform vec2 z_range;
|
||||
@@ -48,6 +50,15 @@ void main()
|
||||
world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0;
|
||||
|
||||
gl_Position = projection_matrix * position;
|
||||
if (is_outline) {
|
||||
vec3 n = normalize((view_normal_matrix * v_normal).xyz);
|
||||
vec2 dir = normalize(n.xy);
|
||||
if (dot(dir, dir) > 0.0) {
|
||||
//set outline thickness
|
||||
float px = 3.0;
|
||||
gl_Position.xy += dir * (px * 2.0 / screen_size) * gl_Position.w;
|
||||
}
|
||||
}
|
||||
// Fill in the scalars for fragment shader clipping. Fragments with any of these components lower than zero are discarded.
|
||||
clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z);
|
||||
color_clip_plane_dot = dot(world_pos, color_clip_plane);
|
||||
|
||||
@@ -11,6 +11,7 @@ uniform sampler2D normal_texture;
|
||||
uniform vec2 inv_tex_size;
|
||||
uniform float z_near;
|
||||
uniform float z_far;
|
||||
uniform bool is_outline;
|
||||
|
||||
varying vec2 tex_coord;
|
||||
|
||||
@@ -22,6 +23,10 @@ float linearize_depth(float depth)
|
||||
|
||||
void main()
|
||||
{
|
||||
if (is_outline) {
|
||||
gl_FragColor = vec4(texture2D(color_texture, tex_coord).rgb, 1.0);
|
||||
return;
|
||||
}
|
||||
vec3 base = texture2D(color_texture, tex_coord).rgb;
|
||||
float depth_center = linearize_depth(texture2D(depth_texture, tex_coord).r);
|
||||
|
||||
|
||||
@@ -232,6 +232,8 @@ void main()
|
||||
s = max(s, DetectSilho(fragCoord.xy + vec2(i, 0)));
|
||||
s = max(s, DetectSilho(fragCoord.xy + vec2(0, i)));
|
||||
}
|
||||
if (s < 0.01)
|
||||
discard;
|
||||
out_color = vec4(mix(color.rgb, getBackfaceColor(color.rgb), s), color.a);
|
||||
}
|
||||
#ifdef ENABLE_ENVIRONMENT_MAP
|
||||
|
||||
@@ -30,6 +30,8 @@ uniform mat4 projection_matrix;
|
||||
uniform mat3 view_normal_matrix;
|
||||
uniform mat4 volume_world_matrix;
|
||||
uniform SlopeDetection slope;
|
||||
uniform bool is_outline;
|
||||
uniform vec2 screen_size;
|
||||
|
||||
// Clipping plane, x = min z, y = max z. Used by the FFF and SLA previews to clip with a top / bottom plane.
|
||||
uniform vec2 z_range;
|
||||
@@ -75,6 +77,15 @@ void main()
|
||||
world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0;
|
||||
|
||||
gl_Position = projection_matrix * position;
|
||||
if (is_outline) {
|
||||
vec3 n = normalize((view_normal_matrix * v_normal).xyz);
|
||||
vec2 dir = normalize(n.xy);
|
||||
if (dot(dir, dir) > 0.0) {
|
||||
//set outline thickness
|
||||
float px = 3.0;
|
||||
gl_Position.xy += dir * (px * 2.0 / screen_size) * gl_Position.w;
|
||||
}
|
||||
}
|
||||
// Fill in the scalars for fragment shader clipping. Fragments with any of these components lower than zero are discarded.
|
||||
clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z);
|
||||
color_clip_plane_dot = dot(world_pos, color_clip_plane);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#version 140
|
||||
|
||||
uniform sampler2D Texture;
|
||||
uniform sampler2D s_texture;
|
||||
|
||||
in vec2 Frag_UV;
|
||||
in vec4 Frag_Color;
|
||||
@@ -9,5 +9,5 @@ out vec4 out_color;
|
||||
|
||||
void main()
|
||||
{
|
||||
out_color = Frag_Color * texture(Texture, Frag_UV.st);
|
||||
out_color = Frag_Color * texture(s_texture, Frag_UV.st);
|
||||
}
|
||||
@@ -277,6 +277,8 @@ void main()
|
||||
s = max(s, DetectSilho(fragCoord.xy + vec2(i, 0)));
|
||||
s = max(s, DetectSilho(fragCoord.xy + vec2(0, i)));
|
||||
}
|
||||
if (s < 0.01)
|
||||
discard;
|
||||
out_color = vec4(mix(shaded_color.rgb, getBackfaceColor(shaded_color.rgb), s), shaded_color.a);
|
||||
}
|
||||
#ifdef ENABLE_ENVIRONMENT_MAP
|
||||
|
||||
@@ -14,6 +14,8 @@ uniform mat4 projection_matrix;
|
||||
uniform mat3 view_normal_matrix;
|
||||
uniform mat4 volume_world_matrix;
|
||||
uniform SlopeDetection slope;
|
||||
uniform bool is_outline;
|
||||
uniform vec2 screen_size;
|
||||
|
||||
// Clipping plane, x = min z, y = max z. Used by the FFF and SLA previews to clip with a top / bottom plane.
|
||||
uniform vec2 z_range;
|
||||
@@ -48,6 +50,15 @@ void main()
|
||||
world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0;
|
||||
|
||||
gl_Position = projection_matrix * position;
|
||||
if (is_outline) {
|
||||
vec3 n = normalize((view_normal_matrix * v_normal).xyz);
|
||||
vec2 dir = normalize(n.xy);
|
||||
if (dot(dir, dir) > 0.0) {
|
||||
//set outline thickness
|
||||
float px = 3.0;
|
||||
gl_Position.xy += dir * (px * 2.0 / screen_size) * gl_Position.w;
|
||||
}
|
||||
}
|
||||
// Fill in the scalars for fragment shader clipping. Fragments with any of these components lower than zero are discarded.
|
||||
clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z);
|
||||
color_clip_plane_dot = dot(world_pos, color_clip_plane);
|
||||
|
||||
@@ -10,6 +10,7 @@ uniform sampler2D depth_texture;
|
||||
uniform sampler2D normal_texture;
|
||||
uniform float z_near;
|
||||
uniform float z_far;
|
||||
uniform bool is_outline;
|
||||
|
||||
in vec2 tex_coord;
|
||||
out vec4 frag_color;
|
||||
@@ -22,6 +23,10 @@ float linearize_depth(float depth)
|
||||
|
||||
void main()
|
||||
{
|
||||
if (is_outline) {
|
||||
frag_color = vec4(texture(color_texture, tex_coord).rgb, 1.0);
|
||||
return;
|
||||
}
|
||||
ivec2 pixel = ivec2(gl_FragCoord.xy);
|
||||
float center_depth = linearize_depth(texelFetch(depth_texture, pixel, 0).r);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../../include/global.css" /> <!-- ORCA One for all-->
|
||||
<link rel="stylesheet" type="text/css" href="../css/common.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../css/dark.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../css/theme.css" />
|
||||
<script type="text/javascript" src="../js/jquery-3.6.0.min.js"></script>
|
||||
<script type="text/javascript" src="../js/json2.js"></script>
|
||||
<script type="text/javascript" src="../../data/text.js"></script>
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
:root {
|
||||
--cbr-border-color: #d2d2d7;
|
||||
--cbr-header-bg: #f6f7f9;
|
||||
--cbr-panel-bg: #ffffff;
|
||||
--cbr-input-bg: #ffffff;
|
||||
--cbr-input-focus-bg: #f2f8f7;
|
||||
--cbr-label-color: #7b7b84;
|
||||
--cbr-icon-color: #75757f;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--cbr-border-color: #4a4a51;
|
||||
--cbr-header-bg: #2f2f34;
|
||||
--cbr-panel-bg: #2d2d31;
|
||||
--cbr-input-bg: #2d2d31;
|
||||
--cbr-input-focus-bg: #3b3b41;
|
||||
--cbr-label-color: #b9b9bc;
|
||||
--cbr-icon-color: #b9b9bc;
|
||||
}
|
||||
--cbr-border-color: var(--border);
|
||||
--cbr-header-bg: var(--panel);
|
||||
--cbr-panel-bg: var(--bg);
|
||||
--cbr-input-bg: var(--bg);
|
||||
--cbr-input-focus-bg: var(--row-hover);
|
||||
--cbr-label-color: var(--muted);
|
||||
--cbr-icon-color: var(--muted);
|
||||
}
|
||||
|
||||
.cbr-browser-container {
|
||||
|
||||
8
resources/web/dialog/PluginWebDialog/blank.html
Normal file
8
resources/web/dialog/PluginWebDialog/blank.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Bootstrap page for PluginWebDialog. The real plugin HTML is loaded via
|
||||
wxWebView::SetPage once this page finishes loading; this file only exists
|
||||
to bring the webview up. -->
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title></title></head>
|
||||
<body style="margin:0;background:transparent;"></body>
|
||||
</html>
|
||||
53
resources/web/dialog/PluginsConfigDialog/index.html
Normal file
53
resources/web/dialog/PluginsConfigDialog/index.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Plugin configuration</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<!-- The shared dialog sheets, theme.css last so its host-injected variables win. -->
|
||||
<link rel="stylesheet" type="text/css" href="../../include/global.css">
|
||||
<link rel="stylesheet" type="text/css" href="../css/common.css">
|
||||
<link rel="stylesheet" type="text/css" href="../css/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="page-header">
|
||||
<h1 id="pagePresetName" class="page-title"></h1>
|
||||
</header>
|
||||
|
||||
<div id="configEmpty" class="detail-empty">This preset does not use any plugin capabilities</div>
|
||||
|
||||
<div id="configLayout" class="config-layout" hidden>
|
||||
<div id="configSidebar" class="config-sidebar thin-scroll" role="listbox"
|
||||
aria-label="Capabilities used by this preset"></div>
|
||||
<div class="config-view">
|
||||
<div id="configError" class="config-error" role="status" aria-live="polite" hidden></div>
|
||||
<div id="configEditor" class="config-editor" hidden>
|
||||
<textarea id="configText" class="config-textarea thin-scroll" spellcheck="false"
|
||||
autocomplete="off" autocapitalize="off" aria-label="Capability configuration (JSON)"></textarea>
|
||||
</div>
|
||||
<!-- Custom capability UI. Sandboxed without allow-same-origin, so the plugin's HTML runs in
|
||||
an opaque origin and reaches the host only through the injected window.orca bridge. -->
|
||||
<iframe id="configCustom" class="config-custom" title="Plugin configuration"
|
||||
sandbox="allow-scripts" referrerpolicy="no-referrer" hidden></iframe>
|
||||
<div id="configFooter" class="config-view-footer" hidden>
|
||||
<span id="configValidation" class="config-validation" role="status" aria-live="polite"></span>
|
||||
<div class="config-actions">
|
||||
<button id="configRestoreBtn" class="ButtonStyleRegular ButtonTypeChoice" type="button"
|
||||
title="Discard this preset's override and use the global configuration again">
|
||||
Restore defaults
|
||||
</button>
|
||||
<button id="configSaveBtn" class="ButtonStyleConfirm ButtonTypeChoice" type="button">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer id="statusBar" class="status-bar is-empty">
|
||||
<span id="statusText" class="status-text"></span>
|
||||
</footer>
|
||||
</main>
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
437
resources/web/dialog/PluginsConfigDialog/index.js
Normal file
437
resources/web/dialog/PluginsConfigDialog/index.js
Normal file
@@ -0,0 +1,437 @@
|
||||
// Capability rows the active preset uses, as PluginConfig::capabilities_payload emits them:
|
||||
// {plugin_key, name, type, type_key, has_config_ui}.
|
||||
let capabilities = [];
|
||||
|
||||
// The selected row's identity; plugin_key is part of it because this list spans plugins.
|
||||
let selectedPluginKey = "";
|
||||
let selectedCapabilityName = "";
|
||||
let selectedCapabilityType = "";
|
||||
let selectedHasPresetOverride = false;
|
||||
let selectedReadOnly = false;
|
||||
|
||||
function SafeJsonParse(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function SendWXMessage(message) {
|
||||
if (window.wx && typeof window.wx.postMessage === "function")
|
||||
window.wx.postMessage(message);
|
||||
}
|
||||
|
||||
function SendMessage(command, payload = {}) {
|
||||
const message = {
|
||||
sequence_id: Math.round(Date.now() / 1000),
|
||||
command: command
|
||||
};
|
||||
Object.keys(payload).forEach((key) => {
|
||||
message[key] = payload[key];
|
||||
});
|
||||
SendWXMessage(JSON.stringify(message));
|
||||
}
|
||||
|
||||
function HandleStudio(value) {
|
||||
const payload = (typeof value === "string") ? SafeJsonParse(value) : value;
|
||||
if (!payload || typeof payload !== "object")
|
||||
return;
|
||||
|
||||
if (payload.command === "list_capabilities") {
|
||||
ApplyCapabilities(payload);
|
||||
} else if (payload.command === "status_message") {
|
||||
ShowStatusMessage(String(payload.message || ""), String(payload.level || "info"));
|
||||
} else if (payload.command === "capability_config") {
|
||||
ApplyCapabilityConfig(payload);
|
||||
} else if (payload.command === "capability_config_saved") {
|
||||
ApplyCapabilityConfigSaved(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function ShowStatusMessage(message, level) {
|
||||
const bar = document.getElementById("statusBar");
|
||||
const text = document.getElementById("statusText");
|
||||
if (!bar || !text)
|
||||
return;
|
||||
|
||||
const normalizedLevel = ["success", "warn", "error", "info"].includes(level) ? level : "info";
|
||||
text.textContent = message;
|
||||
text.title = message;
|
||||
bar.classList.remove("is-empty", "level-success", "level-warn", "level-error", "level-info");
|
||||
bar.classList.add(`level-${normalizedLevel}`);
|
||||
}
|
||||
|
||||
function ApplyCapabilities(payload) {
|
||||
capabilities = Array.isArray(payload.data) ? payload.data : [];
|
||||
|
||||
const presetName = document.getElementById("pagePresetName");
|
||||
if (presetName)
|
||||
presetName.textContent = String(payload.preset_name || "");
|
||||
|
||||
RenderCapabilities();
|
||||
}
|
||||
|
||||
function IsSameCapability(capability) {
|
||||
return String(capability.plugin_key || "") === selectedPluginKey
|
||||
&& String(capability.name || "") === selectedCapabilityName
|
||||
&& String(capability.type_key || "") === selectedCapabilityType;
|
||||
}
|
||||
|
||||
function RenderCapabilities() {
|
||||
const empty = document.getElementById("configEmpty");
|
||||
const layout = document.getElementById("configLayout");
|
||||
const sidebar = document.getElementById("configSidebar");
|
||||
if (!empty || !layout || !sidebar)
|
||||
return;
|
||||
|
||||
if (capabilities.length === 0) {
|
||||
empty.hidden = false;
|
||||
layout.hidden = true;
|
||||
sidebar.replaceChildren();
|
||||
ClearCapabilityConfigView();
|
||||
selectedPluginKey = "";
|
||||
selectedCapabilityName = "";
|
||||
selectedCapabilityType = "";
|
||||
return;
|
||||
}
|
||||
|
||||
empty.hidden = true;
|
||||
layout.hidden = false;
|
||||
|
||||
// Keep the selection across a refresh if the capability is still there, else select the first.
|
||||
if (!capabilities.some(IsSameCapability)) {
|
||||
selectedPluginKey = String(capabilities[0].plugin_key || "");
|
||||
selectedCapabilityName = String(capabilities[0].name || "");
|
||||
selectedCapabilityType = String(capabilities[0].type_key || "");
|
||||
ClearCapabilityConfigView();
|
||||
RequestCapabilityConfig();
|
||||
}
|
||||
|
||||
sidebar.replaceChildren();
|
||||
for (const capability of capabilities) {
|
||||
const item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.className = "config-cap";
|
||||
item.dataset.pluginKey = String(capability.plugin_key || "");
|
||||
item.dataset.capabilityName = String(capability.name || "");
|
||||
item.dataset.capabilityType = String(capability.type_key || "");
|
||||
item.setAttribute("role", "option");
|
||||
|
||||
const isSelected = IsSameCapability(capability);
|
||||
item.classList.toggle("selected", isSelected);
|
||||
item.setAttribute("aria-selected", isSelected ? "true" : "false");
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "config-cap-name";
|
||||
label.textContent = String(capability.name || "");
|
||||
item.appendChild(label);
|
||||
|
||||
const type = document.createElement("span");
|
||||
type.className = "config-cap-type";
|
||||
type.textContent = String(capability.type || "");
|
||||
item.appendChild(type);
|
||||
|
||||
sidebar.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function OnConfigSidebarClick(event) {
|
||||
const item = event.target.closest(".config-cap");
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
const pluginKey = String(item.dataset.pluginKey || "");
|
||||
const name = String(item.dataset.capabilityName || "");
|
||||
const typeKey = String(item.dataset.capabilityType || "");
|
||||
if (!name || (pluginKey === selectedPluginKey && name === selectedCapabilityName && typeKey === selectedCapabilityType))
|
||||
return;
|
||||
|
||||
selectedPluginKey = pluginKey;
|
||||
selectedCapabilityName = name;
|
||||
selectedCapabilityType = typeKey;
|
||||
|
||||
// The native reply is async: clear now so the old config cannot appear under the new selection.
|
||||
ClearCapabilityConfigView();
|
||||
RequestCapabilityConfig();
|
||||
RenderCapabilities();
|
||||
}
|
||||
|
||||
// Config editor: the host's JSON editor, or the capability's own HTML UI in a sandboxed frame.
|
||||
// Both edit the same stored config; the page renders what the native side sends.
|
||||
|
||||
// Replies are async: apply one only if it still matches the selected row (plugin_key included,
|
||||
// since this list spans plugins), so a stale reply never lands under another capability.
|
||||
function IsCurrentCapability(payload) {
|
||||
return String(payload?.plugin_key || "") === selectedPluginKey
|
||||
&& String(payload?.capability_name || "") === selectedCapabilityName
|
||||
&& String(payload?.capability_type || "") === selectedCapabilityType;
|
||||
}
|
||||
|
||||
function RequestCapabilityConfig() {
|
||||
if (!selectedPluginKey || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
SendMessage("get_capability_config", {
|
||||
plugin_key: selectedPluginKey,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType
|
||||
});
|
||||
}
|
||||
|
||||
// Empties both editors and the footer, so nothing from the previous capability lingers while the
|
||||
// next one is in flight.
|
||||
function ClearCapabilityConfigView() {
|
||||
const editor = document.getElementById("configEditor");
|
||||
const custom = document.getElementById("configCustom");
|
||||
const text = document.getElementById("configText");
|
||||
const error = document.getElementById("configError");
|
||||
const footer = document.getElementById("configFooter");
|
||||
|
||||
if (editor)
|
||||
editor.hidden = true;
|
||||
if (custom) {
|
||||
custom.hidden = true;
|
||||
custom.removeAttribute("srcdoc");
|
||||
}
|
||||
if (text)
|
||||
text.value = "";
|
||||
if (error) {
|
||||
error.hidden = true;
|
||||
error.textContent = "";
|
||||
}
|
||||
if (footer)
|
||||
footer.hidden = true;
|
||||
selectedHasPresetOverride = false;
|
||||
selectedReadOnly = false;
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
// A read-only capability cannot be saved, and there is nothing to restore until the preset overrides
|
||||
// the global configuration.
|
||||
function UpdateConfigActions(payload) {
|
||||
selectedHasPresetOverride = payload?.has_preset_override === true;
|
||||
selectedReadOnly = payload?.read_only === true;
|
||||
|
||||
const save = document.getElementById("configSaveBtn");
|
||||
const restore = document.getElementById("configRestoreBtn");
|
||||
if (save)
|
||||
save.disabled = selectedReadOnly;
|
||||
if (restore)
|
||||
restore.disabled = selectedReadOnly || !selectedHasPresetOverride;
|
||||
}
|
||||
|
||||
function ApplyCapabilityConfig(payload) {
|
||||
if (!IsCurrentCapability(payload))
|
||||
return;
|
||||
|
||||
const editor = document.getElementById("configEditor");
|
||||
const custom = document.getElementById("configCustom");
|
||||
const text = document.getElementById("configText");
|
||||
const error = document.getElementById("configError");
|
||||
|
||||
const message = String(payload?.error || "");
|
||||
if (error) {
|
||||
error.textContent = message;
|
||||
error.hidden = message === "";
|
||||
}
|
||||
|
||||
const config = payload && Object.prototype.hasOwnProperty.call(payload, "config") ? payload.config : {};
|
||||
const html = String(payload?.custom_html || "");
|
||||
UpdateConfigActions(payload);
|
||||
|
||||
// The footer belongs to the JSON editor. A custom UI owns its whole surface, including whatever
|
||||
// save/restore controls it wants, and reaches the host through the window.orca bridge.
|
||||
const footer = document.getElementById("configFooter");
|
||||
if (footer)
|
||||
footer.hidden = html !== "";
|
||||
|
||||
if (html) {
|
||||
if (custom) {
|
||||
custom.hidden = false;
|
||||
custom.srcdoc = BuildCustomConfigDocument(html, config);
|
||||
}
|
||||
if (editor)
|
||||
editor.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Default editor: any reason a custom UI is unavailable already arrived in payload.error.
|
||||
if (custom) {
|
||||
custom.hidden = true;
|
||||
custom.removeAttribute("srcdoc");
|
||||
}
|
||||
if (editor)
|
||||
editor.hidden = false;
|
||||
if (text)
|
||||
text.value = JSON.stringify(config, null, 2);
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
function SetConfigValidation(message) {
|
||||
const node = document.getElementById("configValidation");
|
||||
const save = document.getElementById("configSaveBtn");
|
||||
if (node) {
|
||||
node.textContent = message;
|
||||
node.classList.toggle("invalid", message !== "");
|
||||
}
|
||||
// Invalid JSON is never saved: Save is the only way to persist. The native side re-validates.
|
||||
if (save)
|
||||
save.disabled = selectedReadOnly || message !== "";
|
||||
}
|
||||
|
||||
function ValidateConfigText() {
|
||||
const text = document.getElementById("configText");
|
||||
if (!text)
|
||||
return false;
|
||||
|
||||
try {
|
||||
JSON.parse(text.value);
|
||||
SetConfigValidation("");
|
||||
return true;
|
||||
} catch (err) {
|
||||
SetConfigValidation(String(err?.message || "Invalid JSON"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function SaveCapabilityConfig() {
|
||||
if (!selectedPluginKey || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
const text = document.getElementById("configText");
|
||||
if (!text)
|
||||
return;
|
||||
|
||||
if (!ValidateConfigText())
|
||||
return;
|
||||
|
||||
// Sent as text on purpose: the native side is the authority on validity and parses it itself.
|
||||
SendMessage("save_capability_config", {
|
||||
plugin_key: selectedPluginKey,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType,
|
||||
config: text.value
|
||||
});
|
||||
}
|
||||
|
||||
// "Restore defaults" here drops the preset's override, so the capability falls back to the global
|
||||
// configuration. The native side confirms, then re-sends the config that is now effective.
|
||||
function RestoreCapabilityConfig() {
|
||||
if (!selectedPluginKey || !selectedCapabilityName || !selectedHasPresetOverride)
|
||||
return;
|
||||
|
||||
SendMessage("remove_preset_override", {
|
||||
plugin_key: selectedPluginKey,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType
|
||||
});
|
||||
}
|
||||
|
||||
function ApplyCapabilityConfigSaved(payload) {
|
||||
if (!IsCurrentCapability(payload))
|
||||
return;
|
||||
|
||||
const error = document.getElementById("configError");
|
||||
const message = String(payload?.error || "");
|
||||
if (error) {
|
||||
error.textContent = message;
|
||||
error.hidden = message === "";
|
||||
}
|
||||
if (payload?.ok !== true)
|
||||
return;
|
||||
|
||||
// Reload from what was persisted, not from what was typed.
|
||||
const config = payload && Object.prototype.hasOwnProperty.call(payload, "config") ? payload.config : {};
|
||||
const custom = document.getElementById("configCustom");
|
||||
const text = document.getElementById("configText");
|
||||
|
||||
if (custom && !custom.hidden && custom.contentWindow)
|
||||
custom.contentWindow.postMessage({ __orca: "config", config: config }, "*");
|
||||
else if (text)
|
||||
text.value = JSON.stringify(config, null, 2);
|
||||
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
// The whole host surface a custom config UI gets: read the config, save one, drop the preset's
|
||||
// override, and be told when either lands. The frame is sandboxed into an opaque origin, so this
|
||||
// bridge is its only channel.
|
||||
function BuildCustomConfigDocument(html, config) {
|
||||
// Inlined into a <script>: a stored "</script>" would close the tag early, so escape "<" — the
|
||||
// literal stays valid JSON.
|
||||
const seed = JSON.stringify(config).replace(/</g, "\\u003c");
|
||||
const bridge = `<script>
|
||||
(function () {
|
||||
var handlers = [];
|
||||
var current = ${seed};
|
||||
window.orca = {
|
||||
getConfig: function () { return current; },
|
||||
saveConfig: function (cfg) { parent.postMessage({ __orca: "save", config: cfg }, "*"); },
|
||||
restoreDefaults: function () { parent.postMessage({ __orca: "restore" }, "*"); },
|
||||
onConfig: function (cb) {
|
||||
if (typeof cb !== "function") return;
|
||||
handlers.push(cb);
|
||||
try { cb(current); } catch (e) {}
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", function (event) {
|
||||
if (!event.data || event.data.__orca !== "config") return;
|
||||
current = event.data.config || {};
|
||||
handlers.forEach(function (handler) {
|
||||
try { handler(current); } catch (e) {}
|
||||
});
|
||||
});
|
||||
})();
|
||||
<\/script>`;
|
||||
return bridge + html;
|
||||
}
|
||||
|
||||
function OnCustomConfigMessage(event) {
|
||||
const custom = document.getElementById("configCustom");
|
||||
// Only the frame we created, and only while it is actually showing.
|
||||
if (!custom || custom.hidden || !custom.contentWindow || event.source !== custom.contentWindow)
|
||||
return;
|
||||
|
||||
const data = event.data;
|
||||
if (!data || !selectedPluginKey || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
if (data.__orca === "save") {
|
||||
SendMessage("save_capability_config", {
|
||||
plugin_key: selectedPluginKey,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType,
|
||||
config: data.config === undefined ? {} : data.config
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.__orca === "restore")
|
||||
RestoreCapabilityConfig();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const sidebar = document.getElementById("configSidebar");
|
||||
if (sidebar)
|
||||
sidebar.addEventListener("click", OnConfigSidebarClick);
|
||||
|
||||
const saveBtn = document.getElementById("configSaveBtn");
|
||||
if (saveBtn)
|
||||
saveBtn.addEventListener("click", SaveCapabilityConfig);
|
||||
|
||||
const restoreBtn = document.getElementById("configRestoreBtn");
|
||||
if (restoreBtn)
|
||||
restoreBtn.addEventListener("click", RestoreCapabilityConfig);
|
||||
|
||||
const text = document.getElementById("configText");
|
||||
if (text)
|
||||
text.addEventListener("input", ValidateConfigText);
|
||||
|
||||
// The custom UI is sandboxed into an opaque origin, so postMessage is its only channel.
|
||||
// OnCustomConfigMessage matches on the frame's contentWindow, not the origin ("null" when
|
||||
// sandboxed), and ignores anything else.
|
||||
window.addEventListener("message", OnCustomConfigMessage);
|
||||
|
||||
SendMessage("request_capabilities");
|
||||
});
|
||||
251
resources/web/dialog/PluginsConfigDialog/styles.css
Normal file
251
resources/web/dialog/PluginsConfigDialog/styles.css
Normal file
@@ -0,0 +1,251 @@
|
||||
/* Buttons (ButtonStyleRegular/ButtonStyleConfirm/ButtonTypeChoice), scrollbars (.thin-scroll) and
|
||||
the host-injected theme contract (--bg, --text, --border, --panel, --muted, --plugin-status-*,
|
||||
...) all come from the shared sheets linked in index.html (global.css, common.css, theme.css —
|
||||
the same three every resources/web/dialog/* page links). This file only carries what is unique
|
||||
to this page: the fixed-size reset those shared sheets assume, the new page chrome, and the
|
||||
config-related rules ported from PluginsDialog/styles.css (its Config tab uses the same classes).
|
||||
*/
|
||||
|
||||
/* common.css hardcodes body to the PluginsDialog-era fixed 820x660 with overflow:hidden; this
|
||||
dialog is resizable/maximizable, so neutralize that the same way PluginsDialog/styles.css does. */
|
||||
html,
|
||||
body {
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header { flex: 0 0 auto; }
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Ported from resources/web/dialog/PluginsDialog/styles.css (Config tab) ---- */
|
||||
|
||||
.detail-empty {
|
||||
padding: 18px 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-empty[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.config-layout[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.config-cap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-cap:hover {
|
||||
background: var(--row-hover);
|
||||
}
|
||||
|
||||
.config-cap.selected {
|
||||
background: var(--row-selected);
|
||||
border-color: var(--row-selected-outline);
|
||||
}
|
||||
|
||||
.config-cap-name {
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-cap-type {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.config-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-error {
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--plugin-status-warn-bg);
|
||||
color: var(--plugin-status-warn);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-error[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-editor {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.config-editor[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-textarea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
/* common.css applies `user-select: none` to *, so without this the user could type into the
|
||||
editor but not select, drag or copy what they had typed. */
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.config-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
|
||||
.config-custom {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.config-custom[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-view-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-view-footer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-actions > button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-validation {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-validation.invalid {
|
||||
color: var(--plugin-status-danger);
|
||||
}
|
||||
|
||||
/* Footer status bar: single-line, fixed-height strip mirroring PluginsDialog's. */
|
||||
.status-bar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 6px 14px;
|
||||
box-sizing: border-box;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-bar.level-success .status-text {
|
||||
color: var(--plugin-status-ok);
|
||||
}
|
||||
|
||||
.status-bar.level-error .status-text {
|
||||
color: var(--plugin-status-danger);
|
||||
}
|
||||
|
||||
.status-bar.level-warn .status-text {
|
||||
color: var(--plugin-status-warn);
|
||||
}
|
||||
213
resources/web/dialog/PluginsDialog/index.html
Normal file
213
resources/web/dialog/PluginsDialog/index.html
Normal file
@@ -0,0 +1,213 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Plugins</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
<link rel="stylesheet" href="./plugin-sort.css" />
|
||||
<link rel="stylesheet" href="./plugin-search.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../../include/global.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../css/common.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../css/theme.css" />
|
||||
<script type="text/javascript" src="../js/jquery-3.6.0.min.js"></script>
|
||||
<script type="text/javascript" src="../js/json2.js"></script>
|
||||
<script type="text/javascript" src="../../data/text.js"></script>
|
||||
<script type="text/javascript" src="../js/globalapi.js"></script>
|
||||
<script type="text/javascript" src="../js/common.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
<script src="./plugin-sort.js"></script>
|
||||
<script src="../js/fuzzy-search.js"></script>
|
||||
<script src="./plugin-search.js"></script>
|
||||
</head>
|
||||
<body onLoad="OnInit()">
|
||||
<div class="app">
|
||||
<div class="toolbar">
|
||||
<div id="pluginSearch" class="plugin-search">
|
||||
<span class="plugin-search-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||
<circle cx="6.5" cy="6.5" r="4.5" />
|
||||
<line x1="10" y1="10" x2="14" y2="14" />
|
||||
</svg>
|
||||
</span>
|
||||
<input id="plugin_search_input" class="plugin-search-input" type="text"
|
||||
placeholder="Search plugins" autocomplete="off" spellcheck="false" aria-label="Search plugins" />
|
||||
<button id="plugin_search_clear" class="plugin-search-clear" type="button" title="Clear" aria-label="Clear search">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor"
|
||||
stroke-width="1.6" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="5" y1="5" x2="11" y2="11" />
|
||||
<line x1="11" y1="5" x2="5" y2="11" />
|
||||
</svg>
|
||||
</button>
|
||||
<button id="plugin_search_cc" class="plugin-search-toggle" type="button"
|
||||
aria-pressed="false" title="Match case">Aa</button>
|
||||
<button id="plugin_search_w" class="plugin-search-toggle" type="button"
|
||||
aria-pressed="false" title="Match whole word"><span class="plugin-search-underline">ab</span></button>
|
||||
</div>
|
||||
<!-- <button id="open_terminal" class="ButtonStyleRegular ButtonTypeChoice left-btn">-->
|
||||
<!-- Open Terminal-->
|
||||
<!-- </button>-->
|
||||
<button id="refresh_btn" class="ButtonStyleRegular ButtonTypeChoice">
|
||||
Refresh
|
||||
</button>
|
||||
<div id="exploreDropdown" class="explore-dropdown">
|
||||
<button id="explore_menu_btn" class="ButtonStyleConfirm ButtonTypeChoice explore-menu-btn" type="button"
|
||||
aria-haspopup="true" aria-expanded="false" aria-controls="exploreMenu" title="More plugin install options">
|
||||
<span class="explore-menu-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button id="explore_btn" class="ButtonStyleConfirm ButtonTypeChoice explore-main-btn" type="button">
|
||||
Install plugin
|
||||
</button>
|
||||
<div id="exploreMenu" class="explore-menu" role="menu" hidden>
|
||||
<button type="button" class="explore-menu-item" role="menuitem" data-install-action="explore">Install plugin</button>
|
||||
<button type="button" class="explore-menu-item" role="menuitem" data-install-action="install-local">Install local plugin</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="content">
|
||||
<section class="pane plugin-list-pane">
|
||||
<div class="hdr plugin-cols">
|
||||
<span>Activate</span>
|
||||
<span class="sort-th" data-sort-field="name" role="button" tabindex="0"
|
||||
title="Sort by name">Name<span class="sort-tri" aria-hidden="true"></span></span>
|
||||
<span class="sort-th" data-sort-field="version" role="button" tabindex="0"
|
||||
title="Sort by version">Plugin Version<span class="sort-tri" aria-hidden="true"></span></span>
|
||||
<span class="sort-th" data-sort-field="source" role="button" tabindex="0"
|
||||
title="Sort by source">Source<span class="sort-tri" aria-hidden="true"></span></span>
|
||||
<span class="sort-th" data-sort-field="status" role="button" tabindex="0"
|
||||
title="Sort by status">Status<span class="sort-tri" aria-hidden="true"></span></span>
|
||||
</div>
|
||||
<div id="pluginList" class="body thin-scroll"></div>
|
||||
</section>
|
||||
|
||||
<!-- Drag to redistribute the dialog's height between the list and the details pane; the hit
|
||||
area is the whole strip, the visible divider is the line drawn inside it. -->
|
||||
<div id="paneSplitter" class="pane-splitter" role="separator" aria-orientation="horizontal"
|
||||
aria-label="Resize the plugin list" title="Drag to resize; double-click to reset"></div>
|
||||
|
||||
<section class="pane details-pane">
|
||||
<div class="detail-tabs" role="tablist" aria-label="Plugin details">
|
||||
<button id="pluginInfoTab" class="detail-tab active" type="button" role="tab"
|
||||
aria-selected="true" aria-controls="pluginInfoPanel" data-tab="plugin-info">Plugin Info</button>
|
||||
<button id="descriptionTab" class="detail-tab" type="button" role="tab" tabindex="-1"
|
||||
aria-selected="false" aria-controls="descriptionPanel" data-tab="description">Description</button>
|
||||
<button id="configTab" class="detail-tab" type="button" role="tab" tabindex="-1"
|
||||
aria-selected="false" aria-controls="configPanel" data-tab="config">Config</button>
|
||||
<button id="changelogTab" class="detail-tab" type="button" role="tab" tabindex="-1"
|
||||
aria-selected="false" aria-controls="changelogPanel" data-tab="changelog">Changelog</button>
|
||||
<button id="diagnosticsTab" class="detail-tab" type="button" role="tab" tabindex="-1"
|
||||
aria-selected="false" aria-controls="diagnosticsPanel" data-tab="diagnostics">Diagnostics</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-tab-panels">
|
||||
<section id="pluginInfoPanel" class="detail-tab-panel" role="tabpanel"
|
||||
aria-labelledby="pluginInfoTab" data-panel="plugin-info">
|
||||
<div class="plugin-info-layout">
|
||||
<div class="plugin-thumbnail">
|
||||
<img id="detailThumbnail" alt="" hidden />
|
||||
</div>
|
||||
<div class="detail-fields">
|
||||
<div class="detail-field-row">
|
||||
<div class="detail-label">Source</div>
|
||||
<div id="detailSource" class="detail-value">-</div>
|
||||
</div>
|
||||
<div class="detail-field-row">
|
||||
<div class="detail-label">Types</div>
|
||||
<div id="detailTypes" class="detail-value">-</div>
|
||||
</div>
|
||||
<div class="detail-field-row">
|
||||
<div class="detail-label">Author</div>
|
||||
<div id="detailAuthor" class="detail-value">-</div>
|
||||
</div>
|
||||
<div class="detail-field-row">
|
||||
<div class="detail-label">Installed Version</div>
|
||||
<div class="detail-value">
|
||||
<span id="detailInstalledVersion">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-field-row">
|
||||
<div class="detail-label">Latest Version</div>
|
||||
<div class="detail-value detail-version-row">
|
||||
<span id="detailLatestVersion">-</span>
|
||||
<span id="detailUpdateBadge" class="version-update-badge" hidden
|
||||
aria-label="Update available" title="Update available"></span>
|
||||
<button id="detailUpdateBtn" type="button" class="plugin-update-btn" hidden
|
||||
title="Update to the latest version">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="descriptionPanel" class="detail-tab-panel thin-scroll" role="tabpanel" tabindex="0"
|
||||
aria-labelledby="descriptionTab" data-panel="description" hidden>
|
||||
<h2 class="detail-section-title">Description</h2>
|
||||
<div id="detailDescription" class="detail-description">No description available</div>
|
||||
</section>
|
||||
|
||||
<section id="configPanel" class="detail-tab-panel" role="tabpanel" tabindex="0"
|
||||
aria-labelledby="configTab" data-panel="config" hidden>
|
||||
<div id="configEmpty" class="detail-empty">Select a plugin to configure its capabilities</div>
|
||||
<div id="configLayout" class="config-layout" hidden>
|
||||
<div id="configSidebar" class="config-sidebar thin-scroll" role="listbox"
|
||||
aria-label="Configurable capabilities"></div>
|
||||
<div class="config-view">
|
||||
<div id="configError" class="config-error" role="status" aria-live="polite" hidden></div>
|
||||
<div id="configEditor" class="config-editor" hidden>
|
||||
<textarea id="configText" class="config-textarea thin-scroll" spellcheck="false"
|
||||
autocomplete="off" autocapitalize="off" aria-label="Capability configuration (JSON)"></textarea>
|
||||
</div>
|
||||
<!-- Custom capability UI. Sandboxed without allow-same-origin, so the plugin's HTML
|
||||
runs in an opaque origin and reaches the host only through the injected
|
||||
window.orca bridge. -->
|
||||
<iframe id="configCustom" class="config-custom" title="Plugin configuration"
|
||||
sandbox="allow-scripts" referrerpolicy="no-referrer" hidden></iframe>
|
||||
<!-- JSON-editor chrome only: a custom UI renders its own save/restore controls and
|
||||
drives them through the window.orca bridge. -->
|
||||
<div id="configFooter" class="config-view-footer" hidden>
|
||||
<span id="configValidation" class="config-validation" role="status" aria-live="polite"></span>
|
||||
<div class="config-actions">
|
||||
<button id="configRestoreBtn" class="ButtonStyleRegular ButtonTypeChoice" type="button"
|
||||
title="Discard the settings saved for this capability and restore the plugin's defaults">
|
||||
Restore defaults
|
||||
</button>
|
||||
<button id="configSaveBtn" class="ButtonStyleConfirm ButtonTypeChoice" type="button">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="changelogPanel" class="detail-tab-panel thin-scroll" role="tabpanel" tabindex="0"
|
||||
aria-labelledby="changelogTab" data-panel="changelog" hidden>
|
||||
<table id="changelogTable" class="detail-table changelog-table" aria-label="Plugin changelog">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Version</th>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Changes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="changelogBody"></tbody>
|
||||
</table>
|
||||
<div id="changelogEmpty" class="detail-empty" hidden>No changelog available</div>
|
||||
</section>
|
||||
|
||||
<section id="diagnosticsPanel" class="detail-tab-panel thin-scroll" role="tabpanel" tabindex="0"
|
||||
aria-labelledby="diagnosticsTab" data-panel="diagnostics" hidden>
|
||||
<div id="detailStatusBody" class="detail-status-body">No issues detected</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="statusBar" class="status-bar is-empty" role="status" aria-live="polite">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
<span id="statusText" class="status-text"></span>
|
||||
</div>
|
||||
|
||||
<div id="ctxMenu" class="ctx" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
1605
resources/web/dialog/PluginsDialog/index.js
Normal file
1605
resources/web/dialog/PluginsDialog/index.js
Normal file
File diff suppressed because it is too large
Load Diff
138
resources/web/dialog/PluginsDialog/plugin-search.css
Normal file
138
resources/web/dialog/PluginsDialog/plugin-search.css
Normal file
@@ -0,0 +1,138 @@
|
||||
.plugin-search {
|
||||
--search-width: 300px;
|
||||
flex: 0 0 auto;
|
||||
width: var(--search-width);
|
||||
/* why: the search bar takes the auto margin (pinned far left); sort + Refresh + Install cluster right. */
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
/* why: match the compact toolbar buttons (.toolbar .ButtonTypeChoice is 26px) so the row aligns. */
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
box-sizing: border-box;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.plugin-search:focus-within {
|
||||
border-color: var(--main-color);
|
||||
box-shadow: 0 0 0 2px rgba(0, 150, 136, 0.25);
|
||||
}
|
||||
|
||||
.plugin-search-icon {
|
||||
display: inline-flex;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.plugin-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.plugin-search-input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* why: compact squarish toggles - min-width keeps single-char W from collapsing while two-char Cc
|
||||
grows just enough to fit; tight horizontal padding keeps them from reading as wide pills. */
|
||||
.plugin-search-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 1px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* why: set the clear x apart from the Cc/W pair while keeping the pair itself tight - margins on the
|
||||
toggles only, so the icon-to-text gap is left as-is. clear+toggle = Cc, toggle+toggle = W. */
|
||||
.plugin-search-clear + .plugin-search-toggle {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.plugin-search-toggle + .plugin-search-toggle {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
/* why: subtle round clear affordance - a small muted disc, not a square button. The x is an inline SVG
|
||||
(not a text glyph) so it centers pixel-perfectly regardless of the platform font. JS flips its
|
||||
visibility (not display) so its reserved slot never reflows Cc/W. */
|
||||
.plugin-search-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
visibility: hidden;
|
||||
background: rgba(127, 127, 127, 0.20);
|
||||
}
|
||||
|
||||
.plugin-search-clear svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plugin-search-clear:hover {
|
||||
color: var(--text);
|
||||
background: rgba(127, 127, 127, 0.45);
|
||||
}
|
||||
|
||||
.plugin-search-toggle:hover {
|
||||
color: var(--text);
|
||||
background: var(--row-hover);
|
||||
}
|
||||
|
||||
.plugin-search-toggle.on {
|
||||
color: var(--button-fg-light, #fff);
|
||||
background: var(--main-color);
|
||||
border-color: var(--main-color-hover);
|
||||
}
|
||||
|
||||
/* "ab" over a bracket drawn by ::after box draws it */
|
||||
.plugin-search-underline {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.plugin-search-underline::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
/* why: ticks stick out past the outer edges of "a"/"b" on each side. */
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
bottom: 0;
|
||||
/* why: short ticks + bottom rule, tucked near the baseline. */
|
||||
height: 2px;
|
||||
border: 1px solid currentColor;
|
||||
border-top: 0;
|
||||
/* why: soften the two joints where the ticks meet the bottom rule. */
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* why: reuse the themed warn tokens so matched-char marks track light and dark automatically.
|
||||
note: no padding/margin/border - a highlight must not change text width, else rows reflow. */
|
||||
mark.plugin-search-hit {
|
||||
border-radius: 2px;
|
||||
background: var(--plugin-status-warn-bg);
|
||||
color: var(--plugin-status-warn);
|
||||
}
|
||||
110
resources/web/dialog/PluginsDialog/plugin-search.js
Normal file
110
resources/web/dialog/PluginsDialog/plugin-search.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const pluginSearch = { query: "", caseSensitive: false, wholeWord: false };
|
||||
|
||||
function PluginSearchActive() {
|
||||
return pluginSearch.query.length > 0;
|
||||
}
|
||||
|
||||
// why: matcher (FoldChar/Norm/FuzzyRanges/WholeWordRanges) lives in shared ../js/fuzzy-search.js,
|
||||
// loaded before this script - it is shared with the Speed Dial popup. Cc = pluginSearch.caseSensitive.
|
||||
function MatchText(text, query) {
|
||||
if (!query)
|
||||
return [];
|
||||
return pluginSearch.wholeWord
|
||||
? WholeWordRanges(text, query, pluginSearch.caseSensitive)
|
||||
: FuzzyRanges(text, query, pluginSearch.caseSensitive);
|
||||
}
|
||||
|
||||
// Per-plugin evaluator consumed by RenderPlugins. The name text mirrors LabelCell's pluginLabelText so
|
||||
// highlight offsets line up with what is rendered. Capability names exist for loaded plugins only.
|
||||
function ComputePluginMatch(plugin) {
|
||||
const name = plugin.label || plugin.name || plugin.plugin_id || "";
|
||||
const nameRanges = MatchText(name, pluginSearch.query);
|
||||
const capabilities = Array.isArray(plugin?.capabilities) ? plugin.capabilities : [];
|
||||
const capRanges = new Map();
|
||||
for (const capability of capabilities) {
|
||||
const key = String(capability?.name || "");
|
||||
const ranges = MatchText(key, pluginSearch.query);
|
||||
if (ranges)
|
||||
capRanges.set(key, ranges);
|
||||
}
|
||||
return {
|
||||
matched: !!nameRanges || capRanges.size > 0,
|
||||
nameRanges,
|
||||
capRanges,
|
||||
hasCapMatch: capRanges.size > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// --- widget wiring ---
|
||||
let pluginSearchInput = null;
|
||||
let pluginSearchClear = null;
|
||||
let pluginSearchCc = null;
|
||||
let pluginSearchW = null;
|
||||
|
||||
function InitPluginSearch() {
|
||||
pluginSearchInput = document.getElementById("plugin_search_input");
|
||||
pluginSearchClear = document.getElementById("plugin_search_clear");
|
||||
pluginSearchCc = document.getElementById("plugin_search_cc");
|
||||
pluginSearchW = document.getElementById("plugin_search_w");
|
||||
if (!pluginSearchInput)
|
||||
return;
|
||||
|
||||
// why: common.js installs a document-level onkeydown that cancels the default action of every key
|
||||
// (returnValue=false) to block webview shortcuts; on the way up it also swallows typing. Stop the
|
||||
// field's keydowns from bubbling to it so the input stays editable, leaving the global guard intact.
|
||||
pluginSearchInput.addEventListener("keydown", (event) => event.stopPropagation());
|
||||
|
||||
pluginSearchInput.addEventListener("input", OnPluginSearchInput);
|
||||
pluginSearchClear?.addEventListener("click", ClearPluginSearch);
|
||||
pluginSearchCc?.addEventListener("click", () => TogglePluginSearchFlag(pluginSearchCc, "caseSensitive"));
|
||||
pluginSearchW?.addEventListener("click", () => TogglePluginSearchFlag(pluginSearchW, "wholeWord"));
|
||||
SyncPluginSearchClear();
|
||||
}
|
||||
|
||||
function OnPluginSearchInput() {
|
||||
pluginSearch.query = pluginSearchInput.value;
|
||||
// why: emptying the box by editing (not just the x) also ends the search - drop the transient vetoes.
|
||||
if (!pluginSearch.query)
|
||||
ClearSearchExpandOverride();
|
||||
SyncPluginSearchClear();
|
||||
RenderPluginsIfReady();
|
||||
}
|
||||
|
||||
function ClearPluginSearch() {
|
||||
pluginSearch.query = "";
|
||||
if (pluginSearchInput)
|
||||
pluginSearchInput.value = "";
|
||||
ClearSearchExpandOverride();
|
||||
SyncPluginSearchClear();
|
||||
RenderPluginsIfReady();
|
||||
pluginSearchInput?.focus();
|
||||
}
|
||||
|
||||
function TogglePluginSearchFlag(button, key) {
|
||||
pluginSearch[key] = !pluginSearch[key];
|
||||
button.classList.toggle("on", pluginSearch[key]);
|
||||
button.setAttribute("aria-pressed", String(pluginSearch[key]));
|
||||
RenderPluginsIfReady();
|
||||
}
|
||||
|
||||
// why: toggle visibility (not display / the hidden attribute) so the x keeps its reserved slot and
|
||||
// showing or hiding it never reflows the Cc / W buttons.
|
||||
function SyncPluginSearchClear() {
|
||||
if (pluginSearchClear)
|
||||
pluginSearchClear.style.visibility = pluginSearch.query.length ? "visible" : "hidden";
|
||||
}
|
||||
|
||||
// why: searchExpandOverride lives in index.js; guard so this module stays loadable on its own.
|
||||
function ClearSearchExpandOverride() {
|
||||
if (typeof searchExpandOverride !== "undefined")
|
||||
searchExpandOverride.clear();
|
||||
}
|
||||
|
||||
function RenderPluginsIfReady() {
|
||||
if (typeof RenderPlugins === "function")
|
||||
RenderPlugins();
|
||||
}
|
||||
|
||||
// why: guarded so the module can be loaded in headless syntax checks; mirrors plugin-sort.js.
|
||||
if (typeof document !== "undefined")
|
||||
document.addEventListener("DOMContentLoaded", InitPluginSearch);
|
||||
42
resources/web/dialog/PluginsDialog/plugin-sort.css
Normal file
42
resources/web/dialog/PluginsDialog/plugin-sort.css
Normal file
@@ -0,0 +1,42 @@
|
||||
/* why: sort affordance lives on the list column headers, not a toolbar dropdown. */
|
||||
|
||||
.hdr .sort-th {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.hdr .sort-th .sort-tri {
|
||||
width: 0;
|
||||
height: 0;
|
||||
flex: none;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* faint up-triangle hint on hover, only while the column is not the active sort */
|
||||
.hdr .sort-th:hover .sort-tri {
|
||||
display: block;
|
||||
border-bottom: 5px solid var(--muted);
|
||||
}
|
||||
|
||||
/* active column wins over the hover hint (same specificity, declared later) */
|
||||
.hdr .sort-th[data-sort="asc"] .sort-tri {
|
||||
display: block;
|
||||
border-bottom: 5px solid var(--text);
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.hdr .sort-th[data-sort="desc"] .sort-tri {
|
||||
display: block;
|
||||
border-top: 5px solid var(--text);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.hdr .sort-th[data-sort="asc"],
|
||||
.hdr .sort-th[data-sort="desc"] {
|
||||
color: var(--text);
|
||||
}
|
||||
67
resources/web/dialog/PluginsDialog/plugin-sort.js
Normal file
67
resources/web/dialog/PluginsDialog/plugin-sort.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// why: C++ owns ordering; this file only sends and reflects sort state.
|
||||
|
||||
const DEFAULT_PLUGIN_SORT = { key: "none", order: "asc" };
|
||||
// note: SORT_FIELDS are the clickable columns. "none" is the baseline/cleared state, not a field -
|
||||
// it is special-cased in NormalizePluginSort and produced by CyclePluginSort's third click.
|
||||
const SORT_FIELDS = new Set(["status", "name", "source", "version"]);
|
||||
let pluginSort = { ...DEFAULT_PLUGIN_SORT };
|
||||
|
||||
// why: C++ returns canonical sort state; guard stale or malformed values before reflecting them.
|
||||
function NormalizePluginSort(sortKey, sortOrder) {
|
||||
const key = String(sortKey || "");
|
||||
return {
|
||||
key: key === "none" ? "none" : (SORT_FIELDS.has(key) ? key : DEFAULT_PLUGIN_SORT.key),
|
||||
order: sortOrder === "desc" ? "desc" : DEFAULT_PLUGIN_SORT.order,
|
||||
};
|
||||
}
|
||||
|
||||
function RequestPluginSort(sortKey, sortOrder) {
|
||||
pluginSort = NormalizePluginSort(sortKey, sortOrder);
|
||||
RenderSortHeaders();
|
||||
|
||||
if (typeof SendMessage === "function")
|
||||
SendMessage("set_plugin_sort", {
|
||||
sort_key: pluginSort.key,
|
||||
sort_order: pluginSort.order,
|
||||
});
|
||||
}
|
||||
|
||||
// why: one click per column cycles asc -> desc -> clear; setting any column clears the previous
|
||||
// one for free because C++ (and pluginSort) only ever hold a single key.
|
||||
function CyclePluginSort(field) {
|
||||
if (!SORT_FIELDS.has(field))
|
||||
return;
|
||||
if (pluginSort.key !== field)
|
||||
RequestPluginSort(field, "asc");
|
||||
else if (pluginSort.order === "asc")
|
||||
RequestPluginSort(field, "desc");
|
||||
else
|
||||
RequestPluginSort("none", "asc"); // third click: back to baseline
|
||||
}
|
||||
|
||||
// why: paints the sort indicator for headers
|
||||
// e.g., when user clicks triangle to change sort order, or change to sort by a new different field
|
||||
function RenderSortHeaders() {
|
||||
document.querySelectorAll(".hdr .sort-th").forEach((th) => {
|
||||
// "" | "asc" | "desc" - renders the triangle via plugin-sort.css [data-sort=...].
|
||||
th.dataset.sort = th.dataset.sortField === pluginSort.key ? pluginSort.order : "";
|
||||
});
|
||||
}
|
||||
|
||||
function InitSortHeaders() {
|
||||
document.querySelectorAll(".hdr .sort-th").forEach((th) => {
|
||||
th.addEventListener("click", () => CyclePluginSort(th.dataset.sortField));
|
||||
// note: role="button" cells need Enter/Space to match the old dropdown's keyboard access.
|
||||
th.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
CyclePluginSort(th.dataset.sortField);
|
||||
}
|
||||
});
|
||||
});
|
||||
RenderSortHeaders(); // paint the initial state (baseline = no triangle)
|
||||
}
|
||||
|
||||
// why: guarded so the module can be loaded in headless syntax checks.
|
||||
if (typeof document !== "undefined")
|
||||
document.addEventListener("DOMContentLoaded", InitSortHeaders);
|
||||
1376
resources/web/dialog/PluginsDialog/styles.css
Normal file
1376
resources/web/dialog/PluginsDialog/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" type="text/css" href="../../include/global.css" /> <!-- ORCA One for all-->
|
||||
<link rel="stylesheet" type="text/css" href="../css/common.css" />
|
||||
<!-- <link rel="stylesheet" type="text/css" href="23.css" /> -->
|
||||
<link rel="stylesheet" type="text/css" href="../css/dark.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../css/theme.css" />
|
||||
<script type="text/javascript" src="../js/jquery-3.6.0.min.js"></script>
|
||||
<script type="text/javascript" src="../js/json2.js"></script>
|
||||
<script type="text/javascript" src="../../data/text.js"></script>
|
||||
|
||||
@@ -1,26 +1,3 @@
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--panel: #ffffff;
|
||||
--border: #d8d8d8;
|
||||
--border-strong: #e6e6e6;
|
||||
--border-soft: #f0f0f0;
|
||||
--col-sep: #e1e1e1;
|
||||
|
||||
--text: #1f2328;
|
||||
--row-hover: #f7f9fb;
|
||||
--row-selected: #eaf2ff;
|
||||
--row-selected-outline: #b7d0ff;
|
||||
|
||||
--footer-bg: #fafafa;
|
||||
--btn-bg: #ffffff;
|
||||
--btn-border: #cccccc;
|
||||
--btn-hover: #f0f0f0;
|
||||
|
||||
--ctx-bg: #ffffff;
|
||||
--ctx-border: #cccccc;
|
||||
--ctx-hover: #efefef;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
|
||||
32
resources/web/dialog/SpeedDial/index.html
Normal file
32
resources/web/dialog/SpeedDial/index.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Speed Dial</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../css/theme.css" />
|
||||
<script type="text/javascript" src="../../include/globalapi.js"></script>
|
||||
<script src="../../js/fuzzy-search.js"></script>
|
||||
<script src="./speeddial.js"></script>
|
||||
</head>
|
||||
<body onload="OnInit()">
|
||||
<div class="launcher">
|
||||
<div class="row-eyebrow fav-eyebrow" id="favEyebrow" hidden></div>
|
||||
<div class="fav-bar" id="favBar"></div>
|
||||
<div class="dial-head">
|
||||
<div class="plugin-search">
|
||||
<span class="plugin-search-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/></svg>
|
||||
</span>
|
||||
<input class="plugin-search-input" id="q" type="text" placeholder="Search actions" spellcheck="false" autocomplete="off" aria-label="Search actions" />
|
||||
<button class="plugin-search-clear" id="clear" type="button" title="Clear" aria-label="Clear search" hidden>
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><line x1="5" y1="5" x2="11" y2="11"/><line x1="11" y1="5" x2="5" y2="11"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dial-count" id="count" hidden></div>
|
||||
</div>
|
||||
<div class="dial-list" id="list"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
498
resources/web/dialog/SpeedDial/speeddial.js
Normal file
498
resources/web/dialog/SpeedDial/speeddial.js
Normal file
@@ -0,0 +1,498 @@
|
||||
// Speed Dial launcher page. Static-safe module: no DOM access at load time so a
|
||||
// node vm can exercise the pure helpers (filterActions / actionLabel / nextSel).
|
||||
|
||||
// ---- state (populated by the C++ bridge via window.HandleStudio) ----
|
||||
var ACTIONS = []; // [{id,title,source,shortcut}], already frecency-sorted by C++
|
||||
var FAVS = []; // [id...]
|
||||
var query = "";
|
||||
var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav'
|
||||
var lastResizeHeight = 0;
|
||||
var matchIndex = {};
|
||||
|
||||
// why: fuzzy matcher (FoldChar/Norm/FuzzyRanges) lives in shared ../../js/fuzzy-search.js, loaded before
|
||||
// this script - it is shared with the Plugins dialog. Speed dial search is always case-insensitive.
|
||||
|
||||
// element handles, assigned in OnInit (kept null so load-time touches no DOM)
|
||||
var qEl = null, listEl = null, favEl = null, clearEl = null, eyeEl = null, countEl = null;
|
||||
|
||||
// ---- pure helpers (no DOM; unit-tested) -------------------------------------
|
||||
function filterActions(actions, query) {
|
||||
var q = (query || "").trim();
|
||||
var list = actions || [];
|
||||
matchIndex = {};
|
||||
if (!q)
|
||||
return list.slice(0);
|
||||
|
||||
var out = [];
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var a = list[i];
|
||||
var titleMatch = FuzzyRanges(a.title, q, false);
|
||||
var sourceMatch = FuzzyRanges(a.source, q, false);
|
||||
if (!titleMatch && !sourceMatch)
|
||||
continue;
|
||||
matchIndex[a.id] = { title: titleMatch, source: sourceMatch, useTitle: !!titleMatch };
|
||||
out.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function visibleFavourites(favourites, actions) {
|
||||
// why: a fav whose id has no live action (plugin unloaded/disabled) renders a dead
|
||||
// monogram tile whose click run()s to a silent no-op; drop it from the quick-bar.
|
||||
var seen = {};
|
||||
(actions || []).forEach(function (a) { seen[a.id] = true; });
|
||||
return (favourites || []).filter(function (id, i, arr) {
|
||||
return seen[id] && arr.indexOf(id) === i;
|
||||
});
|
||||
}
|
||||
|
||||
function resultCountText(total, shown, query) {
|
||||
return (query || "").trim() ? "Showing " + shown + " of " + total + " actions" : total + " actions";
|
||||
}
|
||||
|
||||
function shouldRenderActionList(query) {
|
||||
return !!(query || "").trim();
|
||||
}
|
||||
|
||||
// Resolve the selection cursor {zone,i} to the action id it points at: fav zone indexes the
|
||||
// visible favourites, list zone the filtered actions. Pure so runSelected() shares one lookup.
|
||||
function selectedActionId(sel, actions, favIds, query) {
|
||||
if (sel.zone === "fav")
|
||||
return favIds[sel.i];
|
||||
// why: search-first keeps the list blank until the user types; an empty query still
|
||||
// filters to ALL actions, so without this gate Enter fires an action never shown.
|
||||
if (!shouldRenderActionList(query))
|
||||
return null;
|
||||
var a = actions[sel.i];
|
||||
return a && a.id;
|
||||
}
|
||||
|
||||
function foldLabel(s) { return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, ""); }
|
||||
|
||||
// Title-case a source for display: "GCODE OPTIMIZER"/"iRoNiNg pRo" -> "Gcode Optimizer"/"Ironing Pro".
|
||||
function prettySource(source) {
|
||||
return String(source || "").toLowerCase().replace(/\b\w/g, function (c) { return c.toUpperCase(); });
|
||||
}
|
||||
|
||||
// Accessible label "Title from Pretty Source", disambiguated with the opaque action id when another
|
||||
// action shares the same title+source (case/separator-insensitive) - so two rows never read out identically.
|
||||
function actionLabel(action, actions) {
|
||||
var label = action.title + " from " + prettySource(action.source);
|
||||
if (actions && actions.length) {
|
||||
var mine = foldLabel(action.title) + "|" + foldLabel(action.source);
|
||||
var clash = actions.some(function (o) {
|
||||
return o.id !== action.id && foldLabel(o.title) + "|" + foldLabel(o.source) === mine;
|
||||
});
|
||||
if (clash)
|
||||
label += " (" + action.id + ")";
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
// Monogram code for a tile: title initial, escalated on collision by PREPENDING the source
|
||||
// initial (pi+ti, e.g. "GC"), then a 1-based ordinal - so same-titled actions stay distinct.
|
||||
// why: ordinal is assigned by id, not by ACTIONS order - ACTIONS is frecency-sorted and
|
||||
// reshuffles as usage changes, which would otherwise flip who's "1" and who's "2" across runs.
|
||||
function tileCode(action, actions) {
|
||||
var list = actions || [];
|
||||
var ti = (action.title || " ").charAt(0).toUpperCase();
|
||||
var sameTitle = list.filter(function (o) { return (o.title || " ").charAt(0).toUpperCase() === ti; });
|
||||
if (sameTitle.length <= 1)
|
||||
return ti;
|
||||
var pi = (action.source || " ").charAt(0).toUpperCase();
|
||||
var sameSource = sameTitle.filter(function (o) { return (o.source || " ").charAt(0).toUpperCase() === pi; });
|
||||
if (sameSource.length <= 1)
|
||||
return pi + ti;
|
||||
sameSource.sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; });
|
||||
for (var i = 0; i < sameSource.length; i++)
|
||||
if (sameSource[i].id === action.id)
|
||||
return pi + ti + (i + 1);
|
||||
return pi + ti;
|
||||
}
|
||||
|
||||
function syncClearButton() {
|
||||
if (clearEl)
|
||||
clearEl.hidden = !query;
|
||||
}
|
||||
|
||||
function stateFromPayload(payload) {
|
||||
return {
|
||||
actions: payload.actions || [],
|
||||
favourites: payload.favourites || [],
|
||||
query: "",
|
||||
sel: { zone: "list", i: 0 },
|
||||
lastResizeHeight: 0
|
||||
};
|
||||
}
|
||||
|
||||
function resetScrollPositions(list, doc) {
|
||||
if (list)
|
||||
list.scrollTop = 0;
|
||||
if (doc && doc.scrollingElement)
|
||||
doc.scrollingElement.scrollTop = 0;
|
||||
if (doc && doc.documentElement)
|
||||
doc.documentElement.scrollTop = 0;
|
||||
if (doc && doc.body)
|
||||
doc.body.scrollTop = 0;
|
||||
}
|
||||
|
||||
// nextSel: pure arrow-nav transition. Down fav->list0; Down list->clamp; Up list@0->fav0;
|
||||
// Up list->i-1; Left/Right clamp within fav. Returns a fresh {zone,i}.
|
||||
function nextSel(sel, key, listLen, favLen) {
|
||||
var zone = sel.zone, i = sel.i;
|
||||
if (key === "ArrowDown") {
|
||||
if (zone === "fav") return { zone: "list", i: 0 };
|
||||
return { zone: "list", i: Math.min(i + 1, Math.max(0, listLen - 1)) };
|
||||
}
|
||||
if (key === "ArrowUp") {
|
||||
if (zone === "list") {
|
||||
if (i <= 0) return favLen ? { zone: "fav", i: 0 } : { zone: "list", i: 0 };
|
||||
return { zone: "list", i: i - 1 };
|
||||
}
|
||||
return { zone: zone, i: i };
|
||||
}
|
||||
if (key === "ArrowLeft" && zone === "fav") return { zone: "fav", i: Math.max(0, i - 1) };
|
||||
if (key === "ArrowRight" && zone === "fav") return { zone: "fav", i: Math.min(favLen - 1, i + 1) };
|
||||
return { zone: zone, i: i };
|
||||
}
|
||||
|
||||
// ---- bridge ------------------------------------------------------------------
|
||||
function SendMessage(msg) {
|
||||
if (typeof SendWXMessage !== "function")
|
||||
return;
|
||||
if (typeof msg === "string") msg = { command: msg };
|
||||
if (msg.sequence_id === undefined) msg.sequence_id = Date.now();
|
||||
SendWXMessage(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
// C++ pushes payloads here. Only list_actions is handled; it (re)seeds all state.
|
||||
window.HandleStudio = function (payload) {
|
||||
if (!payload) return;
|
||||
if (typeof payload === "string") { try { payload = JSON.parse(payload); } catch (e) { return; } }
|
||||
if (payload.command === "list_actions") {
|
||||
var next = stateFromPayload(payload);
|
||||
ACTIONS = next.actions;
|
||||
FAVS = next.favourites;
|
||||
query = next.query;
|
||||
sel = next.sel;
|
||||
lastResizeHeight = next.lastResizeHeight;
|
||||
if (qEl) {
|
||||
qEl.value = "";
|
||||
qEl.placeholder = "Search " + ACTIONS.length + " actions";
|
||||
syncClearButton();
|
||||
}
|
||||
render({ resize: true, resetScroll: true });
|
||||
focusInput();
|
||||
}
|
||||
};
|
||||
|
||||
// ---- DOM helpers -------------------------------------------------------------
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function byId(id) {
|
||||
for (var i = 0; i < ACTIONS.length; i++) if (ACTIONS[i].id === id) return ACTIONS[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
function currentVisibleFavs() { return visibleFavourites(FAVS, ACTIONS); }
|
||||
|
||||
function hue(id) {
|
||||
var h = 0;
|
||||
for (var i = 0; i < id.length; i++)
|
||||
h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
||||
return h % 360;
|
||||
}
|
||||
|
||||
// Build a <div class=className> with the search-match ranges wrapped in <mark>. Used for both the
|
||||
// title and the source eyebrow. Pure (only touches the document factory), so the node-vm test never
|
||||
// calls it and load-time stays DOM-free.
|
||||
function markedText(className, text, match) {
|
||||
var node = document.createElement("div");
|
||||
node.className = className; node.title = text;
|
||||
if (!match || !match.length) {
|
||||
node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
var last = 0;
|
||||
for (var i = 0; i < match.length; i++) {
|
||||
var range = match[i];
|
||||
if (range[0] > last)
|
||||
node.appendChild(document.createTextNode(text.slice(last, range[0])));
|
||||
var m = document.createElement("mark");
|
||||
m.textContent = text.slice(range[0], range[1]);
|
||||
node.appendChild(m);
|
||||
last = range[1];
|
||||
}
|
||||
if (last < text.length)
|
||||
node.appendChild(document.createTextNode(text.slice(last)));
|
||||
return node;
|
||||
}
|
||||
|
||||
function starSvg(on) {
|
||||
return '<svg width="15" height="15" viewBox="0 0 24 24" fill="' + (on ? "currentColor" : "none") +
|
||||
'" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">' +
|
||||
'<path d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 17.9 6.8 20.6l1-5.8L3.5 9.7l5.9-.9z"/></svg>';
|
||||
}
|
||||
|
||||
// ---- render ------------------------------------------------------------------
|
||||
function renderFav() {
|
||||
favEl.innerHTML = "";
|
||||
var favs = currentVisibleFavs();
|
||||
favEl.hidden = favs.length === 0;
|
||||
if (!favs.length && sel.zone === "fav")
|
||||
sel = { zone: "list", i: 0 };
|
||||
else if (sel.zone === "fav")
|
||||
sel.i = Math.max(0, Math.min(sel.i, favs.length - 1));
|
||||
updateFavEyebrow(favs);
|
||||
favs.forEach(function (id, i) {
|
||||
var a = byId(id);
|
||||
var tile = document.createElement("button");
|
||||
tile.className = "fav-tile" + (sel.zone === "fav" && sel.i === i ? " sel" : "");
|
||||
tile.style.setProperty("--h", hue(id));
|
||||
tile.textContent = tileCode(a, ACTIONS);
|
||||
tile.title = a.title;
|
||||
tile.setAttribute("aria-label", actionLabel(a, ACTIONS));
|
||||
tile.onclick = function () { sel = { zone: "fav", i: i }; run(a); };
|
||||
tile.oncontextmenu = function (ev) {
|
||||
ev.preventDefault();
|
||||
// why: selecting shows the eyebrow, which grows the launcher - resize so the popup
|
||||
// isn't clipped (mirrors arrow-nav). requestResize no-ops when height is unchanged.
|
||||
sel = { zone: "fav", i: i }; render({ resize: true });
|
||||
showFavMenu(ev.clientX, ev.clientY, id);
|
||||
};
|
||||
favEl.appendChild(tile);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- favourite context menu (right-click a tile) -----------------------------
|
||||
var favMenuEl = null;
|
||||
|
||||
function hideFavMenu() { if (favMenuEl) favMenuEl.hidden = true; }
|
||||
|
||||
function addFavMenuItem(label, enabled, fn) {
|
||||
var item = document.createElement("button");
|
||||
item.className = "ctx-item";
|
||||
item.textContent = label;
|
||||
item.disabled = !enabled;
|
||||
item.onclick = function () { hideFavMenu(); fn(); };
|
||||
favMenuEl.appendChild(item);
|
||||
}
|
||||
|
||||
// One reused menu node (Move left/right + Unpin), positioned at the cursor and clamped
|
||||
// to the viewport. Native browser context menus can't add items, so we roll our own tiny one.
|
||||
function showFavMenu(x, y, id) {
|
||||
if (!favMenuEl) {
|
||||
favMenuEl = document.createElement("div");
|
||||
favMenuEl.className = "ctx-menu";
|
||||
document.body.appendChild(favMenuEl);
|
||||
}
|
||||
favMenuEl.innerHTML = "";
|
||||
var favs = currentVisibleFavs();
|
||||
var vi = favs.indexOf(id);
|
||||
addFavMenuItem("Move left", vi > 0, function () { moveFav(id, -1); });
|
||||
addFavMenuItem("Move right", vi >= 0 && vi < favs.length - 1, function () { moveFav(id, 1); });
|
||||
addFavMenuItem("Unpin", true, function () { toggleFav(id); });
|
||||
favMenuEl.hidden = false;
|
||||
favMenuEl.style.left = Math.max(0, Math.min(x, window.innerWidth - favMenuEl.offsetWidth - 4)) + "px";
|
||||
favMenuEl.style.top = Math.max(0, Math.min(y, window.innerHeight - favMenuEl.offsetHeight - 4)) + "px";
|
||||
}
|
||||
|
||||
// Swap a favourite with its visible neighbour (dir -1/+1) and persist the new order. Swapping
|
||||
// by id inside FAVS (not the visible slice) keeps any hidden pins (no live action) in place.
|
||||
function moveFav(id, dir) {
|
||||
var favs = currentVisibleFavs();
|
||||
var vi = favs.indexOf(id);
|
||||
var ni = vi + dir;
|
||||
if (vi === -1 || ni < 0 || ni >= favs.length) return;
|
||||
var a = FAVS.indexOf(id), b = FAVS.indexOf(favs[ni]);
|
||||
if (a === -1 || b === -1) return;
|
||||
FAVS[a] = favs[ni]; FAVS[b] = id;
|
||||
SendMessage({ command: "reorder_favourites", ids: FAVS.slice() });
|
||||
sel = { zone: "fav", i: ni };
|
||||
render({ resize: true });
|
||||
}
|
||||
|
||||
// Name of the selected favourite, shown above the bar; hidden unless a fav is selected.
|
||||
function updateFavEyebrow(favs) {
|
||||
if (!eyeEl) return;
|
||||
var a = sel.zone === "fav" && favs.length ? byId(favs[sel.i]) : null;
|
||||
eyeEl.textContent = a ? a.title : "";
|
||||
eyeEl.hidden = !a;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
var q = (query || "").trim();
|
||||
var arr = filterActions(ACTIONS, query);
|
||||
if (sel.zone === "list")
|
||||
sel.i = Math.max(0, Math.min(sel.i, arr.length - 1));
|
||||
listEl.innerHTML = "";
|
||||
// why: search-first - the list stays blank until the user types; only a
|
||||
// non-empty query with zero hits earns the "No actions match" message.
|
||||
if (!shouldRenderActionList(query)) {
|
||||
listEl.className = "dial-list empty";
|
||||
if (countEl) countEl.hidden = true;
|
||||
return;
|
||||
}
|
||||
listEl.className = "dial-list" + (!arr.length ? " empty" : "");
|
||||
if (!arr.length) {
|
||||
if (countEl) countEl.hidden = true;
|
||||
var empty = document.createElement("div");
|
||||
empty.className = "dial-empty";
|
||||
empty.textContent = "No actions match (Total: " + ACTIONS.length + ")";
|
||||
listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
if (countEl) {
|
||||
countEl.hidden = false;
|
||||
countEl.textContent = resultCountText(ACTIONS.length, arr.length, query);
|
||||
}
|
||||
arr.forEach(function (a, i) {
|
||||
var on = FAVS.indexOf(a.id) !== -1;
|
||||
var row = document.createElement("div");
|
||||
row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : "");
|
||||
row.setAttribute("aria-label", actionLabel(a, ACTIONS));
|
||||
|
||||
var tile = document.createElement("div");
|
||||
tile.className = "tile";
|
||||
tile.style.setProperty("--h", hue(a.id));
|
||||
tile.textContent = tileCode(a, ACTIONS);
|
||||
|
||||
var left = document.createElement("div");
|
||||
left.className = "row-left";
|
||||
var mi = matchIndex[a.id];
|
||||
var sourceEl = markedText("row-eyebrow", a.source, mi ? mi.source : null);
|
||||
var line = document.createElement("div");
|
||||
line.className = "row-line";
|
||||
var name = markedText("row-name", a.title, mi ? mi.title : null);
|
||||
line.appendChild(name);
|
||||
if (a.shortcut) {
|
||||
var sc = document.createElement("div");
|
||||
sc.className = "row-sc";
|
||||
a.shortcut.split("+").forEach(function (k) {
|
||||
var key = document.createElement("kbd");
|
||||
key.textContent = k;
|
||||
sc.appendChild(key);
|
||||
});
|
||||
line.appendChild(sc);
|
||||
}
|
||||
left.appendChild(sourceEl);
|
||||
left.appendChild(line);
|
||||
row.appendChild(tile);
|
||||
row.appendChild(left);
|
||||
|
||||
var star = document.createElement("button");
|
||||
star.className = "star" + (on ? " on" : "");
|
||||
star.innerHTML = starSvg(on);
|
||||
star.title = on ? "Unpin from favourites" : "Pin to favourites";
|
||||
star.onclick = function (ev) { ev.stopPropagation(); toggleFav(a.id); };
|
||||
// why: two quick fav/unfav clicks must not dblclick-run the row
|
||||
star.ondblclick = function (ev) { ev.stopPropagation(); };
|
||||
row.appendChild(star);
|
||||
|
||||
row.onclick = function () { sel = { zone: "list", i: i }; render(); };
|
||||
row.ondblclick = function () { sel = { zone: "list", i: i }; run(a); };
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function render(opts) {
|
||||
renderFav();
|
||||
renderList();
|
||||
scrollSelectedIntoView();
|
||||
if (opts && opts.resetScroll)
|
||||
resetScrollPositions(listEl, document);
|
||||
if (opts && opts.resize)
|
||||
requestResize();
|
||||
}
|
||||
|
||||
// Keep the selected item in view as arrows move it: the list scrolls vertically, the fav bar
|
||||
// horizontally (arrow nav "pushes" the scrollable fav row to follow the selection).
|
||||
function scrollSelectedIntoView() {
|
||||
var el = null;
|
||||
if (sel.zone === "list" && listEl)
|
||||
el = listEl.querySelector(".row.sel");
|
||||
else if (sel.zone === "fav" && favEl)
|
||||
el = favEl.querySelector(".fav-tile.sel");
|
||||
if (el && el.scrollIntoView)
|
||||
el.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
}
|
||||
|
||||
function requestResize() {
|
||||
if (!document.body)
|
||||
return;
|
||||
setTimeout(function () {
|
||||
var launcher = document.querySelector(".launcher");
|
||||
if (!launcher)
|
||||
return;
|
||||
var height = Math.ceil(launcher.getBoundingClientRect().height);
|
||||
if (!height || height === lastResizeHeight)
|
||||
return;
|
||||
lastResizeHeight = height;
|
||||
SendMessage({ command: "resize", height: height });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// ---- actions -----------------------------------------------------------------
|
||||
function toggleFav(id) {
|
||||
var k = FAVS.indexOf(id);
|
||||
var newState = k === -1;
|
||||
if (newState) FAVS.push(id); else FAVS.splice(k, 1);
|
||||
SendMessage({ command: "toggle_favourite", id: id, fav: newState });
|
||||
render({ resize: true });
|
||||
}
|
||||
|
||||
// Fire the action; C++ owns the run-confirm (native dialog) + suppression, then closes the popup + toasts.
|
||||
function run(a) {
|
||||
if (!a) return;
|
||||
SendMessage({ command: "run_action", id: a.id, title: a.title });
|
||||
}
|
||||
|
||||
function runSelected() {
|
||||
var id = selectedActionId(sel, filterActions(ACTIONS, query), currentVisibleFavs(), query);
|
||||
if (id) run(byId(id));
|
||||
}
|
||||
|
||||
function focusInput() { setTimeout(function () { qEl.focus(); }, 0); }
|
||||
|
||||
// ---- init --------------------------------------------------------------------
|
||||
function OnInit() {
|
||||
qEl = $("q"); listEl = $("list"); favEl = $("favBar"); clearEl = $("clear"); eyeEl = $("favEyebrow"); countEl = $("count");
|
||||
syncClearButton();
|
||||
|
||||
$("clear").onclick = function () {
|
||||
query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; render({ resize: true, resetScroll: true }); qEl.focus();
|
||||
syncClearButton();
|
||||
};
|
||||
qEl.addEventListener("input", function () {
|
||||
query = qEl.value; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true });
|
||||
});
|
||||
|
||||
// why: dismiss the fav context menu on any click/scroll away from it (capture scroll to catch nested scrollers).
|
||||
document.addEventListener("click", hideFavMenu);
|
||||
document.addEventListener("scroll", hideFavMenu, true);
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (favMenuEl && !favMenuEl.hidden && e.key === "Escape") { e.preventDefault(); hideFavMenu(); return; }
|
||||
var arr = filterActions(ACTIONS, query);
|
||||
var favs = currentVisibleFavs();
|
||||
// why: Up/Down always navigate; Left/Right only navigate the fav bar. In the list zone,
|
||||
// let Left/Right fall through so they move the caret in the focused search field.
|
||||
var lr = e.key === "ArrowLeft" || e.key === "ArrowRight";
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp" || (lr && sel.zone === "fav")) {
|
||||
e.preventDefault();
|
||||
sel = nextSel(sel, e.key, arr.length, favs.length);
|
||||
// why: entering/leaving the fav zone toggles the eyebrow line, changing launcher height;
|
||||
// resize so the popup grows/shrinks instead of clipping. requestResize no-ops when unchanged.
|
||||
render({ resize: true });
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
runSelected();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (query) { query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true }); }
|
||||
else SendMessage({ command: "close_page" });
|
||||
}
|
||||
});
|
||||
|
||||
SendMessage({ command: "request_actions" });
|
||||
}
|
||||
45
resources/web/dialog/SpeedDial/speeddial.test.js
Normal file
45
resources/web/dialog/SpeedDial/speeddial.test.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// Regression tests for the DOM-free Speed Dial helpers.
|
||||
// Run: node resources/web/dialog/SpeedDial/speeddial.test.js
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
|
||||
const ctx = {};
|
||||
ctx.window = ctx;
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(fs.readFileSync(__dirname + "/../../js/fuzzy-search.js", "utf8"), ctx);
|
||||
vm.runInContext(fs.readFileSync(__dirname + "/speeddial.js", "utf8"), ctx);
|
||||
|
||||
assert.equal(typeof ctx.parseId, "undefined", "opaque action ids must never be parsed");
|
||||
|
||||
const duplicateActions = [
|
||||
{ id: "0123456789abcdef", title: "Repair", source: "Mesh Tools" },
|
||||
{ id: "fedcba9876543210", title: "Repair", source: "Mesh Tools" }
|
||||
];
|
||||
assert.equal(
|
||||
ctx.actionLabel(duplicateActions[0], duplicateActions),
|
||||
"Repair from Mesh Tools (0123456789abcdef)",
|
||||
"duplicate labels should use the opaque id without interpreting its contents"
|
||||
);
|
||||
|
||||
assert.equal(ctx.shouldRenderActionList(""), false, "an empty search keeps the action list hidden");
|
||||
assert.equal(ctx.shouldRenderActionList(" "), false, "whitespace-only search keeps the action list hidden");
|
||||
assert.equal(ctx.shouldRenderActionList("r"), true, "typing starts rendering matching actions");
|
||||
|
||||
assert.equal(
|
||||
ctx.selectedActionId({ zone: "list", i: 0 }, duplicateActions, [], ""),
|
||||
null,
|
||||
"Enter with an empty query must not resolve to an action the blank list never showed"
|
||||
);
|
||||
assert.equal(
|
||||
ctx.selectedActionId({ zone: "list", i: 0 }, duplicateActions, [], "rep"),
|
||||
"0123456789abcdef",
|
||||
"a typed query resolves the list selection"
|
||||
);
|
||||
assert.equal(
|
||||
ctx.selectedActionId({ zone: "fav", i: 0 }, duplicateActions, ["fedcba9876543210"], ""),
|
||||
"fedcba9876543210",
|
||||
"favourites stay runnable with an empty query - the fav bar is always visible"
|
||||
);
|
||||
|
||||
console.log("ok");
|
||||
225
resources/web/dialog/SpeedDial/style.css
Normal file
225
resources/web/dialog/SpeedDial/style.css
Normal file
@@ -0,0 +1,225 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body {
|
||||
font-family: var(--orca-font, "Segoe UI", sans-serif);
|
||||
font-size: 13px;
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
background: var(--bg, var(--orca-bg, #fff));
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
.launcher {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel, var(--orca-bg, #fff));
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
overflow: hidden;
|
||||
/* why: no height cap here - the launcher reports its true natural height to C++, which
|
||||
sizes the popup to match (HTML is source of truth). The list's own max-height is what
|
||||
bounds growth; capping the launcher would make the measurement circular. */
|
||||
}
|
||||
.fav-bar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
overflow-x: auto; /* scroll horizontally once favourites overflow the row */
|
||||
scrollbar-width: thin;
|
||||
/* why: keep scrollIntoView (arrow-nav) from scrolling the first/last tile flush to the edge,
|
||||
which would clip its selected outline. Matches the 10px horizontal padding. */
|
||||
scroll-padding-inline: 10px;
|
||||
}
|
||||
.fav-bar[hidden] { display: none; }
|
||||
/* Name of the selected favourite, above the bar; left-aligned to the tiles' 10px inset. */
|
||||
.fav-eyebrow { flex: 0 0 auto; padding: 8px 10px 0; }
|
||||
.fav-tile {
|
||||
flex: 0 0 auto; /* keep tiles full-size; don't shrink to fit - scroll instead */
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--h) var(--speed-tile-text-s, 72%) var(--speed-tile-text-l, 38%));
|
||||
font-weight: 700;
|
||||
/* why: mirror .tile centering - tileCode can be 2-3 chars (e.g. "EA1") on collision, and a
|
||||
bare <button> inherits 13px + UA padding, clipping the code (e.g. "AE1"). inline-flex + 12px + pad:0 fits it. */
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: var(--speed-tile-bg, #f0f0f0);
|
||||
border: 1px solid var(--speed-tile-border, #d8d8d8);
|
||||
}
|
||||
.fav-tile.sel { outline: 2px solid var(--main-color, var(--orca-accent, #009688)); outline-offset: 2px; }
|
||||
.ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
background: var(--panel, var(--orca-bg, #fff));
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.18);
|
||||
}
|
||||
.ctx-menu[hidden] { display: none; }
|
||||
.ctx-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ctx-item:hover { background: var(--row-hover, rgba(127,127,127,.16)); }
|
||||
.ctx-item:disabled { opacity: .4; cursor: default; }
|
||||
.ctx-item:disabled:hover { background: transparent; }
|
||||
.dial-head { flex: 0 0 auto; padding: 8px; }
|
||||
.plugin-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
background: var(--panel, var(--orca-bg, #fff));
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
border-radius: 7px;
|
||||
}
|
||||
.plugin-search:focus-within {
|
||||
border-color: var(--main-color, var(--orca-accent, #009688));
|
||||
box-shadow: 0 0 0 2px rgba(0,150,136,.25);
|
||||
}
|
||||
.plugin-search-icon { display: inline-flex; color: var(--muted, var(--orca-muted, #6b7280)); }
|
||||
.plugin-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
font: inherit;
|
||||
}
|
||||
.plugin-search-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
cursor: pointer;
|
||||
background: rgba(127,127,127,.20);
|
||||
}
|
||||
.plugin-search-clear[hidden] { display: none; }
|
||||
.dial-count {
|
||||
padding: 4px 4px 0;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
}
|
||||
.dial-count[hidden] { display: none; }
|
||||
.dial-list {
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
/* ADJUST HEIGHT HERE. The popup auto-resizes to the content (HTML is the source of truth), so
|
||||
this list cap drives the whole window height: --rows full rows + a ~30% peek of the next row
|
||||
as a "scroll for more" affordance. Bump --rows to show more rows; change 0.3 for a bigger/
|
||||
smaller peek. --row-h MUST match .row min-height (44px). */
|
||||
--row-h: 44px;
|
||||
--rows: 5;
|
||||
max-height: calc(var(--rows) * var(--row-h) + 0.3 * var(--row-h) + 4px);
|
||||
overflow-y: auto;
|
||||
padding: 4px 4px 6px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.dial-list.empty { overflow-y: hidden; }
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row:hover { background: var(--row-hover, rgba(127,127,127,.16)); }
|
||||
.row.sel { background: var(--row-selected, rgba(0,150,136,.18)); }
|
||||
.tile {
|
||||
flex: 0 0 auto;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--h) var(--speed-tile-text-s, 72%) var(--speed-tile-text-l, 38%));
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--speed-tile-bg, #f0f0f0);
|
||||
border: 1px solid var(--speed-tile-border, #d8d8d8);
|
||||
}
|
||||
.row-left { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
|
||||
.row-eyebrow {
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
opacity: .85;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-line { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
||||
.row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.row-name mark, .row-eyebrow mark { background: var(--plugin-status-warn-bg); color: var(--plugin-status-warn); border-radius: 2px; }
|
||||
.row-sc { flex: 0 0 auto; display: inline-flex; gap: 3px; }
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
background: rgba(127,127,127,.12);
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
border-radius: 4px;
|
||||
}
|
||||
.star {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
}
|
||||
.star svg { display: block; }
|
||||
.row:hover .star:not(.on),
|
||||
.row.sel .star:not(.on) { opacity: .5; }
|
||||
.star.on { opacity: 1; color: var(--main-color, var(--orca-accent, #009688)); }
|
||||
.star:hover { background: rgba(127,127,127,.18); }
|
||||
.dial-empty {
|
||||
min-height: 64px;
|
||||
padding: 18px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
text-align: center;
|
||||
}
|
||||
29
resources/web/dialog/TerminalDialog/index.html
Normal file
29
resources/web/dialog/TerminalDialog/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Terminal</title>
|
||||
<link rel="stylesheet" href="../../include/xterm/xterm.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../../include/global.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../css/common.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../css/theme.css" />
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
<script type="text/javascript" src="../js/jquery-3.6.0.min.js"></script>
|
||||
<script type="text/javascript" src="../js/json2.js"></script>
|
||||
<script type="text/javascript" src="../../data/text.js"></script>
|
||||
<script type="text/javascript" src="../js/globalapi.js"></script>
|
||||
<script type="text/javascript" src="../../include/xterm/xterm.js"></script>
|
||||
<script type="text/javascript" src="../../include/xterm/xterm-addon-fit.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
</head>
|
||||
<body onLoad="OnInit()">
|
||||
<div class="app">
|
||||
<div id="terminal-container"></div>
|
||||
<div class="input-row">
|
||||
<input type="text" id="cmd-input" placeholder="python ... or uv ..." />
|
||||
<button id="run-btn">Run</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
141
resources/web/dialog/TerminalDialog/index.js
Normal file
141
resources/web/dialog/TerminalDialog/index.js
Normal file
@@ -0,0 +1,141 @@
|
||||
let term = null;
|
||||
let fitAddon = null;
|
||||
|
||||
// ANSI palettes for the xterm theme — the standard VS Code dark/light sets, picked by
|
||||
// data-orca-theme. Constant data, so defined once at module scope.
|
||||
const ANSI_DARK = {
|
||||
black:"#000000", red:"#cd3131", green:"#0dbc79", yellow:"#e5e510", blue:"#2472c8",
|
||||
magenta:"#bc3fbc", cyan:"#11a8cd", white:"#e5e5e5",
|
||||
brightBlack:"#666666", brightRed:"#f14c4c", brightGreen:"#23d18b", brightYellow:"#f5f543",
|
||||
brightBlue:"#3b8eea", brightMagenta:"#d670d6", brightCyan:"#29b8db", brightWhite:"#ffffff"
|
||||
};
|
||||
const ANSI_LIGHT = {
|
||||
black:"#000000", red:"#cd3131", green:"#107c10", yellow:"#949800", blue:"#0451a5",
|
||||
magenta:"#bc05bc", cyan:"#0598bc", white:"#555555",
|
||||
brightBlack:"#8a8a8a", brightRed:"#cd3131", brightGreen:"#14ce14", brightYellow:"#b5ba00",
|
||||
brightBlue:"#0451a5", brightMagenta:"#bc05bc", brightCyan:"#0598bc", brightWhite:"#a5a5a5"
|
||||
};
|
||||
|
||||
// The xterm.js theme is a plain JS object (it cannot read CSS variables), so build it
|
||||
// from the host contract: background/foreground come from the injected --orca-* colors;
|
||||
// the ANSI palette is the fixed light or dark set chosen by data-orca-theme.
|
||||
function XtermTheme() {
|
||||
var cs = getComputedStyle(document.documentElement);
|
||||
var bg = (cs.getPropertyValue('--orca-bg') || '#1e1e1e').trim();
|
||||
var fg = (cs.getPropertyValue('--orca-fg') || '#d4d4d4').trim();
|
||||
var dark = document.documentElement.getAttribute('data-orca-theme') !== 'light';
|
||||
return Object.assign({ background: bg, foreground: fg, cursor: fg }, dark ? ANSI_DARK : ANSI_LIGHT);
|
||||
}
|
||||
|
||||
// Re-apply the xterm theme when the host flips data-orca-theme (live re-theme).
|
||||
function WatchXtermTheme() {
|
||||
var obs = new MutationObserver(function () {
|
||||
if (term) term.options.theme = XtermTheme();
|
||||
});
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-orca-theme'] });
|
||||
}
|
||||
|
||||
function OnInit() {
|
||||
if (typeof TranslatePage === "function")
|
||||
TranslatePage();
|
||||
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", monospace',
|
||||
theme: XtermTheme(),
|
||||
allowProposedApi: true
|
||||
});
|
||||
|
||||
WatchXtermTheme();
|
||||
|
||||
fitAddon = new (FitAddon.FitAddon || FitAddon)();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
term.open(document.getElementById("terminal-container"));
|
||||
fitAddon.fit();
|
||||
|
||||
// Focus the terminal so it captures keyboard input
|
||||
term.focus();
|
||||
|
||||
// Re-focus terminal when user clicks on it
|
||||
term.element.addEventListener("click", () => term.focus());
|
||||
|
||||
window.addEventListener("resize", () => fitAddon.fit());
|
||||
|
||||
// Send keystrokes to C++
|
||||
term.onData((data) => {
|
||||
SendWXMessage(JSON.stringify({
|
||||
command: "write_stdin",
|
||||
data: data
|
||||
}));
|
||||
});
|
||||
|
||||
document.getElementById("run-btn").addEventListener("click", onRun);
|
||||
document.getElementById("cmd-input").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter")
|
||||
onRun();
|
||||
});
|
||||
|
||||
term.writeln("Plugin terminal ready.");
|
||||
}
|
||||
|
||||
function onRun() {
|
||||
const input = document.getElementById("cmd-input");
|
||||
const cmd = input.value.trim();
|
||||
if (!cmd)
|
||||
return;
|
||||
|
||||
input.value = "";
|
||||
|
||||
term.writeln("$ " + cmd);
|
||||
|
||||
setRunning(true);
|
||||
|
||||
SendWXMessage(JSON.stringify({
|
||||
command: "run_command",
|
||||
cmd: cmd
|
||||
}));
|
||||
}
|
||||
|
||||
function setRunning(running) {
|
||||
document.getElementById("run-btn").disabled = running;
|
||||
document.getElementById("cmd-input").disabled = running;
|
||||
}
|
||||
|
||||
function HandleStudio(value) {
|
||||
const payload = (typeof value === "string") ? SafeJsonParse(value) : value;
|
||||
if (!payload || typeof payload !== "object")
|
||||
return;
|
||||
|
||||
switch (payload.command) {
|
||||
case "output":
|
||||
if (payload.lines && Array.isArray(payload.lines)) {
|
||||
payload.lines.forEach((line) => {
|
||||
if (line.is_stderr)
|
||||
term.writeln("\x1b[91m" + line.text + "\x1b[0m");
|
||||
else
|
||||
term.writeln(line.text);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "process_done":
|
||||
setRunning(false);
|
||||
term.writeln("\x1b[90mProcess exited with code: " + payload.exit_code + "\x1b[0m");
|
||||
break;
|
||||
|
||||
case "process_error":
|
||||
setRunning(false);
|
||||
term.writeln("\x1b[91m" + payload.message + "\x1b[0m");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function SafeJsonParse(value) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
74
resources/web/dialog/TerminalDialog/styles.css
Normal file
74
resources/web/dialog/TerminalDialog/styles.css
Normal file
@@ -0,0 +1,74 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#terminal-container {
|
||||
flex: 1;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#terminal-container,
|
||||
#terminal-container .xterm,
|
||||
#terminal-container .xterm * {
|
||||
font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
|
||||
letter-spacing: 0;
|
||||
word-spacing: 0;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
#cmd-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#cmd-input:focus {
|
||||
border-color: var(--orca-accent);
|
||||
}
|
||||
|
||||
#run-btn {
|
||||
padding: 6px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: var(--orca-accent);
|
||||
color: var(--orca-accent-fg);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#run-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
#run-btn:disabled {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
:root {
|
||||
--bg: #1b1f24;
|
||||
--panel: #242a31;
|
||||
--border: #3a424d;
|
||||
--border-strong: #3a424d;
|
||||
--border-soft: #313843;
|
||||
--col-sep: #3a424d;
|
||||
|
||||
--text: #e6ebf0;
|
||||
--row-hover: #2b3340;
|
||||
--row-selected: #244945;
|
||||
--row-selected-outline: #00bfa5;
|
||||
|
||||
--footer-bg: #20262d;
|
||||
--btn-bg: #2a313a;
|
||||
--btn-border: #4b5664;
|
||||
--btn-hover: #333c47;
|
||||
|
||||
--ctx-bg: #2a313a;
|
||||
--ctx-border: #4b5664;
|
||||
--ctx-hover: #3a4451;
|
||||
}
|
||||
|
||||
*
|
||||
{
|
||||
color: #efeff0;
|
||||
border-color: #B9B9BC;
|
||||
}
|
||||
|
||||
body
|
||||
{
|
||||
background-color:#2D2D31; /* ORCA match background color */
|
||||
color: #efeff0;
|
||||
}
|
||||
|
||||
.ZScrol::-webkit-scrollbar-thumb {/*滚动条里面小方块*/
|
||||
background-color: #939594;
|
||||
}
|
||||
|
||||
.ZScrol::-webkit-scrollbar-track {/*滚动条里面轨道*/
|
||||
background: #161817;
|
||||
}
|
||||
|
||||
#Title div
|
||||
{
|
||||
color: #009688;
|
||||
}
|
||||
|
||||
.search>input[type=text]{
|
||||
background-color:#2D2D31;
|
||||
}
|
||||
|
||||
/*---Checkboxes ORCA---*/
|
||||
input[type=checkbox]{
|
||||
background-color:#2D2D31;
|
||||
border-color:#4A4A51;
|
||||
}
|
||||
|
||||
input[type=checkbox]:checked{
|
||||
background-color:#009688;
|
||||
}
|
||||
|
||||
/*-------Text------*/
|
||||
|
||||
.TextS1
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
.TextS2
|
||||
{
|
||||
color:#B9B9BC;
|
||||
}
|
||||
|
||||
/*---Policy---*/
|
||||
.TextArea1
|
||||
{
|
||||
background-color: #4A4A51;
|
||||
color: #BEBEC0;
|
||||
}
|
||||
|
||||
/*----Region---*/
|
||||
.RegionItem:hover
|
||||
{
|
||||
background-color:#4C4C55;
|
||||
}
|
||||
|
||||
.RegionSelected:hover
|
||||
{
|
||||
background-color:#009688;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/*----Menu----*/
|
||||
#Title div.TitleUnselected
|
||||
{
|
||||
color: #BEBEC0;
|
||||
}
|
||||
|
||||
|
||||
113
resources/web/dialog/css/theme.css
Normal file
113
resources/web/dialog/css/theme.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* Shared theme for resources/web/dialog/* pages.
|
||||
*
|
||||
* App-matched roles are pulled from the host theme "contract" (:root{--orca-*}),
|
||||
* injected from live C++ app colors by WebViewHostDialog. Web-only semantics (row
|
||||
* selection, status/source badges, footer/button/context surfaces) have no native
|
||||
* equivalent, so they are curated here for light and selected for dark by the
|
||||
* [data-orca-theme="dark"] attribute the host sets — the single authoritative theme
|
||||
* signal on all platforms (prefers-color-scheme is intentionally not used).
|
||||
*
|
||||
* This file is linked AFTER common.css so its element rules re-theme the shared chrome
|
||||
* via variables (replacing the retired dark.css). It replaces each page's own :root
|
||||
* palette and the dark.css <link>. */
|
||||
|
||||
:root {
|
||||
/* --- app-matched roles (from the injected contract) --- */
|
||||
--bg: var(--orca-bg);
|
||||
--panel: var(--orca-bg);
|
||||
--text: var(--orca-fg);
|
||||
--muted: var(--orca-muted);
|
||||
--border: var(--orca-border);
|
||||
--border-strong: var(--orca-border);
|
||||
--border-soft: var(--orca-border);
|
||||
--col-sep: var(--orca-border);
|
||||
--row-selected-outline: var(--orca-accent);
|
||||
--main-color: var(--orca-accent);
|
||||
--main-color-hover: var(--orca-accent);
|
||||
--plugin-link-text: var(--orca-accent);
|
||||
|
||||
/* --- web-only semantics: LIGHT --- */
|
||||
--row-hover: #f7f9fb;
|
||||
--row-selected: #eaf2ff;
|
||||
--plugin-status-danger: #b42318;
|
||||
--plugin-status-ok: #137333;
|
||||
--plugin-status-warn: #a15c00;
|
||||
--plugin-status-inactive: var(--muted);
|
||||
--plugin-status-danger-bg: rgba(180, 35, 24, 0.12);
|
||||
--plugin-status-ok-bg: rgba(19, 115, 51, 0.12);
|
||||
--plugin-status-warn-bg: rgba(199, 122, 22, 0.12);
|
||||
--plugin-status-inactive-bg: #edf1f5;
|
||||
--plugin-status-inactive-text: #44505c;
|
||||
--plugin-source-mine-bg: rgba(0, 137, 123, 0.12);
|
||||
--plugin-source-mine-text: #00897b;
|
||||
--plugin-source-neutral-bg: #edf1f5;
|
||||
--plugin-source-neutral-text: #44505c;
|
||||
--plugin-source-subscribed-bg: rgba(37, 99, 170, 0.12);
|
||||
--plugin-source-subscribed-text: #2563aa;
|
||||
--footer-bg: #fafafa;
|
||||
--btn-bg: #ffffff;
|
||||
--btn-border: #cccccc;
|
||||
--btn-hover: #f0f0f0;
|
||||
--ctx-bg: #ffffff;
|
||||
--ctx-border: #cccccc;
|
||||
--ctx-hover: #efefef;
|
||||
|
||||
/* Speed dial icon tile treatment */
|
||||
--speed-tile-bg: #efefef;
|
||||
--speed-tile-border: #d8d8d8;
|
||||
--speed-tile-text-s: 74%;
|
||||
--speed-tile-text-l: 34%;
|
||||
}
|
||||
|
||||
:root[data-orca-theme="dark"] {
|
||||
/* The app-injected border (var(--orca-border) ≈ #36363B in dark) sits almost on top of
|
||||
the dark panels; lift the border roles a touch so dividers/outlines stay visible. */
|
||||
--border: #4a4a51;
|
||||
--border-strong: #4a4a51;
|
||||
--border-soft: #4a4a51;
|
||||
--col-sep: #4a4a51;
|
||||
--row-hover: #2b3340;
|
||||
--row-selected: #244945;
|
||||
--plugin-link-text: #5eead4;
|
||||
--plugin-status-danger: #ff7b72;
|
||||
--plugin-status-ok: #37c871;
|
||||
--plugin-status-warn: #f0b45a;
|
||||
--plugin-status-inactive: #b9c0c8;
|
||||
--plugin-status-danger-bg: rgba(255, 123, 114, 0.16);
|
||||
--plugin-status-ok-bg: rgba(55, 200, 113, 0.16);
|
||||
--plugin-status-warn-bg: rgba(240, 180, 90, 0.18);
|
||||
--plugin-status-inactive-bg: #36363b;
|
||||
--plugin-status-inactive-text: #c7ccd2;
|
||||
--plugin-source-mine-bg: rgba(0, 150, 136, 0.18);
|
||||
--plugin-source-mine-text: #8de5d6;
|
||||
--plugin-source-neutral-bg: #36363b;
|
||||
--plugin-source-neutral-text: #c7ccd2;
|
||||
--plugin-source-subscribed-bg: rgba(88, 166, 255, 0.18);
|
||||
--plugin-source-subscribed-text: #8fc0f0;
|
||||
--footer-bg: #20262d;
|
||||
--btn-bg: #2a313a;
|
||||
--btn-border: #4b5664;
|
||||
--btn-hover: #333c47;
|
||||
--ctx-bg: #2a313a;
|
||||
--ctx-border: #4b5664;
|
||||
--ctx-hover: #3a4451;
|
||||
|
||||
/* Speed dial icon tile treatment */
|
||||
--speed-tile-bg: #34343b;
|
||||
--speed-tile-border: #50505a;
|
||||
--speed-tile-text-s: 82%;
|
||||
--speed-tile-text-l: 84%;
|
||||
}
|
||||
|
||||
/* Re-theme the shared common.css chrome through variables (replaces dark.css's
|
||||
* hardcoded element overrides). Placed after common.css in every page's <head>. */
|
||||
html { background: var(--bg); }
|
||||
body { background: var(--bg); color: var(--text); }
|
||||
#Content { color: var(--text); }
|
||||
#Title div, #Title div.TitleUnselected { color: var(--orca-accent); }
|
||||
.HyperLink { color: var(--orca-accent); }
|
||||
.ZScrol::-webkit-scrollbar-thumb { background-color: var(--border); }
|
||||
.ZScrol::-webkit-scrollbar-track { background: var(--bg); }
|
||||
input[type="checkbox"] { background-color: var(--bg); border-color: var(--border); }
|
||||
input[type="checkbox"]:checked { background-color: var(--orca-accent); border-color: var(--orca-accent); }
|
||||
input[type="checkbox"]::before { box-shadow: inset 1em 1em var(--orca-accent-fg); }
|
||||
@@ -336,4 +336,4 @@ function ExecuteDarkMode( DarkCssPath )
|
||||
}
|
||||
}
|
||||
|
||||
SwitchDarkMode( "../css/dark.css" );
|
||||
// Dialog pages are themed by the injected --orca-* contract + theme.css; the legacy dark.css poll is fully retired.
|
||||
8
resources/web/include/xterm/xterm-addon-fit.js
Normal file
8
resources/web/include/xterm/xterm-addon-fit.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
218
resources/web/include/xterm/xterm.css
Normal file
218
resources/web/include/xterm/xterm.css
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
8
resources/web/include/xterm/xterm.js
Normal file
8
resources/web/include/xterm/xterm.js
Normal file
File diff suppressed because one or more lines are too long
58
resources/web/js/fuzzy-search.js
Normal file
58
resources/web/js/fuzzy-search.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// Shared fuzzy-search core for the webview dialogs (Plugins dialog, Speed Dial popup).
|
||||
// why: both pages carried their own copy of this matcher and had already drifted; one source of truth.
|
||||
// note: keep this DOM-free and plain global-scope (no export/module) - it is loaded by <script src> in
|
||||
// each page AND by a node vm.runInContext in the speed-dial logic test. It MUST be loaded before
|
||||
// the page script that calls it.
|
||||
|
||||
// Fold per-character so matched offsets stay in ORIGINAL string coordinates (highlighting slices the
|
||||
// original text; a separately-folded string would desync offsets).
|
||||
function FoldChar(ch) {
|
||||
return ch.normalize("NFD").replace(/\p{Diacritic}/gu, ""); // accents always folded
|
||||
}
|
||||
|
||||
function Norm(ch, caseSensitive) {
|
||||
const folded = FoldChar(ch);
|
||||
return caseSensitive ? folded : folded.toLowerCase(); // case-sensitivity is the only toggle
|
||||
}
|
||||
|
||||
function EscapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// Fuzzy: ordered subsequence. Builds ranges in original coordinates, merging adjacent runs on the fly.
|
||||
function FuzzyRanges(text, query, caseSensitive) {
|
||||
const t = text || "";
|
||||
const needle = Array.from(query || "").map((ch) => Norm(ch, caseSensitive)).join("");
|
||||
if (!needle)
|
||||
return null;
|
||||
const ranges = [];
|
||||
let qi = 0;
|
||||
for (let i = 0; i < t.length && qi < needle.length; i++) {
|
||||
if (Norm(t[i], caseSensitive) === needle[qi]) {
|
||||
const last = ranges[ranges.length - 1];
|
||||
if (last && last[1] === i)
|
||||
last[1] = i + 1;
|
||||
else
|
||||
ranges.push([i, i + 1]);
|
||||
qi++;
|
||||
}
|
||||
}
|
||||
return qi === needle.length ? ranges : null;
|
||||
}
|
||||
|
||||
// Whole word: literal \b-bounded match that bypasses fuzzy. The per-char fold keeps the haystack
|
||||
// length-aligned to the original text, so regex indices map straight back to original offsets.
|
||||
// note: one-to-many folds (ligatures, eszett) shift offsets by a char; rare in names, cosmetic only.
|
||||
function WholeWordRanges(text, query, caseSensitive) {
|
||||
const haystack = Array.from(text || "").map((ch) => Norm(ch, caseSensitive)).join("");
|
||||
const needle = Array.from(query || "").map((ch) => Norm(ch, caseSensitive)).join("");
|
||||
if (!needle)
|
||||
return null;
|
||||
const re = new RegExp(`\\b${EscapeRegExp(needle)}\\b`, "g");
|
||||
const ranges = [];
|
||||
let match;
|
||||
// why: needle is non-empty, so \b-bounded matches are never zero-length - no empty-match guard needed.
|
||||
while ((match = re.exec(haystack)) !== null)
|
||||
ranges.push([match.index, match.index + match[0].length]);
|
||||
return ranges.length > 0 ? ranges : null;
|
||||
}
|
||||
37
resources/web/js/fuzzy-search.test.js
Normal file
37
resources/web/js/fuzzy-search.test.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Unit test for the shared fuzzy matcher. Run: node resources/web/js/fuzzy-search.test.js
|
||||
// why: fuzzy-search.js is plain global-scope (no exports, so a browser <script src> works) - load it into
|
||||
// a vm context the same way the page and the speed-dial test do, then assert against the globals.
|
||||
const vm = require("vm"), assert = require("assert"), fs = require("fs");
|
||||
const ctx = {};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(fs.readFileSync(__dirname + "/fuzzy-search.js", "utf8"), ctx);
|
||||
const { FoldChar, Norm, EscapeRegExp, FuzzyRanges, WholeWordRanges } = ctx;
|
||||
|
||||
// FoldChar / Norm: accents fold, case only folds when case-insensitive.
|
||||
assert.equal(FoldChar("é"), "e");
|
||||
assert.equal(Norm("É", false), "e");
|
||||
assert.equal(Norm("É", true), "E");
|
||||
|
||||
// FuzzyRanges: ordered subsequence, ranges in ORIGINAL coordinates, adjacent runs merged.
|
||||
assert.deepEqual(FuzzyRanges("Auto Arrange", "aa", false), [[0, 1], [5, 6]]);
|
||||
assert.deepEqual(FuzzyRanges("Measure", "eas", false), [[1, 4]]); // contiguous run merges to one range
|
||||
assert.equal(FuzzyRanges("Measure", "xyz", false), null); // no subsequence -> null
|
||||
assert.equal(FuzzyRanges("Measure", "", false), null); // empty query -> null
|
||||
|
||||
// Accent-insensitive matching, offsets stay in the original (accented) string.
|
||||
assert.deepEqual(FuzzyRanges("Café", "cafe", false), [[0, 4]]);
|
||||
|
||||
// Case sensitivity is the only toggle.
|
||||
assert.equal(FuzzyRanges("Measure", "MEAS", true), null); // case-sensitive: no match
|
||||
assert.deepEqual(FuzzyRanges("Measure", "Meas", true), [[0, 4]]); // case-sensitive: matches exact case
|
||||
|
||||
// WholeWordRanges: \b-bounded literal, bypasses fuzzy. Substring inside a word does NOT match.
|
||||
assert.deepEqual(WholeWordRanges("Auto Arrange", "arrange", false), [[5, 12]]);
|
||||
assert.equal(WholeWordRanges("Rearrange", "arrange", false), null); // not on a word boundary
|
||||
assert.deepEqual(WholeWordRanges("a.b", "a", false), [[0, 1]]); // '.' is a boundary
|
||||
|
||||
// EscapeRegExp: regex metachars in the query are treated literally by whole-word.
|
||||
assert.equal(EscapeRegExp("a.b*"), "a\\.b\\*");
|
||||
assert.deepEqual(WholeWordRanges("c++ tool", "c", false), [[0, 1]]); // '+' would be a regex error unescaped
|
||||
|
||||
console.log("ok");
|
||||
Reference in New Issue
Block a user