UX: WebView native-feel + Context-Menu Redesign + Startup-Fix
- Disable text selection (CSS user-select:none) + block browser context menu (contextmenu preventDefault) in all panels - ContextMenu: pill items, accent hover, entrance animation, optional title header — controlled via single ContextMenu.css - EbenenManager + GeschossManager: show layer name as menu title - startup.py: skip splash on Cmd+N when plugin already loaded - startup.py: hook NewDocument so display-modes apply to new docs
This commit is contained in:
+21
-2
@@ -267,10 +267,12 @@ def _build_inline_template():
|
||||
html = f.read().decode("utf-8")
|
||||
|
||||
placeholder_script = '<script>' + _MODE_SCRIPT_PLACEHOLDER + '</script>'
|
||||
_no_select = '<style>*{-webkit-user-select:none!important;user-select:none!important;}</style>'
|
||||
_no_ctx = '<script>document.addEventListener("contextmenu",function(e){e.preventDefault();},true);</script>'
|
||||
if "</head>" in html:
|
||||
html = html.replace("</head>", placeholder_script + "</head>")
|
||||
html = html.replace("</head>", placeholder_script + _no_select + _no_ctx + "</head>")
|
||||
else:
|
||||
html = placeholder_script + html
|
||||
html = placeholder_script + _no_select + _no_ctx + html
|
||||
|
||||
def inline_css(m):
|
||||
p = os.path.join(dist_dir, m.group(1).lstrip("./").replace("/", os.sep))
|
||||
@@ -353,6 +355,15 @@ def attach_webview(panel, bridge, mode):
|
||||
wv.ExecuteScript("window.RHINO_MODE=true;")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
wv.ExecuteScript(
|
||||
"var _ds=document.createElement('style');"
|
||||
"_ds.textContent='*{-webkit-user-select:none!important;user-select:none!important;}';"
|
||||
"document.head.appendChild(_ds);"
|
||||
"document.addEventListener('contextmenu',function(e){e.preventDefault();},true);"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_idle(s, e):
|
||||
Rhino.RhinoApp.Idle -= on_idle
|
||||
@@ -440,6 +451,14 @@ def open_satellite_window(mode, params=None, title=None, size=(420, 560),
|
||||
def on_loaded(s, e):
|
||||
try: wv.ExecuteScript("window.RHINO_MODE=true;")
|
||||
except Exception: pass
|
||||
try:
|
||||
wv.ExecuteScript(
|
||||
"var _ds=document.createElement('style');"
|
||||
"_ds.textContent='*{-webkit-user-select:none!important;user-select:none!important;}';"
|
||||
"document.head.appendChild(_ds);"
|
||||
"document.addEventListener('contextmenu',function(e){e.preventDefault();},true);"
|
||||
)
|
||||
except Exception: pass
|
||||
|
||||
wv.DocumentTitleChanged += on_title_
|
||||
wv.DocumentLoaded += on_loaded
|
||||
|
||||
@@ -23,6 +23,8 @@ if _HERE not in sys.path:
|
||||
# Nutzer waehrend Python-Imports + Panel-Registrierung nicht in eine schwarze
|
||||
# Rhino-Oberflaeche schaut. Skipt automatisch wenn Launcher seinen eigenen
|
||||
# Splash zeigt (Owner-Marker-Check).
|
||||
# Skipt auch wenn Plugin bereits in dieser Session geladen ist (z.B. Cmd+N).
|
||||
if not sc.sticky.get("_dossier_startup_scheduled"):
|
||||
try:
|
||||
import _startup_splash as _splash_first
|
||||
_splash_first.show()
|
||||
@@ -299,6 +301,10 @@ def _load_all(sender, e):
|
||||
Rhino.RhinoDoc.EndOpenDocument += _on_doc_opened
|
||||
except Exception as ex:
|
||||
print("[STARTUP] EndOpenDocument-Hook:", ex)
|
||||
try:
|
||||
Rhino.RhinoDoc.NewDocument += _on_doc_opened
|
||||
except Exception as ex:
|
||||
print("[STARTUP] NewDocument-Hook:", ex)
|
||||
# Projekt-Config bestimmt, welche Module geladen werden. Ohne Config
|
||||
# (kein Launcher benutzt, oder Datei nicht da) laedt der Host alles.
|
||||
config = _read_project_config()
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
.ctx-menu {
|
||||
position: fixed;
|
||||
padding: 5px;
|
||||
min-width: 200px;
|
||||
background: var(--bg-dialog);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 13px;
|
||||
box-shadow:
|
||||
0 2px 6px rgba(0,0,0,0.10),
|
||||
0 8px 24px rgba(0,0,0,0.18),
|
||||
0 0 0 0.5px var(--border);
|
||||
z-index: 300;
|
||||
animation: ctx-in 100ms cubic-bezier(0.2, 0, 0.13, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ctx-menu {
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0,0,0,0.4),
|
||||
0 12px 32px rgba(0,0,0,0.55),
|
||||
0 0 0 0.5px var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ctx-in {
|
||||
from { opacity: 0; transform: scale(0.94) translateY(-5px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.ctx-title {
|
||||
padding: 6px 12px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ctx-divider {
|
||||
height: 1px;
|
||||
background: var(--border-light);
|
||||
margin: 4px 2px;
|
||||
}
|
||||
|
||||
.ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 5px 12px;
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.01em;
|
||||
font-family: var(--font);
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease, color 100ms ease;
|
||||
}
|
||||
|
||||
.ctx-item:hover:not(:disabled) {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ctx-item:hover:not(:disabled) .ctx-item__icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ctx-item:active:not(:disabled) {
|
||||
background: var(--active-dim);
|
||||
}
|
||||
|
||||
.ctx-item:disabled {
|
||||
opacity: 0.38;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctx-item--danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.ctx-item--danger .ctx-item__icon {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.ctx-item--danger:hover:not(:disabled) {
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||
}
|
||||
|
||||
.ctx-item__icon {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
transition: color 100ms ease;
|
||||
}
|
||||
|
||||
.ctx-item__icon-gap {
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ctx-item__label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ctx-item__shortcut {
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-item);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 999px;
|
||||
padding: 1px 6px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
// Copyright (C) 2026 Karim Gabriele Varano
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Icon from './Icon'
|
||||
import './ContextMenu.css'
|
||||
|
||||
export default function ContextMenu({ x, y, items, onClose }) {
|
||||
export default function ContextMenu({ x, y, items, onClose, title }) {
|
||||
const ref = useRef(null)
|
||||
const [pos, setPos] = useState({ left: x, top: y })
|
||||
|
||||
// Falls Menue rechts/unten ueberlaufen wuerde, links/oben verschieben
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
@@ -41,47 +41,26 @@ export default function ContextMenu({ x, y, items, onClose }) {
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: pos.top, left: pos.left,
|
||||
background: 'var(--bg-dialog)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)',
|
||||
boxShadow: 'var(--shadow-3)',
|
||||
padding: '4px 0',
|
||||
minWidth: 180,
|
||||
zIndex: 300,
|
||||
}}
|
||||
>
|
||||
<div ref={ref} className="ctx-menu" style={{ top: pos.top, left: pos.left }}>
|
||||
{title && <>
|
||||
<div className="ctx-title">{title}</div>
|
||||
<div className="ctx-divider" />
|
||||
</>}
|
||||
{items.map((it, i) => (
|
||||
it.divider ? (
|
||||
<div key={i} style={{ height: 1, background: 'var(--border-light)', margin: '4px 0' }} />
|
||||
<div key={i} className="ctx-divider" />
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
disabled={it.disabled}
|
||||
className={`ctx-item${it.danger ? ' ctx-item--danger' : ''}`}
|
||||
onClick={() => { if (!it.disabled) { it.onClick(); onClose() } }}
|
||||
onMouseEnter={(ev) => { if (!it.disabled) ev.currentTarget.style.background = 'var(--overlay-hover)' }}
|
||||
onMouseLeave={(ev) => { ev.currentTarget.style.background = 'transparent' }}
|
||||
style={{
|
||||
width: '100%', textAlign: 'left',
|
||||
padding: '6px 14px', fontSize: 11,
|
||||
color: it.disabled ? 'var(--text-muted)'
|
||||
: it.danger ? 'var(--danger)'
|
||||
: 'var(--text-primary)',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
borderRadius: 0,
|
||||
cursor: it.disabled ? 'default' : 'pointer',
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
{it.icon
|
||||
? <Icon name={it.icon} size={14} style={{ color: it.disabled ? 'var(--text-muted)' : it.danger ? 'var(--danger)' : 'var(--text-secondary)' }} />
|
||||
: <span style={{ width: 14 }} />}
|
||||
<span style={{ flex: 1 }}>{it.label}</span>
|
||||
{it.shortcut && <span style={{ fontSize: 9, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>{it.shortcut}</span>}
|
||||
? <span className="ctx-item__icon"><Icon name={it.icon} size={14} /></span>
|
||||
: <span className="ctx-item__icon-gap" />}
|
||||
<span className="ctx-item__label">{it.label}</span>
|
||||
{it.shortcut && <span className="ctx-item__shortcut">{it.shortcut}</span>}
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
|
||||
@@ -527,7 +527,8 @@ export default function EbenenManager({
|
||||
const openContextMenu = (ev, code) => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setCtxMenu({ x: ev.clientX, y: ev.clientY, code })
|
||||
const label = _findInTree(ebenen, code)?.name || code
|
||||
setCtxMenu({ x: ev.clientX, y: ev.clientY, code, label })
|
||||
}
|
||||
|
||||
const ctxItems = (code) => [
|
||||
@@ -707,6 +708,7 @@ export default function EbenenManager({
|
||||
{ctxMenu && (
|
||||
<ContextMenu
|
||||
x={ctxMenu.x} y={ctxMenu.y}
|
||||
title={ctxMenu.label}
|
||||
items={ctxItems(ctxMenu.code)}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
|
||||
@@ -435,7 +435,8 @@ export default function GeschossManager({
|
||||
|
||||
const openContextMenu = (ev, id) => {
|
||||
ev.preventDefault(); ev.stopPropagation()
|
||||
setCtxMenu({ x: ev.clientX, y: ev.clientY, id })
|
||||
const label = zeichnungsebenen.find(x => x.id === id)?.name || id
|
||||
setCtxMenu({ x: ev.clientX, y: ev.clientY, id, label })
|
||||
}
|
||||
|
||||
const ctxItems = (id) => {
|
||||
@@ -557,6 +558,7 @@ export default function GeschossManager({
|
||||
{ctxMenu && (
|
||||
<ContextMenu
|
||||
x={ctxMenu.x} y={ctxMenu.y}
|
||||
title={ctxMenu.label}
|
||||
items={ctxItems(ctxMenu.id)}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
|
||||
|
||||
* {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
|
||||
@@ -62,6 +62,8 @@ const RootApp = mode === 'gestaltung' ? GestaltungApp
|
||||
: mode === 'elemente_properties' ? ElementePropertiesApp
|
||||
: App
|
||||
|
||||
document.addEventListener('contextmenu', e => e.preventDefault(), true)
|
||||
|
||||
window.onerror = function (msg, src, line, col, err) {
|
||||
document.body.style.cssText = 'background:#1c1c1e;margin:0;padding:12px'
|
||||
document.body.innerHTML =
|
||||
|
||||
Reference in New Issue
Block a user