Chandra
view release on metacpan or search on metacpan
xs/contextmenu.xs view on Meta::CPAN
MODULE = Chandra PACKAGE = Chandra::ContextMenu
PROTOTYPES: DISABLE
SV *
new(class, ...)
const char *class
CODE:
{
HV *self_hv = newHV();
AV *items_av = newAV();
I32 i;
for (i = 1; i + 1 < items; i += 2) {
const char *key = SvPV_nolen(ST(i));
SV *val = ST(i + 1);
if (strEQ(key, "app")) {
(void)hv_stores(self_hv, "app", newSVsv(val));
} else if (strEQ(key, "items")) {
if (SvROK(val) && SvTYPE(SvRV(val)) == SVt_PVAV) {
SvREFCNT_dec(items_av);
items_av = (AV *)SvRV(val);
SvREFCNT_inc((SV *)items_av);
}
} else if (strEQ(key, "mode")) {
(void)hv_stores(self_hv, "mode", newSVsv(val));
}
}
(void)hv_stores(self_hv, "_items", newRV_noinc((SV *)items_av));
(void)hv_stores(self_hv, "_attachments", newRV_noinc((SV *)newHV()));
(void)hv_stores(self_hv, "_actions", newRV_noinc((SV *)newHV()));
(void)hv_stores(self_hv, "_next_id", newSViv(1));
(void)hv_stores(self_hv, "_enabled", newSViv(1));
(void)hv_stores(self_hv, "_injected", newSViv(0));
(void)hv_stores(self_hv, "_dispatch_bound", newSViv(0));
(void)hv_stores(self_hv, "_global", newSViv(0));
/* Set default mode */
{
SV **mode_svp = hv_fetchs(self_hv, "mode", 0);
if (!mode_svp || !SvOK(*mode_svp))
(void)hv_stores(self_hv, "mode", newSVpvs("html"));
}
RETVAL = sv_bless(newRV_noinc((SV *)self_hv), gv_stashpv(class, GV_ADD));
}
OUTPUT:
RETVAL
# ---- attach(selector, [dynamic_cb]) ----
SV *
attach(self, selector, ...)
SV *self
SV *selector
CODE:
{
HV *hv = (HV *)SvRV(self);
SV **att_svp = hv_fetchs(hv, "_attachments", 0);
HV *att_hv = (HV *)SvRV(*att_svp);
xs/contextmenu.xs view on Meta::CPAN
XPUSHs(sv_2mortal(newSVpvn(key, klen)));
}
}
# ---- enable / disable / is_enabled ----
SV *
enable(self)
SV *self
CODE:
{
HV *hv = (HV *)SvRV(self);
(void)hv_stores(hv, "_enabled", newSViv(1));
RETVAL = SvREFCNT_inc(self);
}
OUTPUT:
RETVAL
SV *
disable(self)
SV *self
CODE:
{
HV *hv = (HV *)SvRV(self);
(void)hv_stores(hv, "_enabled", newSViv(0));
RETVAL = SvREFCNT_inc(self);
}
OUTPUT:
RETVAL
int
is_enabled(self)
SV *self
CODE:
{
HV *hv = (HV *)SvRV(self);
SV **en_svp = hv_fetchs(hv, "_enabled", 0);
RETVAL = (en_svp && SvTRUE(*en_svp)) ? 1 : 0;
}
OUTPUT:
RETVAL
# ---- js_code() â generate the JS injection code ----
SV *
js_code(self)
SV *self
CODE:
{
HV *hv = (HV *)SvRV(self);
SV **att_svp = hv_fetchs(hv, "_attachments", 0);
SV **items_svp = hv_fetchs(hv, "_items", 0);
SV **global_svp = hv_fetchs(hv, "_global", 0);
HV *att_hv = (HV *)SvRV(*att_svp);
AV *items_av = (AV *)SvRV(*items_svp);
int is_global = (global_svp && SvTRUE(*global_svp));
SV *js;
js = newSVpvs(
"(function(){\n"
"if(window.__chandraCtxMenu)return;\n"
"window.__chandraCtxMenu=1;\n"
"var sels=["
);
/* Emit selectors */
{
HE *entry;
int first = 1;
hv_iterinit(att_hv);
while ((entry = hv_iternext(att_hv)) != NULL) {
I32 klen;
const char *key = hv_iterkey(entry, &klen);
if (!first) sv_catpvs(js, ",");
sv_catpvs(js, "'");
sv_catpvn(js, key, klen);
sv_catpvs(js, "'");
first = 0;
}
}
sv_catpvs(js, "];\n");
/* Items JSON */
sv_catpvs(js, "var items=");
_cm_items_to_js(aTHX_ js, items_av);
sv_catpvs(js, ";\n");
/* Global flag */
sv_catpvf(js, "var isGlobal=%d;\n", is_global);
/* CSS for menu */
sv_catpvs(js,
"var style=document.createElement('style');\n"
"style.textContent='"
".chandra-ctx-menu{"
"position:fixed;z-index:999999;min-width:160px;"
"background:#fff;border:1px solid #ccc;border-radius:6px;"
"box-shadow:0 4px 16px rgba(0,0,0,.18);padding:4px 0;"
"font:13px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;"
"color:#222;user-select:none;opacity:0;transform:scale(.96);"
"transition:opacity .12s,transform .12s;"
"}"
".chandra-ctx-menu.show{opacity:1;transform:scale(1);}"
".chandra-ctx-item{"
"padding:6px 32px 6px 28px;cursor:pointer;white-space:nowrap;"
"display:flex;align-items:center;position:relative;"
"}"
".chandra-ctx-item:hover{background:#e8f0fe;}"
".chandra-ctx-item.disabled{color:#999;pointer-events:none;}"
".chandra-ctx-sep{height:1px;background:#e0e0e0;margin:4px 8px;}"
".chandra-ctx-icon{width:20px;margin-right:6px;text-align:center;}"
".chandra-ctx-sc{margin-left:auto;padding-left:24px;color:#888;font-size:12px;}"
".chandra-ctx-check{position:absolute;left:8px;}"
".chandra-ctx-sub-arrow{margin-left:auto;padding-left:16px;color:#888;}"
".chandra-ctx-sub{position:fixed;}"
"@media(prefers-color-scheme:dark){"
".chandra-ctx-menu{background:#2d2d2d;border-color:#555;color:#e0e0e0;}"
".chandra-ctx-item:hover{background:#404040;}"
".chandra-ctx-sep{background:#555;}"
".chandra-ctx-sc,.chandra-ctx-sub-arrow{color:#888;}"
".chandra-ctx-item.disabled{color:#666;}"
"}"
"';\n"
"document.head.appendChild(style);\n"
);
/* Menu rendering and event handling JS */
sv_catpvs(js,
"var openMenus=[];\n"
"function closeAll(){"
"openMenus.forEach(function(m){m.remove()});"
"openMenus=[];"
"}\n"
"function buildMenu(items,x,y,depth){"
"var m=document.createElement('div');"
"m.className='chandra-ctx-menu';"
"items.forEach(function(it){"
"if(it.sep){"
"var s=document.createElement('div');"
"s.className='chandra-ctx-sep';"
"m.appendChild(s);return;"
"}"
"var row=document.createElement('div');"
"row.className='chandra-ctx-item'+(it.dis?' disabled':'');"
/* Check mark */
"if(it.chk){"
"var ck=document.createElement('span');"
"ck.className='chandra-ctx-check';"
"ck.textContent=it.ckd?'\\u2713':'';"
"row.appendChild(ck);"
"}"
/* Icon */
"if(it.ico){"
"var ic=document.createElement('span');"
"ic.className='chandra-ctx-icon';"
"ic.textContent=it.ico;"
"row.appendChild(ic);"
"}"
/* Label */
"var lb=document.createElement('span');"
"lb.textContent=it.l||'';"
"row.appendChild(lb);"
/* Shortcut hint */
"if(it.sc){"
"var sh=document.createElement('span');"
"sh.className='chandra-ctx-sc';"
"sh.textContent=it.sc;"
"row.appendChild(sh);"
"}"
/* Submenu arrow + hover */
"if(it.sub){"
"var ar=document.createElement('span');"
"ar.className='chandra-ctx-sub-arrow';"
"ar.textContent='\\u25b6';"
"row.appendChild(ar);"
"var subTimer=null,subMenu=null;"
"row.addEventListener('mouseenter',function(){"
"clearTimeout(subTimer);"
"if(!subMenu){"
"subTimer=setTimeout(function(){"
"var r=row.getBoundingClientRect();"
"subMenu=buildMenu(it.sub,r.right,r.top,(depth||0)+1);"
"subMenu.addEventListener('mouseleave',function(ev){"
"if(ev.relatedTarget&&row.contains(ev.relatedTarget))return;"
"subMenu.remove();"
"var idx=openMenus.indexOf(subMenu);"
"if(idx>=0)openMenus.splice(idx,1);"
"subMenu=null;"
"});"
"},150);"
"}"
"});"
"row.addEventListener('mouseleave',function(ev){"
"if(ev.relatedTarget&&subMenu&&subMenu.contains(ev.relatedTarget))return;"
"clearTimeout(subTimer);"
"if(subMenu){subMenu.remove();"
"var idx=openMenus.indexOf(subMenu);"
"if(idx>=0)openMenus.splice(idx,1);"
"subMenu=null;}"
"});"
"}"
/* Click handler */
"if(it.id!=null&&!it.dis){"
"row.addEventListener('click',function(e){"
"e.stopPropagation();"
"var payload=JSON.stringify({type:'action',id:it.id"
",chk:it.chk?!it.ckd:undefined});"
"if(it.chk){it.ckd=!it.ckd;"
"var cm=row.querySelector('.chandra-ctx-check');"
"if(cm)cm.textContent=it.ckd?'\\u2713':'';}"
"closeAll();"
"window.chandra.invoke('__chandraContextMenuBridge',[payload]);"
"});"
"}"
"m.appendChild(row);"
"});\n"
"m.style.left=x+'px';m.style.top=y+'px';"
"document.body.appendChild(m);"
"openMenus.push(m);"
/* Reposition if off-screen */
"var br=m.getBoundingClientRect();"
"if(br.right>window.innerWidth)m.style.left=(x-br.width)+'px';"
"if(br.bottom>window.innerHeight)m.style.top=(y-br.height)+'px';"
"requestAnimationFrame(function(){m.classList.add('show');});"
"return m;"
"}\n"
/* Dismiss on click outside or Escape */
"document.addEventListener('click',function(){closeAll();});\n"
"document.addEventListener('keydown',function(e){"
"if(e.key==='Escape')closeAll();"
"});\n"
/* Context menu handler */
"document.addEventListener('contextmenu',function(e){"
"var t=e.target;"
/* Check selectors */
"var matched=false;"
"for(var i=0;i<sels.length;i++){"
"if(t.matches(sels[i])||t.closest(sels[i])){"
"matched=true;break;"
"}"
"}"
"if(!matched&&!isGlobal)return;\n"
"e.preventDefault();"
"closeAll();"
/* Send dynamic request or show static menu */
"var tdata={id:t.id||'',class:t.className||'',tag:t.tagName||''"
",sel:matched?sels[i]:''};\n"
/* Check if we need dynamic items */
"var payload=JSON.stringify({type:'contextmenu'"
",x:e.clientX,y:e.clientY,target:tdata});\n"
"window.chandra.invoke('__chandraContextMenuBridge',[payload]);\n"
"});\n"
/* Expose show function for dynamic response */
"window.__chandraCtxShow=function(x,y,dynItems){"
"closeAll();"
"buildMenu(dynItems||items,x,y,0);"
"};\n"
"})();\n"
);
RETVAL = js;
}
OUTPUT:
RETVAL
# ---- _dispatch(json_sv) â handle messages from JS bridge ----
void
_dispatch(self, json_sv)
SV *self
SV *json_sv
CODE:
{
HV *hv = (HV *)SvRV(self);
SV **en_svp = hv_fetchs(hv, "_enabled", 0);
SV *event_sv;
HV *event_hv;
SV **type_svp;
const char *type;
if (!en_svp || !SvTRUE(*en_svp)) return;
/* Decode JSON */
{
dSP;
int count;
ENTER; SAVETMPS;
PUSHMARK(SP);
XPUSHs(json_sv);
PUTBACK;
count = call_pv("Cpanel::JSON::XS::decode_json", G_SCALAR | G_EVAL);
SPAGAIN;
if (SvTRUE(ERRSV) || count < 1) {
FREETMPS; LEAVE;
return;
}
event_sv = newSVsv(POPs);
PUTBACK;
FREETMPS; LEAVE;
}
if (!SvROK(event_sv) || SvTYPE(SvRV(event_sv)) != SVt_PVHV) {
SvREFCNT_dec(event_sv);
return;
}
event_hv = (HV *)SvRV(event_sv);
type_svp = hv_fetchs(event_hv, "type", 0);
if (!type_svp || !SvOK(*type_svp)) {
SvREFCNT_dec(event_sv);
return;
}
type = SvPV_nolen(*type_svp);
/* Action click */
if (strEQ(type, "action")) {
SV **id_svp = hv_fetchs(event_hv, "id", 0);
SV **chk_svp = hv_fetchs(event_hv, "chk", 0);
if (id_svp && SvOK(*id_svp)) {
SV **actions_svp = hv_fetchs(hv, "_actions", 0);
HV *actions = (HV *)SvRV(*actions_svp);
char id_str[32];
int id_len = my_snprintf(id_str, sizeof(id_str), "%ld", (long)SvIV(*id_svp));
SV **cb_svp = hv_fetch(actions, id_str, id_len, 0);
if (cb_svp && SvROK(*cb_svp) && SvTYPE(SvRV(*cb_svp)) == SVt_PVCV) {
/* Update checked state in items if checkable */
if (chk_svp && SvOK(*chk_svp)) {
/* Find item by _id and update checked */
SV **items_svp = hv_fetchs(hv, "_items", 0);
AV *items_av = (AV *)SvRV(*items_svp);
I32 len = av_len(items_av);
I32 j;
for (j = 0; j <= len; j++) {
SV **elem = av_fetch(items_av, j, 0);
if (!elem || !SvROK(*elem) || SvTYPE(SvRV(*elem)) != SVt_PVHV)
continue;
HV *it = (HV *)SvRV(*elem);
SV **iid = hv_fetchs(it, "_id", 0);
if (iid && SvOK(*iid) && SvIV(*iid) == SvIV(*id_svp)) {
(void)hv_stores(it, "checked",
newSViv(SvTRUE(*chk_svp) ? 1 : 0));
break;
}
}
}
{
dSP;
ENTER; SAVETMPS;
PUSHMARK(SP);
if (chk_svp && SvOK(*chk_svp))
XPUSHs(*chk_svp);
PUTBACK;
call_sv(*cb_svp, G_DISCARD | G_EVAL);
if (SvTRUE(ERRSV))
warn("ContextMenu action error: %s", SvPV_nolen(ERRSV));
FREETMPS; LEAVE;
}
}
}
}
/* Context menu request â dynamic items or show static */
else if (strEQ(type, "contextmenu")) {
SV **x_svp = hv_fetchs(event_hv, "x", 0);
SV **y_svp = hv_fetchs(event_hv, "y", 0);
SV **target_svp = hv_fetchs(event_hv, "target", 0);
NV x = (x_svp && SvOK(*x_svp)) ? SvNV(*x_svp) : 0;
NV y = (y_svp && SvOK(*y_svp)) ? SvNV(*y_svp) : 0;
/* Check for dynamic callback: per-selector or global */
SV *dynamic_cb = NULL;
if (target_svp && SvROK(*target_svp)) {
HV *tgt = (HV *)SvRV(*target_svp);
SV **sel_svp = hv_fetchs(tgt, "sel", 0);
if (sel_svp && SvOK(*sel_svp) && SvCUR(*sel_svp) > 0) {
SV **att_svp2 = hv_fetchs(hv, "_attachments", 0);
HV *att = (HV *)SvRV(*att_svp2);
STRLEN slen;
const char *s = SvPV(*sel_svp, slen);
SV **dcb = hv_fetch(att, s, slen, 0);
if (dcb && SvROK(*dcb) && SvTYPE(SvRV(*dcb)) == SVt_PVCV)
dynamic_cb = *dcb;
}
}
if (!dynamic_cb) {
SV **gd_svp = hv_fetchs(hv, "_global_dynamic", 0);
if (gd_svp && SvROK(*gd_svp) && SvTYPE(SvRV(*gd_svp)) == SVt_PVCV)
dynamic_cb = *gd_svp;
}
if (dynamic_cb) {
/* Call dynamic callback with target info, get items arrayref back */
SV *dyn_items;
AV *dyn_av;
{
dSP;
int count;
ENTER; SAVETMPS;
PUSHMARK(SP);
if (target_svp && SvOK(*target_svp))
XPUSHs(*target_svp);
PUTBACK;
count = call_sv(dynamic_cb, G_SCALAR | G_EVAL);
SPAGAIN;
if (SvTRUE(ERRSV)) {
warn("ContextMenu dynamic callback error: %s", SvPV_nolen(ERRSV));
FREETMPS; LEAVE;
SvREFCNT_dec(event_sv);
return;
}
dyn_items = (count > 0) ? newSVsv(POPs) : NULL;
PUTBACK;
FREETMPS; LEAVE;
}
if (dyn_items && SvROK(dyn_items) && SvTYPE(SvRV(dyn_items)) == SVt_PVAV) {
dyn_av = (AV *)SvRV(dyn_items);
/* Register actions for dynamic items */
{
SV **actions_svp = hv_fetchs(hv, "_actions", 0);
_cm_register_actions(aTHX_ hv, dyn_av, (HV *)SvRV(*actions_svp));
}
/* Build JS items and eval show */
{
SV *show_js = newSVpvs("window.__chandraCtxShow(");
sv_catpvf(show_js, "%g,%g,", x, y);
_cm_items_to_js(aTHX_ show_js, dyn_av);
sv_catpvs(show_js, ");");
/* Call app->eval(js) */
{
SV **app_svp = hv_fetchs(hv, "app", 0);
if (app_svp && SvOK(*app_svp)) {
dSP;
ENTER; SAVETMPS;
PUSHMARK(SP);
XPUSHs(*app_svp);
XPUSHs(show_js);
PUTBACK;
call_method("eval", G_DISCARD);
FREETMPS; LEAVE;
}
}
SvREFCNT_dec(show_js);
}
SvREFCNT_dec(dyn_items);
}
} else {
/* Static menu â show via JS */
SV *show_js = newSVpvf("window.__chandraCtxShow(%g,%g);", x, y);
SV **app_svp = hv_fetchs(hv, "app", 0);
if (app_svp && SvOK(*app_svp)) {
dSP;
ENTER; SAVETMPS;
PUSHMARK(SP);
XPUSHs(*app_svp);
XPUSHs(show_js);
PUTBACK;
call_method("eval", G_DISCARD);
FREETMPS; LEAVE;
}
SvREFCNT_dec(show_js);
}
}
SvREFCNT_dec(event_sv);
}
# ---- inject() â bind dispatch + inject JS ----
SV *
inject(self)
SV *self
CODE:
{
HV *hv = (HV *)SvRV(self);
SV **injected_svp;
injected_svp = hv_fetchs(hv, "_injected", 0);
if (injected_svp && SvTRUE(*injected_svp)) {
RETVAL = SvREFCNT_inc(self);
} else {
SV *js;
(void)hv_stores(hv, "_injected", newSViv(1));
/* Bind __chandraContextMenuBridge dispatch if not already done */
{
SV **db_svp = hv_fetchs(hv, "_dispatch_bound", 0);
if (!db_svp || !SvTRUE(*db_svp)) {
SV **app_svp = hv_fetchs(hv, "app", 0);
if (app_svp && SvOK(*app_svp)) {
SV *cm_self_ref = newSVsv(self);
CV *wrapper_cv;
sv_rvweaken(cm_self_ref);
wrapper_cv = newXS(NULL, XS_Chandra__ContextMenu__dispatch_trampoline, __FILE__);
CvXSUBANY(wrapper_cv).any_ptr = (void *)cm_self_ref;
{
dSP;
ENTER; SAVETMPS;
PUSHMARK(SP);
XPUSHs(*app_svp);
XPUSHs(sv_2mortal(newSVpvs("__chandraContextMenuBridge")));
XPUSHs(sv_2mortal(newRV_noinc((SV *)wrapper_cv)));
PUTBACK;
call_method("bind", G_DISCARD);
FREETMPS; LEAVE;
}
(void)hv_stores(hv, "_dispatch_bound", newSViv(1));
}
}
}
/* Get JS code */
{
dSP;
int count;
ENTER; SAVETMPS;
PUSHMARK(SP);
XPUSHs(self);
PUTBACK;
count = call_method("js_code", G_SCALAR);
SPAGAIN;
js = (count > 0) ? newSVsv(POPs) : newSVpvs("");
PUTBACK;
FREETMPS; LEAVE;
}
/* Call $self->{app}->eval($js) */
{
SV **app_svp = hv_fetchs(hv, "app", 0);
if (app_svp && SvOK(*app_svp)) {
dSP;
ENTER; SAVETMPS;
PUSHMARK(SP);
XPUSHs(*app_svp);
XPUSHs(sv_2mortal(js));
PUTBACK;
call_method("eval", G_DISCARD);
FREETMPS; LEAVE;
} else {
SvREFCNT_dec(js);
}
}
RETVAL = SvREFCNT_inc(self);
}
}
OUTPUT:
RETVAL
( run in 2.976 seconds using v1.01-cache-2.11-cpan-39bf76dae61 )