Repository navigation
Expand file tree
/
Copy pathreview.html
More file actions
242 lines (232 loc) · 17 KB
/
Copy pathreview.html
File metadata and controls
242 lines (232 loc) · 17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Annotate It</title>
<style>
:root{--bg:#f7f7f5;--panel:#fff;--ink:#1f2328;--muted:#6b7280;--border:#e2e2dd;--accent:#2B3A67;--mark:#fff3bf;--mark-active:#ffe066;--danger:#b4232c;--ok:#1f7a4d}
*{box-sizing:border-box}
body{margin:0;font:15px/1.55 -apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
header{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:5}
header h1{font-size:15px;margin:0 8px 0 0;color:var(--accent);font-weight:700}
.spacer{flex:1}
.count{color:var(--muted);font-size:13px}
button.btn{border:1px solid var(--border);background:var(--panel);border-radius:7px;padding:6px 14px;cursor:pointer;font:inherit;color:var(--ink)}
button.btn:hover{border-color:var(--accent)}
button.btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
button.btn.ghost-danger{color:var(--danger)}
main{display:grid;grid-template-columns:1fr 360px;gap:16px;padding:16px;align-items:start}
@media (max-width:860px){main{grid-template-columns:1fr}}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:16px}
.panel h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:0 0 10px}
#rendered{white-space:pre-wrap;word-wrap:break-word;font:14px/1.7 ui-monospace,"SF Mono",Menlo,Consolas,monospace;min-height:60vh}
#rendered mark{background:var(--mark);border-radius:3px;padding:0 1px;cursor:pointer}
#rendered mark[data-depth="2"]{background:#ffe8a3}
#rendered mark[data-depth="3"]{background:#ffd43b}
#rendered mark.active{background:var(--mark-active);box-shadow:0 0 0 1px var(--accent)}
.hint{color:var(--muted);font-size:13px;margin-bottom:10px}
.overall textarea{width:100%;min-height:52px;border:1px solid var(--border);border-radius:8px;padding:8px 10px;font:inherit;resize:vertical;margin-bottom:14px}
ul#comments{list-style:none;margin:0;padding:0}
ul#comments li{border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:10px;cursor:pointer}
ul#comments li.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
ul#comments li .quote{font-size:13px;color:var(--muted);border-left:3px solid var(--mark-active);padding-left:8px;margin-bottom:6px;white-space:pre-wrap}
ul#comments li .note{white-space:pre-wrap}
ul#comments li .note:empty::before{content:"(no comment yet)";color:var(--muted);font-style:italic}
.empty{color:var(--muted);font-style:italic}
#editor{position:absolute;z-index:20;width:300px;background:var(--panel);border:1px solid var(--accent);border-radius:10px;box-shadow:0 8px 28px rgba(0,0,0,.18);padding:12px;display:none}
#editor .eq{font-size:12px;color:var(--muted);border-left:3px solid var(--mark-active);padding-left:8px;margin-bottom:8px;max-height:64px;overflow:auto;white-space:pre-wrap}
#editor textarea{width:100%;min-height:70px;border:1px solid var(--border);border-radius:8px;padding:8px;font:inherit;resize:vertical}
#editor .row{display:flex;gap:8px;margin-top:8px}
#editor .row .spacer{flex:1}
#picker{position:absolute;z-index:25;width:300px;background:var(--panel);border:1px solid var(--accent);border-radius:10px;box-shadow:0 8px 28px rgba(0,0,0,.18);padding:6px;display:none}
#picker .picker-head{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:4px 8px 6px}
#picker .picker-item{padding:8px;border-radius:7px;cursor:pointer}
#picker .picker-item:hover{background:var(--bg)}
#picker .pq{font-size:12px;color:var(--muted);border-left:3px solid var(--mark-active);padding-left:8px;margin-bottom:4px;white-space:pre-wrap;max-height:48px;overflow:auto}
#picker .pn{white-space:pre-wrap}
#picker .pn:empty::before{content:"(no comment yet)";color:var(--muted);font-style:italic}
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%) translateY(20px);background:var(--accent);color:#fff;padding:9px 16px;border-radius:8px;opacity:0;transition:all .18s ease;pointer-events:none;z-index:50}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
.toast.ok{background:var(--ok)}
.toast.err{background:var(--danger)}
</style>
</head>
<body>
<header>
<h1>Annotate It</h1>
<span class="count" id="count">0 comments</span>
<span class="spacer"></span>
<button class="btn primary" id="send">Send</button>
<button class="btn" id="copy" title="Copies your comments as Markdown to paste into the chat if Send doesn't work.">Copy as Markdown</button>
<button class="btn ghost-danger" id="clear">Clear comments</button>
</header>
<main>
<section class="panel" id="left">
<h2>Review</h2>
<p class="hint" id="review-hint">Select any text to add a comment — selections may overlap or nest. Click a highlight to edit or delete it. When done, click <b>Send</b> and return to the assistant.</p>
<div id="rendered"></div>
</section>
<aside class="panel">
<h2>Comments</h2>
<div class="overall"><textarea id="overall" placeholder="Optional: overall note (sent at the top)"></textarea></div>
<ul id="comments"></ul>
<p class="empty" id="comments-empty">No comments yet.</p>
</aside>
</main>
<div id="editor">
<div class="eq" id="editor-quote"></div>
<textarea id="editor-note" placeholder="Your comment…"></textarea>
<div class="row"><button class="btn ghost-danger" id="editor-delete">Delete</button><span class="spacer"></span><button class="btn" id="editor-cancel">Cancel</button><button class="btn primary" id="editor-save">Save</button></div>
</div>
<div id="picker"></div>
<div class="toast" id="toast"></div>
<script>
"use strict";
(function () {
const STORE_PREFIX = "annotate-it:";
const state = { text:"", overall:"", comments:[] };
let activeId = null, idSeq = 1, pendingNewId = null, storeKey = STORE_PREFIX + "default";
const $ = (id)=>document.getElementById(id);
const renderedEl=$("rendered"), commentsEl=$("comments"), commentsEmpty=$("comments-empty"),
overallEl=$("overall"), countEl=$("count"),
editor=$("editor"), editorQuote=$("editor-quote"), editorNote=$("editor-note"), picker=$("picker");
// Storage is keyed by a hash of the draft, so a new draft auto-resets comments.
function hash(s){let h=0;for(let i=0;i<s.length;i++){h=(h*31+s.charCodeAt(i))|0}return String(h)}
function save(){try{localStorage.setItem(storeKey,JSON.stringify({overall:state.overall,comments:state.comments}))}catch(e){}}
function loadComments(){try{const r=localStorage.getItem(storeKey);if(!r)return;const d=JSON.parse(r);state.overall=d.overall||"";state.comments=Array.isArray(d.comments)?d.comments:[]}catch(e){}}
function nextId(){const m=state.comments.reduce((a,c)=>Math.max(a,c.id||0),0);idSeq=Math.max(idSeq,m+1);return idSeq++}
// Render with boundary segmentation so comments may overlap or nest: split the
// text at every comment start/end, then wrap each segment once and shade it by
// how many comments cover it (depth 1..3). A single linear pass can't wrap
// nested spans, which is why the old non-overlapping renderer needed a guard.
function renderText(){
renderedEl.innerHTML="";
const text=state.text;
const points=new Set([0,text.length]);
for(const c of state.comments){points.add(c.start);points.add(c.end)}
const bounds=[...points].filter(p=>p>=0&&p<=text.length).sort((a,b)=>a-b);
for(let i=0;i<bounds.length-1;i++){
const s=bounds[i],e=bounds[i+1];if(e<=s)continue;
const seg=text.slice(s,e);
const covering=state.comments.filter(c=>c.start<=s&&c.end>=e);
if(!covering.length){renderedEl.appendChild(document.createTextNode(seg));continue}
const m=document.createElement("mark");
m.textContent=seg;
const ids=covering.map(c=>c.id);
m.dataset.ids=ids.join(",");
m.dataset.depth=String(Math.min(covering.length,3));
if(covering.some(c=>c.id===activeId))m.classList.add("active");
m.addEventListener("click",ev=>{ev.stopPropagation();onMarkClick(ids,m)});
renderedEl.appendChild(m);
}
}
// A comment can be split across several segment marks; return the first one.
function markForId(id){return [...renderedEl.querySelectorAll("mark")].find(m=>(m.dataset.ids||"").split(",").includes(String(id)))||null}
// Capture the click rect now: opening a popover re-renders the marks, which
// detaches the clicked element, so we position from coordinates, not the node.
function onMarkClick(ids,m){const rect=m.getBoundingClientRect();if(ids.length===1)openEditorFor(ids[0],rect);else openPicker(ids,rect)}
// Character offset of a (node, nodeOffset) DOM position within #rendered.
// Range.toString() handles element containers too, so a triple-click — whose
// selection ends on an element boundary, not a text node — maps correctly.
function offsetOf(node,nodeOffset){const r=document.createRange();r.setStart(renderedEl,0);r.setEnd(node,nodeOffset);return r.toString().length}
function getSelectionRange(){const s=window.getSelection();if(!s||s.rangeCount===0||s.isCollapsed)return null;const r=s.getRangeAt(0);if(!renderedEl.contains(r.startContainer)||!renderedEl.contains(r.endContainer))return null;let a=offsetOf(r.startContainer,r.startOffset),b=offsetOf(r.endContainer,r.endOffset);if(a>b)[a,b]=[b,a];if(a===b)return null;return{start:a,end:b}}
// Bound to document (not #rendered) so releasing the mouse just outside the
// text still completes the selection. Multi-click selections arrive as a rapid
// sequence of mouseups with rising e.detail (2 = word, 3 = line); debounce them
// so a triple-click yields one line comment instead of first committing the
// word from the double-click step. A drag is detail 1 and commits instantly.
let selectTimer=null;
function onMouseUp(e){
if(e&&e.detail>1){clearTimeout(selectTimer);selectTimer=setTimeout(commitSelection,250);return}
clearTimeout(selectTimer);commitSelection();
}
function commitSelection(){selectTimer=null;const r=getSelectionRange();if(!r)return;const c={id:nextId(),start:r.start,end:r.end,quote:state.text.slice(r.start,r.end),note:""};state.comments.push(c);save();window.getSelection().removeAllRanges();renderText();renderComments();pendingNewId=c.id;const m=markForId(c.id);openEditorFor(c.id,m?m.getBoundingClientRect():null)}
function discardPendingNew(){if(pendingNewId==null)return;const c=state.comments.find(x=>x.id===pendingNewId);if(c&&!(c.note&&c.note.trim())){state.comments=state.comments.filter(x=>x.id!==pendingNewId);save()}pendingNewId=null}
// Position from a captured rect (viewport-relative), not a live element, so it
// survives the re-render that openEditorFor/openPicker trigger.
function positionPopover(el,rect){if(!rect)return;const top=window.scrollY+rect.bottom+6;let left=window.scrollX+rect.left;const maxL=window.scrollX+document.documentElement.clientWidth-el.offsetWidth-8;if(left>maxL)left=maxL;if(left<8)left=8;el.style.top=top+"px";el.style.left=left+"px"}
function openEditorFor(id,rect){closePicker();if(pendingNewId!=null&&pendingNewId!==id)discardPendingNew();const c=state.comments.find(x=>x.id===id);if(!c)return;activeId=id;editorQuote.textContent=c.quote;editorNote.value=c.note;editor.style.display="block";renderText();renderComments();positionPopover(editor,rect);editorNote.focus()}
function closeEditor(){discardPendingNew();editor.style.display="none";activeId=null;renderText();renderComments()}
function saveEditor(){const c=state.comments.find(x=>x.id===activeId);if(c){c.note=editorNote.value.trim();save()}pendingNewId=null;closeEditor()}
function deleteActive(){state.comments=state.comments.filter(x=>x.id!==activeId);if(pendingNewId===activeId)pendingNewId=null;save();closeEditor()}
// When a clicked segment is covered by several comments, let the user pick one.
function openPicker(ids,rect){closeEditor();picker.innerHTML="";const cs=ids.map(id=>state.comments.find(c=>c.id===id)).filter(Boolean);const head=document.createElement("div");head.className="picker-head";head.textContent="Which comment?";picker.appendChild(head);for(const c of cs){const item=document.createElement("div");item.className="picker-item";const q=document.createElement("div");q.className="pq";q.textContent=c.quote;const n=document.createElement("div");n.className="pn";n.textContent=c.note;item.appendChild(q);item.appendChild(n);item.addEventListener("click",ev=>{ev.stopPropagation();openEditorFor(c.id,rect)});picker.appendChild(item)}picker.style.display="block";positionPopover(picker,rect)}
function closePicker(){picker.style.display="none"}
function renderComments(){
const sorted=[...state.comments].sort((a,b)=>a.start-b.start);
commentsEl.innerHTML="";commentsEmpty.style.display=sorted.length?"none":"";
countEl.textContent=sorted.length+(sorted.length===1?" comment":" comments");
for(const c of sorted){
const li=document.createElement("li");if(c.id===activeId)li.classList.add("active");
const q=document.createElement("div");q.className="quote";q.textContent=c.quote;
const n=document.createElement("div");n.className="note";n.textContent=c.note;
li.appendChild(q);li.appendChild(n);
li.addEventListener("click",()=>{const m=markForId(c.id);if(m)m.scrollIntoView({block:"center",behavior:"smooth"});openEditorFor(c.id,m?m.getBoundingClientRect():null)});
commentsEl.appendChild(li);
}
}
function contextFor(start,end){
const t=state.text;
const ls=t.lastIndexOf("\n",start-1)+1; // start of the selection's first line
let le=t.indexOf("\n",end);if(le===-1)le=t.length;
return {line:t.slice(ls,le).trim(), before:t.slice(Math.max(0,start-60),start), after:t.slice(end,end+60)};
}
function feedbackPayload(){
const sorted=[...state.comments].sort((a,b)=>a.start-b.start);
return {overall:overallEl.value.trim(), count:sorted.length,
comments:sorted.map(c=>{const x=contextFor(c.start,c.end);return {quote:c.quote, note:c.note||"", line:x.line, before:x.before, after:x.after};})};
}
function feedbackMarkdown(){
const p=feedbackPayload();const L=["## Feedback"];
if(p.overall)L.push("Overall: "+p.overall);L.push("");
if(!p.comments.length)L.push("_(no per-quote comments)_");
for(const c of p.comments){
if(c.line&&c.line!==c.quote)L.push("context: "+c.line);
L.push("> "+c.quote.replace(/\n/g,"\n> "));
L.push("— "+(c.note||"(no comment)"));L.push("");
}
return L.join("\n").trim()+"\n";
}
async function sendFeedback(){
try{
const res=await fetch("/feedback",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(feedbackPayload())});
if(res.ok)toast("Sent. Return to the assistant and tell it you're done.","ok");
else toast("Send failed — use Copy as Markdown instead","err");
}catch(e){toast("Send failed (server down?) — use Copy as Markdown instead","err")}
}
async function copyFeedback(){
const text=feedbackMarkdown();
try{await navigator.clipboard.writeText(text);toast("Copied as Markdown","ok")}
catch(e){const ta=document.createElement("textarea");ta.value=text;ta.style.position="fixed";ta.style.opacity="0";document.body.appendChild(ta);ta.select();try{document.execCommand("copy");toast("Copied as Markdown","ok")}catch(_){toast("Copy failed","err")}document.body.removeChild(ta)}
}
let toastTimer=null;
function toast(msg,cls){const t=$("toast");t.textContent=msg;t.className="toast show"+(cls?" "+cls:"");clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.className="toast",2400)}
async function init(){
let draft="";
try{const r=await fetch("/draft",{cache:"no-store"});draft=await r.text()}catch(e){draft=""}
state.text=draft;
storeKey=STORE_PREFIX+hash(draft);
loadComments(); // restores prior comments only if the draft hash matches
overallEl.value=state.overall;
renderText();renderComments();
overallEl.addEventListener("input",()=>{state.overall=overallEl.value;save()});
document.addEventListener("mouseup",onMouseUp);
$("editor-save").addEventListener("click",saveEditor);
$("editor-cancel").addEventListener("click",closeEditor);
$("editor-delete").addEventListener("click",deleteActive);
editorNote.addEventListener("keydown",e=>{if(e.key==="Enter"&&(e.metaKey||e.ctrlKey)){e.preventDefault();saveEditor()}if(e.key==="Escape"){e.preventDefault();closeEditor()}});
$("send").addEventListener("click",sendFeedback);
$("copy").addEventListener("click",copyFeedback);
$("clear").addEventListener("click",()=>{if(!confirm("Clear all comments?"))return;state.overall="";state.comments=[];save();overallEl.value="";renderComments();renderText()});
document.addEventListener("mousedown",e=>{
const onMark=e.target.tagName==="MARK";
if(editor.style.display==="block"&&!editor.contains(e.target)&&!onMark)closeEditor();
if(picker.style.display==="block"&&!picker.contains(e.target)&&!onMark)closePicker();
});
}
init();
})();
</script>
</body>
</html>