Skip to content

Instantly share code, notes, and snippets.

@engalar
Last active December 16, 2025 09:11
Show Gist options
  • Select an option

  • Save engalar/9878bffebbaf74f977d9c2e134a7242b to your computer and use it in GitHub Desktop.

Select an option

Save engalar/9878bffebbaf74f977d9c2e134a7242b to your computer and use it in GitHub Desktop.
Mendix DevTool: Inspector & Opener
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script src="assets/tailwindcss.js"></script>
</head>
<body class="bg-gray-100 font-sans">
<div id="app"></div>
<script src="assets/vendor-bundle.umd.js"></script>
<script src="assets/babel.min.js"></script>
<script type="text/babel">
// ===================================================================
// =================== FRAMEWORK CODE ========================
// ===================================================================
// This section contains the reusable, application-agnostic core.
// You should not need to modify this section to add new features.
// -------------------------------------------------------------------
// 等价于
// import * as React from 'react';
// import ReactDOM from 'react-dom/client';
// import * as redi from '@wendellhu/redi';
// import * as rediReact from '@wendellhu/redi/react-bindings';
// import * as rxjs from 'rxjs';
// import * as reactSpring from '@react-spring/web';
const { React, ReactDOM, redi, rediReact, rxjs, reactSpring } = globalThis.__tmp;
delete globalThis.__tmp;
// keep unused import for redi and rediReact
const {
Inject,
Injector,
LookUp,
Many,
Optional,
Quantity,
RediError,
Self,
SkipSelf,
WithNew,
createIdentifier,
forwardRef,
isAsyncDependencyItem,
isAsyncHook,
isClassDependencyItem,
isCtor,
isDisposable,
isFactoryDependencyItem,
isValueDependencyItem,
setDependencies,
} = redi;
const {
RediConsumer,
RediContext,
RediProvider,
WithDependency,
connectDependencies,
connectInjector,
useDependency,
useInjector,
useObservable,
useUpdateBinder,
} = rediReact;
const {
useState,
useEffect,
useRef,
useReducer,
Fragment,
useCallback,
createContext,
useContext,
} = React;
// 1. FRAMEWORK: CORE COMMUNICATION SERVICES
const IBackendEventService = createIdentifier("IBackendEventService");
class BackendEventService {
constructor() {
this.subscribers = new Map();
}
subscribe(eventType, callback) {
if (!this.subscribers.has(eventType)) {
this.subscribers.set(eventType, new Set());
}
this.subscribers.get(eventType).add(callback);
return () => {
const eventSubscribers = this.subscribers.get(eventType);
if (eventSubscribers) {
eventSubscribers.delete(callback);
}
};
}
publish(eventType, data) {
const eventSubscribers = this.subscribers.get(eventType);
if (eventSubscribers) {
eventSubscribers.forEach((callback) => callback(data));
}
}
}
setDependencies(BackendEventService, []);
const IMessageService = createIdentifier("IMessageService");
class BrowserMessageService {
constructor(eventService) {
this.eventService = eventService;
this.requestId = 0;
this.handleBackendResponse = this.handleBackendResponse.bind(this);
this.initializeListener();
}
async call(type, payload) {
const correlationId = `req-${this.requestId++}`;
const command = { type, payload, correlationId, timestamp: new Date().toISOString() };
window.parent.sendMessage("frontend:message", command);
return correlationId;
}
initializeListener() {
window.addEventListener("message", this.handleBackendResponse);
}
handleBackendResponse(event) {
if (event.data && event.data.type === "backendResponse") {
try {
const response = JSON.parse(event.data.data);
this.eventService.publish("backendResponse", response);
} catch (e) {
console.error("Fatal error parsing backend response:", e, event.data.data);
}
}
}
dispose() {
window.removeEventListener("message", this.handleBackendResponse);
}
}
setDependencies(BrowserMessageService, [IBackendEventService]);
// 2. FRAMEWORK: HIGH-LEVEL COMMAND ABSTRACTION
const ICommandService = createIdentifier("ICommandService");
class CommandService {
constructor(messageService, eventService) {
this.messageService = messageService;
this.pendingCommands = new Map();
this.RPC_TIMEOUT = 10000;
this.handleBackendResponse = this.handleBackendResponse.bind(this);
eventService.subscribe("backendResponse", this.handleBackendResponse);
}
execute = (type, payload) => {
return new Promise(async (resolve, reject) => {
const commandId = await this.messageService.call(type, payload);
const timeoutId = setTimeout(() => {
if (this.pendingCommands.has(commandId)) {
this.pendingCommands.delete(commandId);
reject(new Error(`Command '${type}' timed out.`));
}
}, this.RPC_TIMEOUT);
this.pendingCommands.set(commandId, { resolve, reject, timeoutId, type });
});
};
handleBackendResponse(response) {
const { correlationId, taskId } = response;
if (correlationId && this.pendingCommands.has(correlationId)) {
// Sync RPC or Async Task Initiation
const cmd = this.pendingCommands.get(correlationId);
if (response.status === "success") {
if (response.data && response.data.taskId) {
// It's an async task starting. Transfer promise to the new taskId.
const newTaskId = response.data.taskId;
this.pendingCommands.delete(correlationId); // remove old correlationId
this.pendingCommands.set(newTaskId, cmd); // re-map with taskId
} else {
// It's a normal sync RPC response.
clearTimeout(cmd.timeoutId);
this.pendingCommands.delete(correlationId);
cmd.resolve(response.data);
}
} else {
// Error on initiation
clearTimeout(cmd.timeoutId);
this.pendingCommands.delete(correlationId);
cmd.reject(new Error(response.message || "An unknown backend error occurred."));
}
} else if (taskId && this.pendingCommands.has(taskId)) {
// Async Task Completion
const cmd = this.pendingCommands.get(taskId);
clearTimeout(cmd.timeoutId); // In case we want a total task timeout
this.pendingCommands.delete(taskId);
if (response.status === "success") {
cmd.resolve(response.data);
} else {
cmd.reject(new Error(response.message || "Async task failed."));
}
}
}
}
setDependencies(CommandService, [IMessageService, IBackendEventService]);
// 3. FRAMEWORK: UNIFIED REACT HOOK FOR UI COMPONENTS
const useCommand = () => {
const commandService = useDependency(ICommandService);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
const execute = useCallback(async (type, payload) => {
setIsLoading(true);
setError(null);
setData(null);
try {
const result = await commandService.execute(type, payload);
setData(result);
return result;
} catch (err) {
setError(err.message);
throw err;
} finally {
setIsLoading(false);
}
}, [commandService]);
return { execute, isLoading, error, data };
};
// 4. FRAMEWORK: BOOTSTRAPPING AND VIEW MANAGEMENT
const IView = createIdentifier("IView");
class ViewManagementService {
constructor(views) { this.views = views; }
getViews() { return this.views; }
}
setDependencies(ViewManagementService, [[new Many(), IView]]);
const App = () => {
const viewManager = useDependency(ViewManagementService);
return (
<div className="p-4 max-w-5xl mx-auto bg-gray-50 shadow-lg rounded-lg flex flex-col h-[100vh]">
{viewManager.getViews().map((ViewComponent, index) => (
<Fragment key={index}><ViewComponent /></Fragment>
))}
</div>
);
};
const Alert = ({ message, type }) => {
const baseClasses = "text-sm px-4 py-2 rounded-md";
const typeClasses = {
success: "bg-green-100 text-green-800",
error: "bg-red-100 text-red-800",
};
return <div className={`${baseClasses} ${typeClasses[type]}`}>{message}</div>;
};
// ===================================================================
// =============== BUSINESS LOGIC CODE =======================
// ===================================================================
// This section contains your feature-specific components (Views).
// To add a new feature, add your new Component here and register
// it as an `IView` in the IOC Configuration section below.
// -------------------------------------------------------------------
const BridgeControlView = () => {
const [serverState, setServerState] = useState({ status: 'unknown', port: null });
const { execute, isLoading, error } = useCommand();
const fetchStatus = useCallback(async () => {
try {
const data = await execute('SERVER_GET_STATUS', {});
setServerState(data);
} catch (e) { setServerState({ status: 'error' }); }
}, [execute]);
useEffect(() => { fetchStatus(); }, [fetchStatus]);
const handleToggle = async (action) => {
try {
const cmd = action === 'start' ? 'SERVER_START' : 'SERVER_STOP';
const finalState = await execute(cmd, {});
setServerState(finalState);
} catch (e) { console.error(e); fetchStatus(); }
};
const isRunning = serverState.status === 'running';
return (
<div className="p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-4">Browser Inspector Bridge</h2>
<div className="bg-blue-50 border border-blue-200 rounded p-4 mb-6">
<p className="text-blue-800 text-sm">
This server listens for requests from the browser UserScript to open elements in Studio Pro.
<br/>
<strong>Endpoint:</strong> http://localhost:{serverState.port || 5000}/open_in_studio_pro
</p>
</div>
<div className="flex items-center justify-between bg-white p-4 rounded shadow-sm border border-gray-200 mb-6">
<div className="flex items-center space-x-3">
<div className={`w-4 h-4 rounded-full ${isRunning ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className="font-medium text-gray-700">
{isRunning ? `Running on port ${serverState.port}` : 'Stopped'}
</span>
</div>
<button onClick={fetchStatus} className="text-sm text-gray-500 hover:text-gray-700 underline">
Check Status
</button>
</div>
<button
onClick={() => handleToggle(isRunning ? 'stop' : 'start')}
disabled={isLoading}
className={`w-full py-3 rounded-lg font-semibold text-white transition-colors shadow-md
${isRunning
? 'bg-red-600 hover:bg-red-700'
: 'bg-blue-600 hover:bg-blue-700'}
${isLoading ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isLoading ? 'Processing...' : (isRunning ? 'Stop Server' : 'Start Server')}
</button>
{error && <p className="text-red-600 mt-4 text-center">{error}</p>}
</div>
);
};
// ===================================================================
// ============== IOC & APP INITIALIZATION ===================
// ===================================================================
const AppWithDependencies = connectDependencies(App, [
// --- Framework Registrations ---
[ViewManagementService],
[IBackendEventService, { useClass: BackendEventService }],
[IMessageService, { useClass: BrowserMessageService }],
[ICommandService, { useClass: CommandService }],
// --- Business Logic Registrations ---
// To add a new view, simply add another line here like the one below.
[IView, { useValue: BridgeControlView }],
]);
const root = ReactDOM.createRoot(document.getElementById("app"));
root.render(<AppWithDependencies />);
</script>
</body>
</html>
# region FRAMEWORK CODE
import json
import traceback
from typing import Any, Dict, Callable, Iterable
from abc import ABC, abstractmethod
# Mendix and .NET related imports
import clr
clr.AddReference("System.Text.Json")
clr.AddReference("Mendix.StudioPro.ExtensionsAPI")
import threading
import uuid
from dependency_injector import containers, providers
from System.Text.Json import JsonSerializer
# ShowDevTools()
# ===================================================================
# =================== FRAMEWORK CODE ========================
# ===================================================================
# This section contains the reusable, application-agnostic core.
# You should not need to modify this section to add new features.
# -------------------------------------------------------------------
# 1. FRAMEWORK: CORE ABSTRACTIONS AND INTERFACES
class MendixEnvironmentService:
"""Abstracts the Mendix host environment global variables."""
def __init__(self, app_context, window_service, post_message_func: Callable):
self.app = app_context
self.window_service = window_service
self.post_message = post_message_func
def get_project_path(self) -> str:
return self.app.Root.DirectoryPath
class ICommandHandler(ABC):
"""Contract for all command handlers."""
@property
@abstractmethod
def command_type(self) -> str:
"""The command type string this handler responds to."""
pass
@abstractmethod
def execute(self, payload: Dict) -> Any:
"""Executes the business logic for the command."""
pass
class IAsyncCommandHandler(ICommandHandler):
"""Extends ICommandHandler for tasks that should not block the main thread."""
@abstractmethod
def execute_async(self, payload: Dict, task_id: str):
"""The logic to be executed in a background thread."""
pass
# 2. FRAMEWORK: CENTRAL DISPATCHER
class AppController:
"""Routes incoming frontend commands to the appropriate ICommandHandler."""
def __init__(self, handlers: Iterable[ICommandHandler], mendix_env: MendixEnvironmentService):
self._mendix_env = mendix_env
self._command_handlers = {h.command_type: h for h in handlers}
self._mendix_env.post_message(
"backend:info", f"Controller initialized with handlers for: {list(self._command_handlers.keys())}")
def dispatch(self, request: Dict) -> Dict:
command_type = request.get("type")
payload = request.get("payload", {})
correlation_id = request.get("correlationId")
try:
handler = self._command_handlers.get(command_type)
if not handler:
raise ValueError(f"No handler found for command type: {command_type}")
# Generic logic to handle sync vs. async handlers
if isinstance(handler, IAsyncCommandHandler):
task_id = f"task-{uuid.uuid4()}"
thread = threading.Thread(
target=handler.execute_async,
args=(payload, task_id)
)
thread.daemon = True
thread.start()
# The immediate response includes the taskId for frontend tracking
result = handler.execute(payload)
result['taskId'] = task_id
return self._create_success_response(result, correlation_id)
else:
# Original synchronous execution path
result = handler.execute(payload)
return self._create_success_response(result, correlation_id)
except Exception as e:
error_message = f"Error executing command '{command_type}': {e}"
self._mendix_env.post_message(
"backend:info", f"{error_message}\n{traceback.format_exc()}")
return self._create_error_response(error_message, correlation_id)
def _create_success_response(self, data: Any, correlation_id: str) -> Dict:
return {"status": "success", "data": data, "correlationId": correlation_id}
def _create_error_response(self, message: str, correlation_id: str) -> Dict:
return {"status": "error", "message": message, "correlationId": correlation_id}
# endregion
# region BUSINESS LOGIC CODE
# ===================================================================
# =============== BUSINESS LOGIC CODE =======================
# ===================================================================
# This section contains your feature-specific command handlers.
# To add a new feature, create a new class implementing ICommandHandler
# or IAsyncCommandHandler, and register it in the Container below.
# -------------------------------------------------------------------
# [REPLACED] Imports & Bridge Service
import time
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.responses import JSONResponse, Response
from starlette.requests import Request
from typing import Optional
# ==========================================
# [HELPER] Mendix Document/Widget Finder
# ==========================================
class MendixFinder:
@staticmethod
def execute_open_logic(payload: Dict, app_root) -> bool:
target_str = payload.get('target', '')
if not target_str: return False
parts = target_str.split('.')
if len(parts) < 2: return False
module_name = parts[0]
unit_name = parts[1]
widget_name = parts[2] if len(parts) > 2 else None
# 1. Find Module
module = next((m for m in app_root.GetModules() if m.Name == module_name), None)
if not module: return False
# 2. Find Document (Recursive)
def find_doc(folder, name):
doc = next((d for d in folder.GetDocuments() if d.Name == name), None)
if doc: return doc
for sub in folder.GetFolders():
res = find_doc(sub, name)
if res: return res
return None
document = find_doc(module, unit_name)
if not document: return document
# 3. Find Widget (Recursive)
target_element = None
# 由于当前API不支持检索页面内部的IElement对象,跳过
# if widget_name:
# def find_wid(node, name):
# if getattr(node, "Name", None) == name: return node
# if hasattr(node, "GetProperties"):
# for p in node.GetProperties():
# if p.Value:
# if p.IsList:
# for item in p.Value:
# res = find_wid(item, name)
# if res: return res
# elif hasattr(p.Value, "GetProperties"):
# res = find_wid(p.Value, name)
# if res: return res
# return None
# found = find_wid(document, widget_name)
# if found: target_element = found
# 4. Open Editor
if 'dockingWindowService' in globals():
dockingWindowService.TryOpenEditor(document, target_element)
return True
return False
class BridgeServerService:
"""Manages the Uvicorn server for Browser-StudioPro communication."""
def __init__(self, mendix_env: MendixEnvironmentService):
self._mendix_env = mendix_env
self._server: Optional[uvicorn.Server] = None
self._server_thread: Optional[threading.Thread] = None
self.port = 5000 # Matches UserScript default
def is_running(self) -> bool:
return self._server is not None and not self._server.should_exit
def start(self):
if self.is_running(): return
async def handle_rpc(request: Request):
headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type"
}
if request.method == "OPTIONS":
return Response(status_code=204, headers=headers)
try:
payload = await request.json()
# [新增] 退出指令处理
if payload.get("action") == "shutdown":
if self._server:
self._server.should_exit = True
return JSONResponse({"status": "shutting_down"}, status_code=200, headers=headers)
success = MendixFinder.execute_open_logic(payload, self._mendix_env.app.Root)
return JSONResponse({"status": "success" if success else "failed"}, status_code=200, headers=headers)
except Exception as e:
return JSONResponse({"status": "error", "message": str(e)}, status_code=500, headers=headers)
app = Starlette(routes=[
Route("/open_in_studio_pro", handle_rpc, methods=["POST", "OPTIONS"])
])
config = uvicorn.Config(app, host="127.0.0.1", port=self.port, log_config=None)
self._server = uvicorn.Server(config)
self._server_thread = threading.Thread(target=self._server.run)
self._server_thread.start()
self._mendix_env.post_message("backend:info", f"Bridge Server started on port {self.port}")
def stop(self):
if not self.is_running(): return
self._server.should_exit = True
self._mendix_env.post_message("backend:info", "Bridge Server stopped manually.")
def get_status(self) -> Dict:
return {"status": "running" if self.is_running() else "stopped", "port": self.port}
# [REPLACED] Command Handlers
class StartServerCommandHandler(IAsyncCommandHandler):
command_type = "SERVER_START" # Modified type
def __init__(self, service: BridgeServerService, mendix_env: MendixEnvironmentService):
self._service = service
self._mendix_env = mendix_env
def execute(self, payload: Dict) -> Dict:
return {"status": "accepted"}
def execute_async(self, payload: Dict, task_id: str):
try:
self._service.start()
self._mendix_env.post_message("backend:response", json.dumps({
"taskId": task_id, "status": "success", "data": self._service.get_status()
}))
except Exception as e:
self._mendix_env.post_message("backend:info", str(e))
class StopServerCommandHandler(IAsyncCommandHandler):
command_type = "SERVER_STOP" # Modified type
def __init__(self, service: BridgeServerService, mendix_env: MendixEnvironmentService):
self._service = service
self._mendix_env = mendix_env
def execute(self, payload: Dict) -> Dict:
return {"status": "accepted"}
def execute_async(self, payload: Dict, task_id: str):
self._service.stop()
time.sleep(0.5)
self._mendix_env.post_message("backend:response", json.dumps({
"taskId": task_id, "status": "success", "data": self._service.get_status()
}))
class GetStatusCommandHandler(ICommandHandler):
command_type = "SERVER_GET_STATUS"
def __init__(self, service: BridgeServerService):
self._service = service
def execute(self, payload: Dict) -> Dict:
return self._service.get_status()
# endregion
# region IOC & APP INITIALIZATION
# ===================================================================
# ============== IOC & APP INITIALIZATION ===================
# ===================================================================
class Container(containers.DeclarativeContainer):
"""The application's Inversion of Control (IoC) container."""
config = providers.Configuration()
# --- Framework Registrations ---
mendix_env = providers.Singleton(
MendixEnvironmentService,
app_context=config.app_context,
window_service=config.window_service,
post_message_func=config.post_message_func,
)
# [REPLACED] IoC Container
# --- Business Logic Registrations ---
bridge_service = providers.Singleton(BridgeServerService, mendix_env=mendix_env)
command_handlers = providers.List(
providers.Singleton(StartServerCommandHandler, service=bridge_service, mendix_env=mendix_env),
providers.Singleton(StopServerCommandHandler, service=bridge_service, mendix_env=mendix_env),
providers.Singleton(GetStatusCommandHandler, service=bridge_service),
)
# --- Framework Controller (depends on handlers) ---
app_controller = providers.Singleton(
AppController,
handlers=command_handlers,
mendix_env=mendix_env,
)
# --- Application Entrypoint and Wiring ---
def onMessage(e: Any):
"""Entry point called by Mendix Studio Pro for messages from the UI."""
if e.Message != "frontend:message":
return
controller = container.app_controller()
request_object = None
try:
request_string = JsonSerializer.Serialize(e.Data)
request_object = json.loads(request_string)
response = controller.dispatch(request_object)
PostMessage("backend:response", json.dumps(response))
except Exception as ex:
PostMessage("backend:info", f"Fatal error in onMessage: {ex}\n{traceback.format_exc()}")
correlation_id = request_object.get("correlationId", "unknown") if request_object else "unknown"
fatal_error_response = {
"status": "error",
"message": f"A fatal backend error occurred: {ex}",
"correlationId": correlation_id
}
PostMessage("backend:response", json.dumps(fatal_error_response))
def initialize_app():
"""Initializes the IoC container with the Mendix environment services."""
container = Container()
container.config.from_dict({
"app_context": currentApp,
"window_service": dockingWindowService,
"post_message_func": PostMessage
})
return container
# --- Application Start ---
import urllib.request
import urllib.error
def ensure_previous_instance_killed(port=5000):
"""
尝试连接本地端口并发送关闭指令。
如果端口通畅(旧服务在运行),旧服务收到指令后会退出。
如果连接被拒绝(无服务运行),则跳过。
"""
url = f"http://127.0.0.1:{port}/open_in_studio_pro"
shutdown_payload = json.dumps({"action": "shutdown"}).encode('utf-8')
try:
# 尝试发送关闭指令
req = urllib.request.Request(url, data=shutdown_payload, headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=1) as response:
PostMessage("backend:info", f"Signal sent to existing instance on port {port}. Waiting for shutdown...")
# 给予旧线程一点时间释放端口
time.sleep(1.5)
except urllib.error.URLError:
# 连接失败说明没有服务在运行,直接继续
pass
except Exception as e:
PostMessage("backend:info", f"Warning during cleanup check: {str(e)}")
# 1. 先清理环境
PostMessage("backend:clear", '')
# 2. 尝试关闭之前的实例(如果有)
ensure_previous_instance_killed(5000)
# 3. 初始化并启动新应用
container = initialize_app()
PostMessage("backend:info", "Backend Python script initialized successfully.")
# endregion
{
"name": "MendixDevTool",
"author": null,
"email": null,
"ui": "index.html",
"plugin": "main.py"
}
// ==UserScript==
// @name Mendix DevTool: Inspector & Opener
// @namespace http://mendix.dev/
// @version 2.0
// @description Visual inspector for Mendix to open Layouts, Pages, and Widgets in Studio Pro via Python RPC.
// @author Dev
// @match http://localhost:8082/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @run-at document-end
// ==/UserScript==
//https://gist.github.com/engalar/9878bffebbaf74f977d9c2e134a7242b
(function () {
'use strict';
//Configuration
const RPC_URL = "http://localhost:5000/open_in_studio_pro";
// --- State Management ---
let isInspectMode = false;
let lockedContext = null; // Stores the context when user clicks to freeze selection
// --- CSS Styles ---
const styles = `
/* Control Button */
#mx-dev-toggle {
position: fixed; bottom: 20px; right: 20px; z-index: 10000;
width: 50px; height: 50px; border-radius: 50%;
background: #444; color: white; border: 2px solid #fff;
cursor: pointer; box-shadow: 0 4px 10px rgba(0,0,0,0.3);
display: flex; align-items: center; justify-content: center; font-size: 24px;
transition: background 0.3s;
}
#mx-dev-toggle.active { background: #264AE5; border-color: #61DAFB; }
/* Information Panel */
#mx-dev-panel {
position: fixed; top: 20px; right: 20px; z-index: 9999;
width: 350px; background: rgba(30, 30, 30, 0.95);
color: #eee; padding: 10px; border-radius: 8px;
font-family: Consolas, monospace; font-size: 12px;
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
display: none; pointer-events: auto;
border: 1px solid #555;
transition: top 0.2s, bottom 0.2s; /* 添加动画 */
}
/* 当鼠标在右上角时,面板移到右下角 (Toggle按钮上方) */
#mx-dev-panel.moved { top: auto; bottom: 80px; }
/* Header & Close Button */
.mx-panel-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #555; padding-bottom: 5px; margin-bottom: 5px; }
.mx-panel-header h3 { margin: 0; font-size: 14px; color: #fff; }
.mx-close-btn { cursor: pointer; font-size: 16px; color: #aaa; padding: 0 5px; }
.mx-close-btn:hover { color: #fff; }
/* Tree View Styles */
.mx-tree-ul { list-style: none; padding-left: 0; margin: 0; }
.mx-tree-li { margin-left: 15px; border-left: 1px solid #555; }
.mx-tree-item {
padding: 4px 6px; display: flex; align-items: center;
border-radius: 3px; margin-bottom: 2px;
}
.mx-tree-item:hover { background: #444; }
.mx-tree-item.active { background: #264AE5; color: white; }
.mx-tree-content { flex: 1; overflow: hidden; display: flex; align-items: center; cursor: pointer; }
.mx-tree-type { font-size: 10px; opacity: 0.8; margin-right: 6px; min-width: 35px; text-transform: uppercase; font-weight: bold;}
.mx-tree-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 5px;}
/* Copy Button */
.mx-copy-btn {
background: transparent; border: 1px solid #666; color: #aaa;
font-size: 10px; cursor: pointer; border-radius: 3px; padding: 0 4px;
margin-left: auto; display: none; /* Show on hover */
}
.mx-tree-item:hover .mx-copy-btn { display: block; }
.mx-copy-btn:hover { background: #fff; color: #000; border-color: #fff; }
/* Highlight Overlays */
.mx-dev-overlay {
position: fixed; pointer-events: none; z-index: 9990;
background: rgba(0,0,0,0.05); transition: all 0.1s ease-out;
display: none; border-width: 2px; border-style: solid;
}
/* Info Label on Overlay */
.mx-overlay-info {
position: absolute; top: -22px; left: -2px;
background: rgba(0,0,0,0.8); color: white;
padding: 2px 6px; font-size: 11px; border-radius: 3px 3px 0 0;
white-space: nowrap; font-family: sans-serif;
pointer-events: none;
}
.color-layout { border-color: #ff9800; background: rgba(255, 152, 0, 0.1); }
.color-page { border-color: #4caf50; background: rgba(76, 175, 80, 0.1); }
.color-widget { border-color: #2196f3; background: rgba(33, 150, 243, 0.1); }
`;
GM_addStyle(styles);
function createUI() {
// Toggle Button
const toggle = document.createElement('div');
toggle.id = 'mx-dev-toggle';
toggle.innerHTML = '🔍';
// 修改:点击按钮在 开启/冻结 之间切换,或者重新开启
toggle.onclick = () => toggleInspectMode();
document.body.appendChild(toggle);
// Tree Panel
const panel = document.createElement('div');
panel.id = 'mx-dev-panel';
panel.innerHTML = `
<div class="mx-panel-header">
<h3>Page Structure</h3>
<span class="mx-close-btn" title="Close Panel">✕</span>
</div>
<div id="mx-dev-tree-container" style="max-height: 400px; overflow-y: auto;"></div>
<div style="margin-top:5px; font-size:10px; color:#888; border-top:1px solid #444; padding-top:5px;">
<span style="color:#4caf50">●</span> Page <span style="color:#2196f3">●</span> Widget<br>
Left Click: Freeze & Edit | Copy btn: Copy ShortName
</div>
`;
document.body.appendChild(panel);
// 修改:点击关闭图标 -> 完全退出
panel.querySelector('.mx-close-btn').onclick = () => {
toggleInspectMode('close');
};
// Overlays (保持不变)
const div = document.createElement('div');
div.id = `mx-overlay-focus`;
div.className = `mx-dev-overlay color-widget`;
div.innerHTML = `<div class="mx-overlay-info"></div>`;
document.body.appendChild(div);
}
function updateUI(treeData) {
const container = document.getElementById('mx-dev-tree-container');
if (!container) return;
container.innerHTML = '';
let currentContainer = document.createElement('ul');
currentContainer.className = 'mx-tree-ul';
container.appendChild(currentContainer);
treeData.forEach(item => {
const li = document.createElement('li');
li.className = 'mx-tree-li';
const div = document.createElement('div');
div.className = 'mx-tree-item';
if (item === treeData[treeData.length - 1]) div.classList.add('active');
// Copy Action
const copyShortName = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(item.rpcTarget.split('.')[1]);
const btn = e.target;
btn.innerText = "OK";
setTimeout(() => btn.innerText = "Copy", 1000);
};
// HTML Structure: Type + Name + Copy Button
// item.rpcTarget is the Full Qualified Name (Module.Page.Widget)
// item.displayName is the Short Name (Widget)
div.innerHTML = `
<div class="mx-tree-content" title="${item.rpcTarget || item.fullId}">
<span class="mx-tree-type" style="color:${getTypeColor(item.type)}">${item.type}</span>
<span class="mx-tree-name">${item.rpcTarget}</span>
</div>
<button class="mx-copy-btn">Copy</button>
`;
// Events
const contentDiv = div.querySelector('.mx-tree-content');
contentDiv.onclick = (e) => { e.stopPropagation(); sendRpc(item.rpcTarget, item.type); highlightNode(item.node, item); };
contentDiv.onmouseenter = () => highlightNode(item.node, item);
// Bind Copy
div.querySelector('.mx-copy-btn').onclick = copyShortName;
li.appendChild(div);
const nextUl = document.createElement('ul');
nextUl.className = 'mx-tree-ul';
li.appendChild(nextUl);
currentContainer.appendChild(li);
currentContainer = nextUl;
});
if (treeData.length > 0) {
const lastItem = treeData[treeData.length - 1];
highlightNode(lastItem.node, lastItem);
}
}
function getTypeColor(type) {
if (type === 'layout') return '#ff9800';
if (type === 'page') return '#4caf50';
return '#2196f3';
}
function highlightNode(node, itemData) {
const overlay = document.getElementById(`mx-overlay-focus`);
const infoLabel = overlay.querySelector('.mx-overlay-info');
if (node && node.nodeType === 1) {
const rect = node.getBoundingClientRect();
if (rect.width > 0) {
overlay.style.display = 'block';
overlay.style.top = rect.top + 'px';
overlay.style.left = rect.left + 'px';
overlay.style.width = rect.width + 'px';
overlay.style.height = rect.height + 'px';
// Update styling based on type
overlay.className = `mx-dev-overlay color-${itemData ? itemData.type : 'widget'}`;
// Update Info Label
if (itemData) {
infoLabel.innerHTML = `<span style="opacity:0.7">${itemData.type}:</span> <strong>${itemData.displayName}</strong>`;
// Adjust label position if element is at top of screen
if (rect.top < 25) {
infoLabel.style.top = '100%'; // Move label to bottom
infoLabel.style.bottom = 'auto';
infoLabel.style.borderRadius = '0 0 3px 3px';
} else {
infoLabel.style.top = '-22px'; // Standard top
infoLabel.style.bottom = 'auto';
infoLabel.style.borderRadius = '3px 3px 0 0';
}
}
return;
}
}
overlay.style.display = 'none';
}
// --- Core Logic: Traversal & Tree Building ---
function analyzeContext(targetNode) {
let current = targetNode;
const path = [];
let foundPagePrefix = null;
// 1. 向上遍历收集节点
while (current && current !== document.body) {
// A. 获取样式名 (作为备选名称)
let mxName = null;
if (current.classList) {
current.classList.forEach(cls => {
if (cls.startsWith('mx-name-')) mxName = cls.substring(8);
});
}
// B. 获取 Mendix ID
let mendixId = current.getAttribute ? current.getAttribute('data-mendix-id') : null;
if (mxName || mendixId) {
let type = 'widget';
let shortName = mxName; // 短名称 (不含路径)
// 逻辑修正:严格解析 ID
if (mendixId) {
// 去除 l. 或 p. 前缀,便于统一处理
const cleanId = mendixId.replace(/^[lp]\./, '');
const parts = cleanId.split('.');
// [关键修改] 始终只取最后一段作为组件名
// 无论 ID 是 "Mod.Page.Btn" 还是 "Mod.Page.Grid.Btn",都只取 "Btn"
shortName = parts[parts.length - 1];
// 确定类型
if (mendixId.startsWith('l.')) type = 'layout';
else if (mendixId.startsWith('p.')) {
type = 'page';
// 记录 Page 前缀 (Module.PageName)
if (parts.length >= 2) foundPagePrefix = `${parts[0]}.${parts[1]}`;
} else {
// 如果是普通 Widget ID,尝试提取 Page 前缀 (备用)
if (!foundPagePrefix && parts.length >= 2) {
foundPagePrefix = `${parts[0]}.${parts[1]}`;
}
}
}
// 如果只有 mx-name,没有 mendixId,shortName 已经是正确的了
path.push({
type: type,
displayName: shortName, // 树上显示 "badge1"
fullId: mendixId,
mxName: mxName,
node: current
});
}
current = current.parentNode;
}
const tree = path.reverse();
// 2. 生成符合 Studio Pro 规范的 RPC 参数
tree.forEach(item => {
if (item.type === 'widget') {
if (foundPagePrefix) {
// [关键修改] 强制格式:Module.Page.WidgetShortName
// 忽略中间的容器路径,直接拼接 Page 前缀和组件短名称
item.rpcTarget = `${foundPagePrefix}.${item.displayName}`;
} else {
// 无法找到 Page 前缀时的兜底 (如 Popup 内部有时很难找)
item.rpcTarget = item.fullId || item.displayName;
}
} else if (item.type === 'page') {
// Page 直接用前缀 (Module.Page)
item.rpcTarget = foundPagePrefix || parseMendixName(item.fullId);
} else {
// Layout 等其他情况
item.rpcTarget = item.fullId ? parseMendixName(item.fullId) : item.displayName;
}
});
return tree;
}
function parseMendixName(id) {
const parts = id.split('.');
// 移除 l. 或 p. 前缀
if (parts[0] === 'l' || parts[0] === 'p') {
parts.shift();
}
// 返回 Qualified Name (Module.Name.SubName...)
return parts.join('.');
}
// --- Interaction Logic ---
function handleMouseMove(e) {
if (lockedContext) return; // 如果已锁定,不再随鼠标更新
// --- 新增逻辑:自动避让 ---
const panel = document.getElementById('mx-dev-panel');
if (panel && panel.style.display !== 'none') {
const winWidth = window.innerWidth;
// 如果鼠标进入右上角区域 (面板宽度约350px + 边距,高度预估300px内)
if (e.clientX > (winWidth - 400) && e.clientY < 400) {
panel.classList.add('moved');
} else {
panel.classList.remove('moved');
}
}
// -----------------------
const context = analyzeContext(e.target);
updateUI(context);
}
function handleClick(e) {
// 允许操作 DevTool 面板本身
if (e.target.closest('#mx-dev-panel') || e.target.closest('#mx-dev-toggle')) return;
if (!isInspectMode) return;
e.preventDefault();
e.stopPropagation();
// 核心修改:点击后进入"冻结"状态 (停止监听,但保留面板和高亮)
toggleInspectMode(); // 不传参即为 Freeze
}
// 修改 toggleInspectMode 支持三种状态
function toggleInspectMode(action) {
const btn = document.getElementById('mx-dev-toggle');
const panel = document.getElementById('mx-dev-panel');
// 如果传入 'start' 或者 (未指定且当前未在审查模式),则开启
if (action === 'start' || (action === undefined && !isInspectMode)) {
isInspectMode = true;
btn.classList.add('active');
panel.style.display = 'block';
document.body.style.cursor = 'crosshair';
// 重新开始时清除之前的树(可选,看你喜好)
// document.getElementById('mx-dev-tree-container').innerHTML = '';
hideOverlays();
startListening();
} else {
// 停止审查 (冻结状态)
isInspectMode = false;
btn.classList.remove('active');
document.body.style.cursor = 'default';
stopListening();
// 只有显式传入 'close' 时才隐藏面板
if (action === 'close') {
panel.style.display = 'none';
hideOverlays();
}
}
}
function handleKeyDown(e) {
if (e.key === "Escape") {
toggleInspectMode('close'); // ESC 视为完全退出
}
}
function hideOverlays() {
const overlay = document.getElementById(`mx-overlay-focus`);
if (overlay) overlay.style.display = 'none';
document.querySelectorAll('.mx-dev-overlay').forEach(el => el.style.display = 'none');
}
function startListening() {
document.addEventListener('mousemove', handleMouseMove, true);
document.addEventListener('click', handleClick, true);
document.addEventListener('keydown', handleKeyDown, true); // [新增]
}
function stopListening() {
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
document.removeEventListener('keydown', handleKeyDown, true); // [新增]
}
function updatePanelRow(type, data) {
const row = document.getElementById(`row-${type}`);
const valSpan = row.querySelector('.mx-dev-val');
const btn = row.querySelector('button');
if (data) {
valSpan.innerText = data.name;
valSpan.style.color = '#fff';
btn.disabled = false;
btn.style.opacity = 1;
} else {
valSpan.innerText = 'Not detected';
valSpan.style.color = '#666';
btn.disabled = true;
btn.style.opacity = 0.5;
}
}
function updateOverlayPosition(type, node) {
const overlay = document.getElementById(`mx-overlay-${type}`);
if (node && node.nodeType === 1) { // Ensure it's an element
const rect = node.getBoundingClientRect();
// 防止 overlay 也是 0x0
if (rect.width > 0 && rect.height > 0) {
overlay.style.display = 'block';
overlay.style.top = rect.top + 'px';
overlay.style.left = rect.left + 'px';
overlay.style.width = rect.width + 'px';
overlay.style.height = rect.height + 'px';
return;
}
}
overlay.style.display = 'none';
}
// --- RPC Communication ---
function sendRpc(elementName, type) {
if (!elementName) return;
console.log(`[MendixRPC] Opening ${type}: ${elementName}`);
GM_xmlhttpRequest({
method: "POST",
url: RPC_URL,
headers: { "Content-Type": "application/json" },
data: JSON.stringify({
target: elementName,
type: type // 'layout', 'page', or 'widget'
}),
onload: function (res) {
if (res.status === 200) {
const btn = document.querySelector(`button.mx-copy-btn`);
const originalText = btn.innerText;
btn.innerText = "Opened!";
btn.style.background = "#4caf50";
setTimeout(() => {
btn.innerText = originalText;
btn.style.background = "";
}, 1000);
}
}
});
}
// Initialize
setTimeout(createUI, 2000); // Wait for Mendix to hydrate
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment