@dhruviii/attack-path 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/main.ts +17 -0
- package/.storybook/preview.tsx +21 -0
- package/README.md +73 -0
- package/dist/attack-path.js +6127 -0
- package/dist/favicon.svg +8 -0
- package/dist/vite.svg +1 -0
- package/eslint.config.js +23 -0
- package/index.html +696 -0
- package/package.json +59 -0
- package/public/favicon.svg +8 -0
- package/public/vite.svg +1 -0
- package/src/App.css +0 -0
- package/src/App.tsx +9 -0
- package/src/Attack-path.tsx +195 -0
- package/src/components/DomainSidebar.tsx +154 -0
- package/src/components/GraphCanvas.tsx +135 -0
- package/src/components/Inspector.tsx +176 -0
- package/src/components/Minimap.tsx +48 -0
- package/src/components/NodeGlyph.tsx +9 -0
- package/src/data/data.json +530 -0
- package/src/hooks/usePanZoom.ts +97 -0
- package/src/index.css +76 -0
- package/src/index.tsx +1 -0
- package/src/lib/surface.ts +367 -0
- package/src/main.tsx +10 -0
- package/src/stories/components/card/card.stories.tsx +32 -0
- package/src/stories/components/card/card.tsx +13 -0
- package/src/stories/components/card/index.ts +1 -0
- package/tsconfig.app.json +20 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +53 -0
- package/vitest.shims.d.ts +1 -0
package/index.html
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
|
10
|
+
<title>Sentinel · Attack Path Analysis</title>
|
|
11
|
+
<script data-arena-recording="true">(function(){
|
|
12
|
+
"use strict";
|
|
13
|
+
var SK="__arena_rec",RRWEB_CDN="https://cdn.jsdelivr.net/npm/rrweb@2.0.0-alpha.4/dist/rrweb.min.js";
|
|
14
|
+
var MAX_RECORDING_MS=600000,MAX_RRWEB_EVENTS=30000,MAX_INTERACTIONS=5000,MAX_CURSOR_POINTS=10000,recStopped=false;
|
|
15
|
+
var stored=null;try{var raw=sessionStorage.getItem(SK);if(raw)stored=JSON.parse(raw);}catch(ex){}
|
|
16
|
+
var meta=stored?stored.m||null:null,rrwebEvents=stored?stored.r||[]:[],rrwebStarted=false,startTime=stored?stored.st||Date.now():Date.now();
|
|
17
|
+
var interactions=stored?stored.i||[]:[],cursorPath=stored?stored.c||[]:[],lastCursorSample=0,lastScrollSample=0;
|
|
18
|
+
var pageHeight=document.documentElement.scrollHeight;
|
|
19
|
+
var viewport={width:window.innerWidth,height:window.innerHeight};
|
|
20
|
+
function isCapped(){if(recStopped)return true;if(Date.now()-startTime>=MAX_RECORDING_MS){recStopped=true;return true;}return false;}
|
|
21
|
+
function persist(){try{var d=JSON.stringify({r:rrwebEvents,i:interactions,c:cursorPath,m:meta,st:startTime});sessionStorage.setItem(SK,d);}catch(ex){rrwebEvents=rrwebEvents.slice(Math.floor(rrwebEvents.length/2));try{sessionStorage.setItem(SK,JSON.stringify({r:rrwebEvents,i:interactions,c:cursorPath,m:meta,st:startTime}));}catch(ex2){}}}
|
|
22
|
+
setInterval(persist,5000);
|
|
23
|
+
window.addEventListener("resize",function(){viewport={width:window.innerWidth,height:window.innerHeight};pageHeight=document.documentElement.scrollHeight;});
|
|
24
|
+
document.addEventListener("click",function(e){if(isCapped()||interactions.length>=MAX_INTERACTIONS)return;var el=e.target;var tag=el.tagName?el.tagName.toLowerCase():"";var text=(el.textContent||"").trim().substring(0,80);var classes=el.className&&typeof el.className==="string"?el.className.substring(0,120):"";interactions.push({type:"click",t:Date.now()-startTime,x:Math.round(e.clientX),y:Math.round(e.clientY),element:{tag:tag,text:text,classes:classes}});},true);
|
|
25
|
+
window.addEventListener("scroll",function(){if(isCapped()||interactions.length>=MAX_INTERACTIONS)return;var now=Date.now();if(now-lastScrollSample<200)return;lastScrollSample=now;var scrollY=Math.round(window.scrollY||window.pageYOffset||0);var depthPct=pageHeight>viewport.height?Math.min(100,Math.round(((scrollY+viewport.height)/pageHeight)*100)):100;interactions.push({type:"scroll",t:now-startTime,y:scrollY,depth_pct:depthPct});},{passive:true});
|
|
26
|
+
document.addEventListener("mousemove",function(e){if(isCapped()||cursorPath.length>=MAX_CURSOR_POINTS)return;var now=Date.now();if(now-lastCursorSample<250)return;lastCursorSample=now;cursorPath.push([Math.round(e.clientX),Math.round(e.clientY),now-startTime]);},{passive:true});
|
|
27
|
+
document.addEventListener("keydown",function(e){if(isCapped()||interactions.length>=MAX_INTERACTIONS)return;if(e.target&&(e.target.tagName==="INPUT"||e.target.tagName==="TEXTAREA"))return;interactions.push({type:"keydown",t:Date.now()-startTime,key:e.key});},true);
|
|
28
|
+
function buildAnalytics(){if(!meta)return null;var ph=document.documentElement.scrollHeight;var maxScroll=0,clickElements={},firstClick=null,firstScroll=null,totalDistance=0;for(var i=0;i<interactions.length;i++){var ev=interactions[i];if(ev.type==="scroll"){if(ev.depth_pct>maxScroll)maxScroll=ev.depth_pct;if(!firstScroll)firstScroll=ev.t;}if(ev.type==="click"){if(!firstClick)firstClick=ev.t;var key=ev.element.tag+":"+ev.element.text.substring(0,30);clickElements[key]=true;}}for(var j=1;j<cursorPath.length;j++){var dx=cursorPath[j][0]-cursorPath[j-1][0];var dy=cursorPath[j][1]-cursorPath[j-1][1];totalDistance+=Math.sqrt(dx*dx+dy*dy);}var duration=Date.now()-startTime;var clickHeatmap=[];for(var k=0;k<interactions.length;k++){if(interactions[k].type==="click")clickHeatmap.push([interactions[k].x,interactions[k].y]);}return{generation_id:meta.generationId,tournament_id:meta.tournamentId,started_at:new Date(startTime).toISOString(),ended_at:new Date().toISOString(),duration_ms:duration,viewport:viewport,page_height:ph,interactions:interactions,cursor_path:cursorPath,click_heatmap:clickHeatmap,summary:{total_clicks:clickHeatmap.length,total_scrolls:interactions.filter(function(e){return e.type==="scroll";}).length,max_scroll_depth_pct:maxScroll,unique_elements_clicked:Object.keys(clickElements).length,time_to_first_click_ms:firstClick,time_to_first_scroll_ms:firstScroll,cursor_distance_px:Math.round(totalDistance),active_time_ms:duration}};}
|
|
29
|
+
function loadRrweb(){if(rrwebStarted)return;rrwebStarted=true;var s=document.createElement("script");s.src=RRWEB_CDN;s.onload=function(){var rec=(window.rrweb&&window.rrweb.record)||window.rrwebRecord;if(!rec)return;rec({emit:function(event){if(rrwebEvents.length>=MAX_RRWEB_EVENTS||isCapped())return;rrwebEvents.push(event);}});};s.onerror=function(){};document.head.appendChild(s);}
|
|
30
|
+
window.addEventListener("message",function(e){if(!e.data||typeof e.data.type!=="string")return;if(e.data.type==="arena:init"){if(!meta)startTime=Date.now();meta={generationId:e.data.generationId||e.data.modelId,tournamentId:e.data.tournamentId};persist();loadRrweb();if(e.source)e.source.postMessage({type:"arena:init-ack",generationId:meta.generationId},"*");}if(e.data.type==="arena:flush"){var resp={type:"arena:recording-data",generationId:meta?meta.generationId:null,rrwebEvents:rrwebEvents,analytics:buildAnalytics()};try{sessionStorage.removeItem(SK);}catch(ex){}if(e.source)e.source.postMessage(resp,"*");}});
|
|
31
|
+
loadRrweb();
|
|
32
|
+
})();</script>
|
|
33
|
+
<script data-arena-views="true">(function(){var isTop=false;try{isTop=window.self===window.top;}catch(e){}if(!isTop)return;window.__ARENA_META={generationId:"claude-opus-4-8",tournamentId:"70c93d8b-a9fc-4617-805d-e9f86363792b"};var payload={tournamentId:"70c93d8b-a9fc-4617-805d-e9f86363792b",modelId:"claude-opus-4-8"};try{var r=document.referrer;if(r){var u=new URL(r);payload.referrerDomain=u.hostname;}}catch(e){}try{var vid=null;try{vid=localStorage.getItem('arena_vid');}catch(e){}if(!vid){vid=typeof crypto!=="undefined"&&crypto.randomUUID?crypto.randomUUID():(Date.now().toString(36)+Math.random().toString(36).slice(2)).slice(0,32);try{localStorage.setItem('arena_vid',vid);}catch(e){}}if(vid)payload.viewerId=vid;}catch(e){}try{fetch("https://www.designarena.ai/api/agon/page-views",{"method":"POST","headers":{"Content-Type":"application/json"},"body":JSON.stringify(payload),"keepalive":true});}catch(e){}})();</script>
|
|
34
|
+
</head>
|
|
35
|
+
<body>
|
|
36
|
+
<div id="root"></div>
|
|
37
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
38
|
+
<script data-element-picker>/**
|
|
39
|
+
* Agon Element Picker — injected into the preview iframe.
|
|
40
|
+
*
|
|
41
|
+
* Two modes, both driven by postMessage from the parent:
|
|
42
|
+
*
|
|
43
|
+
* Inspect mode (click-to-select for AI chat prefill):
|
|
44
|
+
* Parent -> iframe: { type: 'inspect:mode', enabled: true/false }
|
|
45
|
+
* iframe -> Parent: { type: 'element:selected', file, line, column, tag, text, classes, id }
|
|
46
|
+
*
|
|
47
|
+
* Edit mode (WYSIWYG text editing):
|
|
48
|
+
* Parent -> iframe: { type: 'arena:edit-mode', enabled: true/false }
|
|
49
|
+
* Parent -> iframe: { type: 'arena:set-text', domPath, text } // undo/redo
|
|
50
|
+
* iframe -> Parent: { type: 'arena:text-edit', source: {file,line,col}|null,
|
|
51
|
+
* tag, oldText, newText, domPath, ts }
|
|
52
|
+
*
|
|
53
|
+
* Capability handshake (parent shows edit UI only when the build is tagged):
|
|
54
|
+
* Parent -> iframe: { type: 'arena:init', ... } (already sent for recording)
|
|
55
|
+
* iframe -> Parent: { type: 'arena:edit-capability', textEdit: true, tagged: bool }
|
|
56
|
+
*
|
|
57
|
+
* Source locations come from data-source-loc="file:line:col" attributes added
|
|
58
|
+
* by the vite source-tags plugin (data-agon-source is read as a legacy fallback).
|
|
59
|
+
*/
|
|
60
|
+
(function () {
|
|
61
|
+
'use strict';
|
|
62
|
+
|
|
63
|
+
// Capability markers — bumped whenever the picker gains behavior that older
|
|
64
|
+
// baked-in copies lack, so visual_picker_inject._refresh_stale_picker knows
|
|
65
|
+
// to re-inline this version.
|
|
66
|
+
// arena:link-edit — editing link / <a> text
|
|
67
|
+
// arena:textnode-edit — editing a single text node inside a mixed-content
|
|
68
|
+
// element (e.g. <h1>Text<span>..</span></h1>)
|
|
69
|
+
// arena:header-edit — edit `user-select:none` headers/nav; box-only
|
|
70
|
+
// highlight (no source/tag label)
|
|
71
|
+
// arena:multirun-edit — patch the exact text run of a multi-run element
|
|
72
|
+
// (e.g. lines split by <br/>), via runIndex
|
|
73
|
+
// marker: arena:link-edit arena:textnode-edit arena:header-edit arena:multirun-edit
|
|
74
|
+
|
|
75
|
+
var SOURCE_ATTR = 'data-source-loc';
|
|
76
|
+
var LEGACY_SOURCE_ATTR = 'data-agon-source';
|
|
77
|
+
|
|
78
|
+
var inspectEnabled = false;
|
|
79
|
+
var editEnabled = false;
|
|
80
|
+
var overlay = null;
|
|
81
|
+
var label = null;
|
|
82
|
+
var currentTarget = null;
|
|
83
|
+
var activeEdit = null; // { element, oldText }
|
|
84
|
+
|
|
85
|
+
function getSourceAttr(el) {
|
|
86
|
+
return el.getAttribute(SOURCE_ATTR) || el.getAttribute(LEGACY_SOURCE_ATTR);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createOverlay() {
|
|
90
|
+
if (overlay) return;
|
|
91
|
+
|
|
92
|
+
overlay = document.createElement('div');
|
|
93
|
+
overlay.id = '__picker-overlay';
|
|
94
|
+
overlay.style.cssText = [
|
|
95
|
+
'position: fixed',
|
|
96
|
+
'pointer-events: none',
|
|
97
|
+
'z-index: 2147483647',
|
|
98
|
+
'border: 2px solid #3b82f6',
|
|
99
|
+
'background: rgba(59, 130, 246, 0.08)',
|
|
100
|
+
'transition: all 0.05s ease-out',
|
|
101
|
+
'display: none',
|
|
102
|
+
'box-sizing: border-box',
|
|
103
|
+
].join(';');
|
|
104
|
+
|
|
105
|
+
label = document.createElement('div');
|
|
106
|
+
label.id = '__picker-label';
|
|
107
|
+
label.style.cssText = [
|
|
108
|
+
'position: fixed',
|
|
109
|
+
'pointer-events: none',
|
|
110
|
+
'z-index: 2147483647',
|
|
111
|
+
'background: #1e40af',
|
|
112
|
+
'color: #fff',
|
|
113
|
+
'font: 11px/1.4 monospace',
|
|
114
|
+
'padding: 2px 6px',
|
|
115
|
+
'border-radius: 3px',
|
|
116
|
+
'white-space: nowrap',
|
|
117
|
+
'display: none',
|
|
118
|
+
'max-width: 400px',
|
|
119
|
+
'overflow: hidden',
|
|
120
|
+
'text-overflow: ellipsis',
|
|
121
|
+
].join(';');
|
|
122
|
+
|
|
123
|
+
document.body.appendChild(overlay);
|
|
124
|
+
document.body.appendChild(label);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function removeOverlay() {
|
|
128
|
+
if (overlay) { overlay.remove(); overlay = null; }
|
|
129
|
+
if (label) { label.remove(); label = null; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isPickerNode(el) {
|
|
133
|
+
return el === overlay || el === label ||
|
|
134
|
+
el.id === '__picker-overlay' || el.id === '__picker-label';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function findSourceAttr(el) {
|
|
138
|
+
while (el && el !== document.body && el !== document.documentElement) {
|
|
139
|
+
var src = getSourceAttr(el);
|
|
140
|
+
if (src) return { element: el, source: src };
|
|
141
|
+
el = el.parentElement;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseSource(sourceStr) {
|
|
147
|
+
// Format: "relative/path.tsx:line:col"
|
|
148
|
+
var parts = sourceStr.split(':');
|
|
149
|
+
if (parts.length < 3) return { file: sourceStr, line: 0, column: 0 };
|
|
150
|
+
var col = parseInt(parts.pop(), 10);
|
|
151
|
+
var line = parseInt(parts.pop(), 10);
|
|
152
|
+
var file = parts.join(':'); // rejoin in case path has colons (unlikely but safe)
|
|
153
|
+
return { file: file, line: line, column: col };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Stable-enough CSS path so the parent can address this element for undo.
|
|
157
|
+
function domPath(el) {
|
|
158
|
+
var segs = [];
|
|
159
|
+
while (el && el !== document.body && el.parentElement) {
|
|
160
|
+
var i = 1;
|
|
161
|
+
var sib = el;
|
|
162
|
+
while ((sib = sib.previousElementSibling)) i++;
|
|
163
|
+
segs.unshift(el.tagName.toLowerCase() + ':nth-child(' + i + ')');
|
|
164
|
+
el = el.parentElement;
|
|
165
|
+
}
|
|
166
|
+
return 'body > ' + segs.join(' > ');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// An element is whole-text-editable when it owns at least one non-whitespace
|
|
170
|
+
// text node and no element children — editing such an element via
|
|
171
|
+
// contenteditable is safe. Mixed-content elements are handled per-text-node
|
|
172
|
+
// (see resolveEditable / beginEditTextNode) instead.
|
|
173
|
+
function isTextEditable(el) {
|
|
174
|
+
if (!el || el.children.length > 0) return false;
|
|
175
|
+
for (var i = 0; i < el.childNodes.length; i++) {
|
|
176
|
+
var n = el.childNodes[i];
|
|
177
|
+
if (n.nodeType === Node.TEXT_NODE && n.textContent.trim()) return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The text node directly under a viewport point, if it holds real text.
|
|
183
|
+
function textNodeAtPoint(x, y) {
|
|
184
|
+
var node = null;
|
|
185
|
+
if (document.caretPositionFromPoint) {
|
|
186
|
+
var cp = document.caretPositionFromPoint(x, y);
|
|
187
|
+
node = cp && cp.offsetNode;
|
|
188
|
+
} else if (document.caretRangeFromPoint) {
|
|
189
|
+
var r = document.caretRangeFromPoint(x, y);
|
|
190
|
+
node = r && r.startContainer;
|
|
191
|
+
}
|
|
192
|
+
if (node && node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
|
|
193
|
+
return node;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// First direct non-whitespace text child of an element, if any.
|
|
199
|
+
function firstDirectTextNode(el) {
|
|
200
|
+
if (!el) return null;
|
|
201
|
+
for (var i = 0; i < el.childNodes.length; i++) {
|
|
202
|
+
var n = el.childNodes[i];
|
|
203
|
+
if (n.nodeType === Node.TEXT_NODE && n.textContent.trim()) return n;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Resolve what a hover/click at (x, y) should edit:
|
|
209
|
+
// { kind: 'text', textNode, element } — one text run inside a mixed element
|
|
210
|
+
// { kind: 'element', element } — a whole pure-text element
|
|
211
|
+
// null — nothing editable here
|
|
212
|
+
function resolveEditable(target, x, y) {
|
|
213
|
+
var tn = textNodeAtPoint(x, y);
|
|
214
|
+
if (tn) {
|
|
215
|
+
var p = tn.parentElement;
|
|
216
|
+
// Only split out an individual text node when the element also has
|
|
217
|
+
// element children (mixed content); pure-text elements use the cleaner
|
|
218
|
+
// whole-element path below.
|
|
219
|
+
if (p && !isPickerNode(p) && p.children.length > 0) {
|
|
220
|
+
return { kind: 'text', textNode: tn, element: p };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (isTextEditable(target)) return { kind: 'element', element: target };
|
|
224
|
+
// Fallback for mixed-content elements whose caret can't be hit-tested
|
|
225
|
+
// (e.g. `user-select: none` headers/nav): edit the first direct text run.
|
|
226
|
+
if (target && !isPickerNode(target) && target.children.length > 0) {
|
|
227
|
+
var direct = firstDirectTextNode(target);
|
|
228
|
+
if (direct) return { kind: 'text', textNode: direct, element: target };
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Viewport rect of a text node's contents (falls back to its parent).
|
|
234
|
+
function rectOfTextNode(tn) {
|
|
235
|
+
try {
|
|
236
|
+
var r = document.createRange();
|
|
237
|
+
r.selectNodeContents(tn);
|
|
238
|
+
var rect = r.getBoundingClientRect();
|
|
239
|
+
if (rect && (rect.width || rect.height)) return rect;
|
|
240
|
+
} catch (e) { /* fall through */ }
|
|
241
|
+
return tn.parentElement ? tn.parentElement.getBoundingClientRect() : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function applyHighlight(rect, editable, labelEl, showLabel) {
|
|
245
|
+
if (!overlay || !rect) return;
|
|
246
|
+
overlay.style.top = rect.top + 'px';
|
|
247
|
+
overlay.style.left = rect.left + 'px';
|
|
248
|
+
overlay.style.width = rect.width + 'px';
|
|
249
|
+
overlay.style.height = rect.height + 'px';
|
|
250
|
+
overlay.style.borderColor = editable ? '#10b981' : '#3b82f6';
|
|
251
|
+
overlay.style.background = editable
|
|
252
|
+
? 'rgba(16, 185, 129, 0.08)' : 'rgba(59, 130, 246, 0.08)';
|
|
253
|
+
overlay.style.display = 'block';
|
|
254
|
+
|
|
255
|
+
// Edit mode shows just the highlight box — no source/tag label.
|
|
256
|
+
if (!showLabel || !label) {
|
|
257
|
+
if (label) label.style.display = 'none';
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
var tag = labelEl ? labelEl.tagName.toLowerCase() : 'text';
|
|
262
|
+
var src = labelEl ? getSourceAttr(labelEl) : null;
|
|
263
|
+
var labelText = '<' + tag + '>';
|
|
264
|
+
if (src) {
|
|
265
|
+
var parsed = parseSource(src);
|
|
266
|
+
labelText += ' ' + parsed.file + ':' + parsed.line;
|
|
267
|
+
}
|
|
268
|
+
if (editable) labelText += ' ✎';
|
|
269
|
+
label.textContent = labelText;
|
|
270
|
+
label.style.background = editable ? '#065f46' : '#1e40af';
|
|
271
|
+
|
|
272
|
+
// Position label above the highlight, or below if too close to top
|
|
273
|
+
var labelTop = rect.top - 22;
|
|
274
|
+
if (labelTop < 4) labelTop = rect.bottom + 4;
|
|
275
|
+
label.style.top = labelTop + 'px';
|
|
276
|
+
label.style.left = Math.max(4, rect.left) + 'px';
|
|
277
|
+
label.style.display = 'block';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function highlightElement(el, editable, showLabel) {
|
|
281
|
+
if (!el) return;
|
|
282
|
+
applyHighlight(el.getBoundingClientRect(), editable, el, showLabel);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function hideOverlay() {
|
|
286
|
+
if (overlay) overlay.style.display = 'none';
|
|
287
|
+
if (label) label.style.display = 'none';
|
|
288
|
+
currentTarget = null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function postToParent(msg) {
|
|
292
|
+
if (window.parent && window.parent !== window) {
|
|
293
|
+
window.parent.postMessage(msg, '*');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------- inspect
|
|
298
|
+
|
|
299
|
+
function onMouseOver(e) {
|
|
300
|
+
if (!inspectEnabled && !editEnabled) return;
|
|
301
|
+
var target = e.target;
|
|
302
|
+
if (isPickerNode(target)) return;
|
|
303
|
+
if (activeEdit && target === activeEdit.element) { hideOverlay(); return; }
|
|
304
|
+
|
|
305
|
+
if (editEnabled) {
|
|
306
|
+
// Edit mode: highlight box only, no source/tag label (showLabel=false).
|
|
307
|
+
var res = resolveEditable(target, e.clientX, e.clientY);
|
|
308
|
+
if (res) {
|
|
309
|
+
currentTarget = res.element;
|
|
310
|
+
if (res.kind === 'text') {
|
|
311
|
+
applyHighlight(rectOfTextNode(res.textNode), true, res.element, false);
|
|
312
|
+
} else {
|
|
313
|
+
highlightElement(res.element, true, false);
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
currentTarget = target;
|
|
317
|
+
highlightElement(target, false, false);
|
|
318
|
+
}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
var found = findSourceAttr(target);
|
|
323
|
+
if (found) {
|
|
324
|
+
currentTarget = found.element;
|
|
325
|
+
highlightElement(found.element, false, true);
|
|
326
|
+
} else {
|
|
327
|
+
currentTarget = target;
|
|
328
|
+
highlightElement(target, false, true);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function onMouseOut(e) {
|
|
333
|
+
if (!inspectEnabled && !editEnabled) return;
|
|
334
|
+
var related = e.relatedTarget;
|
|
335
|
+
if (related === overlay || related === label) return;
|
|
336
|
+
if (!e.relatedTarget || e.relatedTarget === document) {
|
|
337
|
+
hideOverlay();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function onInspectClick(e) {
|
|
342
|
+
if (!inspectEnabled) return;
|
|
343
|
+
|
|
344
|
+
e.preventDefault();
|
|
345
|
+
e.stopPropagation();
|
|
346
|
+
e.stopImmediatePropagation();
|
|
347
|
+
|
|
348
|
+
var target = e.target;
|
|
349
|
+
if (isPickerNode(target)) return;
|
|
350
|
+
|
|
351
|
+
var found = findSourceAttr(target);
|
|
352
|
+
var element = found ? found.element : target;
|
|
353
|
+
var sourceStr = found ? found.source : null;
|
|
354
|
+
|
|
355
|
+
var info = {
|
|
356
|
+
type: 'element:selected',
|
|
357
|
+
tag: element.tagName.toLowerCase(),
|
|
358
|
+
text: (element.textContent || '').trim().substring(0, 200),
|
|
359
|
+
classes: element.className && typeof element.className === 'string'
|
|
360
|
+
? element.className.substring(0, 300) : '',
|
|
361
|
+
id: element.id || '',
|
|
362
|
+
file: null,
|
|
363
|
+
line: null,
|
|
364
|
+
column: null,
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
if (sourceStr) {
|
|
368
|
+
var parsed = parseSource(sourceStr);
|
|
369
|
+
info.file = parsed.file;
|
|
370
|
+
info.line = parsed.line;
|
|
371
|
+
info.column = parsed.column;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
postToParent(info);
|
|
375
|
+
console.log('[element-picker] Selected:', info);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function enableInspect() {
|
|
379
|
+
if (inspectEnabled) return;
|
|
380
|
+
if (editEnabled) disableEdit();
|
|
381
|
+
inspectEnabled = true;
|
|
382
|
+
createOverlay();
|
|
383
|
+
document.addEventListener('mouseover', onMouseOver, true);
|
|
384
|
+
document.addEventListener('mouseout', onMouseOut, true);
|
|
385
|
+
document.addEventListener('click', onInspectClick, true);
|
|
386
|
+
document.body.style.cursor = 'crosshair';
|
|
387
|
+
console.log('[element-picker] Inspect mode ON');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function disableInspect() {
|
|
391
|
+
if (!inspectEnabled) return;
|
|
392
|
+
inspectEnabled = false;
|
|
393
|
+
document.removeEventListener('mouseover', onMouseOver, true);
|
|
394
|
+
document.removeEventListener('mouseout', onMouseOut, true);
|
|
395
|
+
document.removeEventListener('click', onInspectClick, true);
|
|
396
|
+
hideOverlay();
|
|
397
|
+
removeOverlay();
|
|
398
|
+
document.body.style.cursor = '';
|
|
399
|
+
console.log('[element-picker] Inspect mode OFF');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ------------------------------------------------------------------- edit
|
|
403
|
+
|
|
404
|
+
function commitEdit() {
|
|
405
|
+
if (!activeEdit) return;
|
|
406
|
+
// For text-node edits the contenteditable target is a temporary wrapper
|
|
407
|
+
// span, but the source location + tag belong to the element that owns the
|
|
408
|
+
// text (sourceEl). childIndex addresses the specific text run for undo.
|
|
409
|
+
var editEl = activeEdit.element;
|
|
410
|
+
var srcEl = activeEdit.kind === 'text' ? activeEdit.sourceEl : editEl;
|
|
411
|
+
var childIndex = activeEdit.kind === 'text' ? activeEdit.childIndex : null;
|
|
412
|
+
var runIndex = activeEdit.kind === 'text' ? activeEdit.textRunIndex : null;
|
|
413
|
+
var oldText = activeEdit.oldText;
|
|
414
|
+
var newText = editEl.textContent;
|
|
415
|
+
endEdit();
|
|
416
|
+
|
|
417
|
+
if (newText === oldText) return;
|
|
418
|
+
|
|
419
|
+
var sourceStr = getSourceAttr(srcEl);
|
|
420
|
+
postToParent({
|
|
421
|
+
type: 'arena:text-edit',
|
|
422
|
+
source: sourceStr ? parseSource(sourceStr) : null,
|
|
423
|
+
tag: srcEl.tagName.toLowerCase(),
|
|
424
|
+
oldText: oldText,
|
|
425
|
+
newText: newText,
|
|
426
|
+
domPath: domPath(srcEl),
|
|
427
|
+
childIndex: childIndex,
|
|
428
|
+
runIndex: runIndex,
|
|
429
|
+
ts: Date.now(),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function cancelEdit() {
|
|
434
|
+
if (!activeEdit) return;
|
|
435
|
+
activeEdit.element.textContent = activeEdit.oldText;
|
|
436
|
+
endEdit();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function endEdit() {
|
|
440
|
+
if (!activeEdit) return;
|
|
441
|
+
var el = activeEdit.element;
|
|
442
|
+
el.removeAttribute('contenteditable');
|
|
443
|
+
el.style.outline = activeEdit.prevOutline;
|
|
444
|
+
el.style.removeProperty('user-select');
|
|
445
|
+
el.style.removeProperty('-webkit-user-select');
|
|
446
|
+
el.removeEventListener('blur', onEditBlur);
|
|
447
|
+
el.removeEventListener('keydown', onEditKeydown);
|
|
448
|
+
// Text-node edits: unwrap the temporary span back into a plain text node so
|
|
449
|
+
// the DOM structure matches the (now-edited) source.
|
|
450
|
+
if (activeEdit.kind === 'text' && el.parentNode) {
|
|
451
|
+
el.parentNode.replaceChild(document.createTextNode(el.textContent), el);
|
|
452
|
+
}
|
|
453
|
+
// Restore the href we stashed while editing a link (see beginEdit).
|
|
454
|
+
if (activeEdit.anchor && activeEdit.prevHref != null) {
|
|
455
|
+
activeEdit.anchor.setAttribute('href', activeEdit.prevHref);
|
|
456
|
+
}
|
|
457
|
+
activeEdit = null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function onEditBlur() {
|
|
461
|
+
commitEdit();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function onEditKeydown(e) {
|
|
465
|
+
e.stopPropagation();
|
|
466
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
467
|
+
e.preventDefault();
|
|
468
|
+
commitEdit();
|
|
469
|
+
} else if (e.key === 'Escape') {
|
|
470
|
+
e.preventDefault();
|
|
471
|
+
cancelEdit();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Nearest enclosing link whose href should be neutralized while editing, so a
|
|
476
|
+
// click (caret placement) or Enter (commit) doesn't navigate away. Restored
|
|
477
|
+
// in endEdit; JS-driven router navigation is suppressed in onEditClick.
|
|
478
|
+
function stashAnchorHref(el) {
|
|
479
|
+
var anchor = el.tagName === 'A' ? el : (el.closest ? el.closest('a') : null);
|
|
480
|
+
var prevHref = anchor && anchor.hasAttribute('href') ? anchor.getAttribute('href') : null;
|
|
481
|
+
if (anchor && prevHref != null) anchor.removeAttribute('href');
|
|
482
|
+
return { anchor: prevHref != null ? anchor : null, prevHref: prevHref };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Turn `el` into a focused, select-all contenteditable region.
|
|
486
|
+
function activateEditable(el) {
|
|
487
|
+
try {
|
|
488
|
+
el.setAttribute('contenteditable', 'plaintext-only');
|
|
489
|
+
if (!el.isContentEditable) el.setAttribute('contenteditable', 'true');
|
|
490
|
+
} catch (err) {
|
|
491
|
+
el.setAttribute('contenteditable', 'true');
|
|
492
|
+
}
|
|
493
|
+
el.style.outline = '2px solid #10b981';
|
|
494
|
+
// Headers/nav are frequently `user-select: none`, which also blocks
|
|
495
|
+
// contenteditable editing; force it on (important beats class styles) for
|
|
496
|
+
// the duration of the edit. Cleared in endEdit (element) / discarded with
|
|
497
|
+
// the wrapper (text).
|
|
498
|
+
el.style.setProperty('user-select', 'text', 'important');
|
|
499
|
+
el.style.setProperty('-webkit-user-select', 'text', 'important');
|
|
500
|
+
el.addEventListener('blur', onEditBlur);
|
|
501
|
+
el.addEventListener('keydown', onEditKeydown);
|
|
502
|
+
el.focus();
|
|
503
|
+
|
|
504
|
+
// Select all text so a replacement starts with a clean slate
|
|
505
|
+
try {
|
|
506
|
+
var range = document.createRange();
|
|
507
|
+
range.selectNodeContents(el);
|
|
508
|
+
var sel = window.getSelection();
|
|
509
|
+
sel.removeAllRanges();
|
|
510
|
+
sel.addRange(range);
|
|
511
|
+
} catch (err) { /* selection is best-effort */ }
|
|
512
|
+
|
|
513
|
+
hideOverlay();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Edit a whole pure-text element in place.
|
|
517
|
+
function beginEdit(el) {
|
|
518
|
+
if (activeEdit) commitEdit();
|
|
519
|
+
var href = stashAnchorHref(el);
|
|
520
|
+
activeEdit = {
|
|
521
|
+
kind: 'element',
|
|
522
|
+
element: el,
|
|
523
|
+
oldText: el.textContent,
|
|
524
|
+
prevOutline: el.style.outline,
|
|
525
|
+
anchor: href.anchor,
|
|
526
|
+
prevHref: href.prevHref,
|
|
527
|
+
};
|
|
528
|
+
activateEditable(el);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Edit a single text run inside a mixed-content element (e.g. the leading
|
|
532
|
+
// text of <h1>Text<span>..</span></h1>). The run is wrapped in a temporary
|
|
533
|
+
// span so only it becomes editable; the wrapper is removed in endEdit, and
|
|
534
|
+
// the edit is reported against the owning element's source location.
|
|
535
|
+
function beginEditTextNode(textNode, sourceEl) {
|
|
536
|
+
if (activeEdit) commitEdit();
|
|
537
|
+
|
|
538
|
+
var parent = textNode.parentElement;
|
|
539
|
+
var childIndex = Array.prototype.indexOf.call(parent.childNodes, textNode);
|
|
540
|
+
// Index of this text node among the parent's non-whitespace direct text
|
|
541
|
+
// runs — lets the server patch the exact run of a multi-run element
|
|
542
|
+
// (e.g. <h1>A<br/>B<br/>C</h1>), not just the first.
|
|
543
|
+
var textRunIndex = 0;
|
|
544
|
+
for (var k = 0; k < parent.childNodes.length; k++) {
|
|
545
|
+
var cn = parent.childNodes[k];
|
|
546
|
+
if (cn === textNode) break;
|
|
547
|
+
if (cn.nodeType === Node.TEXT_NODE && cn.textContent.trim()) textRunIndex++;
|
|
548
|
+
}
|
|
549
|
+
var oldText = textNode.textContent;
|
|
550
|
+
var href = stashAnchorHref(parent);
|
|
551
|
+
|
|
552
|
+
var wrap = document.createElement('span');
|
|
553
|
+
wrap.setAttribute('data-agon-edit-wrap', '');
|
|
554
|
+
parent.replaceChild(wrap, textNode);
|
|
555
|
+
wrap.appendChild(textNode);
|
|
556
|
+
|
|
557
|
+
activeEdit = {
|
|
558
|
+
kind: 'text',
|
|
559
|
+
element: wrap,
|
|
560
|
+
sourceEl: sourceEl || parent,
|
|
561
|
+
childIndex: childIndex,
|
|
562
|
+
textRunIndex: textRunIndex,
|
|
563
|
+
oldText: oldText,
|
|
564
|
+
prevOutline: wrap.style.outline,
|
|
565
|
+
anchor: href.anchor,
|
|
566
|
+
prevHref: href.prevHref,
|
|
567
|
+
};
|
|
568
|
+
activateEditable(wrap);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function onEditClick(e) {
|
|
572
|
+
if (!editEnabled) return;
|
|
573
|
+
|
|
574
|
+
var target = e.target;
|
|
575
|
+
if (isPickerNode(target)) return;
|
|
576
|
+
|
|
577
|
+
// Clicks inside the element being edited behave normally (caret moves).
|
|
578
|
+
// When editing link text, still swallow the event so the app's own click
|
|
579
|
+
// handlers (e.g. router <Link> navigation) don't fire — caret placement is
|
|
580
|
+
// the browser default action and survives stopImmediatePropagation.
|
|
581
|
+
if (activeEdit && (target === activeEdit.element || activeEdit.element.contains(target))) {
|
|
582
|
+
if (activeEdit.anchor) {
|
|
583
|
+
e.stopPropagation();
|
|
584
|
+
e.stopImmediatePropagation();
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
e.preventDefault();
|
|
590
|
+
e.stopPropagation();
|
|
591
|
+
e.stopImmediatePropagation();
|
|
592
|
+
|
|
593
|
+
if (activeEdit) commitEdit();
|
|
594
|
+
var res = resolveEditable(target, e.clientX, e.clientY);
|
|
595
|
+
if (res && res.kind === 'text') {
|
|
596
|
+
beginEditTextNode(res.textNode, res.element);
|
|
597
|
+
} else if (res && res.kind === 'element') {
|
|
598
|
+
beginEdit(res.element);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function enableEdit() {
|
|
603
|
+
if (editEnabled) return;
|
|
604
|
+
if (inspectEnabled) disableInspect();
|
|
605
|
+
editEnabled = true;
|
|
606
|
+
createOverlay();
|
|
607
|
+
document.addEventListener('mouseover', onMouseOver, true);
|
|
608
|
+
document.addEventListener('mouseout', onMouseOut, true);
|
|
609
|
+
document.addEventListener('click', onEditClick, true);
|
|
610
|
+
document.body.style.cursor = 'text';
|
|
611
|
+
console.log('[element-picker] Edit mode ON');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function disableEdit() {
|
|
615
|
+
if (!editEnabled) return;
|
|
616
|
+
if (activeEdit) commitEdit();
|
|
617
|
+
editEnabled = false;
|
|
618
|
+
document.removeEventListener('mouseover', onMouseOver, true);
|
|
619
|
+
document.removeEventListener('mouseout', onMouseOut, true);
|
|
620
|
+
document.removeEventListener('click', onEditClick, true);
|
|
621
|
+
hideOverlay();
|
|
622
|
+
removeOverlay();
|
|
623
|
+
document.body.style.cursor = '';
|
|
624
|
+
console.log('[element-picker] Edit mode OFF');
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Parent-driven text set, used for undo/redo of committed edits. When
|
|
628
|
+
// childIndex is provided the edit targeted a single text run of a
|
|
629
|
+
// mixed-content element, so only that child node is restored (setting the
|
|
630
|
+
// element's textContent would clobber its sibling elements).
|
|
631
|
+
function setText(path, text, childIndex) {
|
|
632
|
+
var el = null;
|
|
633
|
+
try { el = document.querySelector(path); } catch (err) { /* bad selector */ }
|
|
634
|
+
if (!el) {
|
|
635
|
+
console.warn('[element-picker] arena:set-text target not found:', path);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (activeEdit && activeEdit.element === el) cancelEdit();
|
|
639
|
+
if (childIndex != null && el.childNodes[childIndex]) {
|
|
640
|
+
el.childNodes[childIndex].textContent = text;
|
|
641
|
+
} else {
|
|
642
|
+
el.textContent = text;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function reportCapability() {
|
|
647
|
+
var tagged = !!document.querySelector(
|
|
648
|
+
'[' + SOURCE_ATTR + '], [' + LEGACY_SOURCE_ATTR + ']'
|
|
649
|
+
);
|
|
650
|
+
postToParent({ type: 'arena:edit-capability', textEdit: true, tagged: tagged });
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ---------------------------------------------------------------- wiring
|
|
654
|
+
|
|
655
|
+
window.addEventListener('message', function (e) {
|
|
656
|
+
if (!e.data || typeof e.data.type !== 'string') return;
|
|
657
|
+
|
|
658
|
+
switch (e.data.type) {
|
|
659
|
+
case 'inspect:mode':
|
|
660
|
+
if (e.data.enabled) enableInspect();
|
|
661
|
+
else disableInspect();
|
|
662
|
+
break;
|
|
663
|
+
case 'arena:edit-mode':
|
|
664
|
+
if (e.data.enabled) enableEdit();
|
|
665
|
+
else disableEdit();
|
|
666
|
+
break;
|
|
667
|
+
case 'arena:set-text':
|
|
668
|
+
setText(e.data.domPath, e.data.text, e.data.childIndex);
|
|
669
|
+
break;
|
|
670
|
+
case 'arena:init':
|
|
671
|
+
reportCapability();
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// Also support toggling via keyboard for standalone testing:
|
|
677
|
+
// Alt+Shift+I — inspect mode, Alt+Shift+E — edit mode
|
|
678
|
+
document.addEventListener('keydown', function (e) {
|
|
679
|
+
if (e.altKey && e.shiftKey && e.key === 'I') {
|
|
680
|
+
inspectEnabled ? disableInspect() : enableInspect();
|
|
681
|
+
} else if (e.altKey && e.shiftKey && e.key === 'E') {
|
|
682
|
+
editEnabled ? disableEdit() : enableEdit();
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
if (document.readyState === 'loading') {
|
|
687
|
+
document.addEventListener('DOMContentLoaded', reportCapability);
|
|
688
|
+
} else {
|
|
689
|
+
reportCapability();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
console.log('[element-picker] Loaded. inspect:mode / arena:edit-mode (or Alt+Shift+I / Alt+Shift+E).');
|
|
693
|
+
})();
|
|
694
|
+
</script>
|
|
695
|
+
</body>
|
|
696
|
+
</html>
|