egregore-artifacts 0.9.8 → 0.9.9
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/lib/edit-client.js +866 -0
- package/lib/edit-shell.js +64 -0
- package/lib/edit-styles.js +537 -0
- package/lib/markdown.js +318 -32
- package/lib/templates/handoff-v1.js +57 -160
- package/package.json +1 -1
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
// Browser-side editor for /edit. Pure-JS (no framework), bootstrapped by
|
|
2
|
+
// edit-shell.js. Reads token + doc from URL params, fetches source-mapped
|
|
3
|
+
// HTML + sidecar from the loopback API, and provides the annotation UX:
|
|
4
|
+
//
|
|
5
|
+
// - Selection layer: drag-to-highlight → popup with classification + comment
|
|
6
|
+
// - Auto-classification: keyword-only, ambiguous → null (per Contract §6.3)
|
|
7
|
+
// - Sidebar: filterable + section-grouped list, threaded replies
|
|
8
|
+
// - "Send to session" composer: builds #egregore-edit:v1 payload, copies it
|
|
9
|
+
//
|
|
10
|
+
// Spec references:
|
|
11
|
+
// Contract §3 API surface
|
|
12
|
+
// Contract §2.2 sidecar / annotation schema
|
|
13
|
+
// Contract §4 source-map data attributes
|
|
14
|
+
// Contract §5 bearer token
|
|
15
|
+
// Contract §6.3 keyword-only auto-classification
|
|
16
|
+
//
|
|
17
|
+
// This file is shipped to the browser as a literal string (see edit-shell.js).
|
|
18
|
+
// `export function editClientScript()` returns the JS source so the shell can
|
|
19
|
+
// inline it inside <script>…</script>.
|
|
20
|
+
|
|
21
|
+
export function editClientScript() {
|
|
22
|
+
// The IIFE below is the actual browser code. Everything inside the template
|
|
23
|
+
// literal runs in the browser (not Node). Don't import anything in here.
|
|
24
|
+
return `
|
|
25
|
+
(function(){
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
// ── State ───────────────────────────────────────────────────────────
|
|
29
|
+
var state = {
|
|
30
|
+
token: null,
|
|
31
|
+
doc: null,
|
|
32
|
+
docEncoded: null,
|
|
33
|
+
sidecar: null, // {schemaVersion, doc, sessionId, annotations}
|
|
34
|
+
paragraphIndex: [], // from /api/edit/render
|
|
35
|
+
sourceText: '',
|
|
36
|
+
activeFilter: 'all', // 'all' | 'green' | 'yellow' | 'red' | 'null'
|
|
37
|
+
initiativeMode: 'review_each_yellow',
|
|
38
|
+
pendingSelection: null, // {anchor, snippet, range}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
var API = ''; // same-origin (the editor is served by view-server)
|
|
42
|
+
|
|
43
|
+
// ── Keyword auto-classification (Contract §6.3) ─────────────────────
|
|
44
|
+
// Exposed on window for the smoke test.
|
|
45
|
+
var KEYWORDS = {
|
|
46
|
+
green: ['keep','lands','love','great','nice','yes','good'],
|
|
47
|
+
yellow: ['tighten','unclear','needs work','rephrase','revise','expand'],
|
|
48
|
+
red: ['cut','drop','remove','redundant','delete','kill','no'],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function classifyByKeyword(text) {
|
|
52
|
+
if (!text) return null;
|
|
53
|
+
var lower = text.toLowerCase();
|
|
54
|
+
var hits = [];
|
|
55
|
+
for (var color in KEYWORDS) {
|
|
56
|
+
var list = KEYWORDS[color];
|
|
57
|
+
var matched = false;
|
|
58
|
+
for (var i = 0; i < list.length; i++) {
|
|
59
|
+
// word-ish boundary: surrounded by non-letters or string edges
|
|
60
|
+
var kw = list[i].toLowerCase();
|
|
61
|
+
var idx = lower.indexOf(kw);
|
|
62
|
+
while (idx !== -1) {
|
|
63
|
+
var before = idx === 0 ? ' ' : lower.charAt(idx - 1);
|
|
64
|
+
var after = idx + kw.length >= lower.length ? ' ' : lower.charAt(idx + kw.length);
|
|
65
|
+
if (!/[a-z0-9]/.test(before) && !/[a-z0-9]/.test(after)) { matched = true; break; }
|
|
66
|
+
idx = lower.indexOf(kw, idx + 1);
|
|
67
|
+
}
|
|
68
|
+
if (matched) break;
|
|
69
|
+
}
|
|
70
|
+
if (matched) hits.push(color);
|
|
71
|
+
}
|
|
72
|
+
if (hits.length === 1) return hits[0];
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
window.__egEditClassify = classifyByKeyword;
|
|
76
|
+
|
|
77
|
+
// ── DOM helpers ─────────────────────────────────────────────────────
|
|
78
|
+
function el(tag, attrs, children) {
|
|
79
|
+
var node = document.createElement(tag);
|
|
80
|
+
if (attrs) {
|
|
81
|
+
for (var k in attrs) {
|
|
82
|
+
if (k === 'class') node.className = attrs[k];
|
|
83
|
+
else if (k === 'style') node.setAttribute('style', attrs[k]);
|
|
84
|
+
else if (k === 'text') node.textContent = attrs[k];
|
|
85
|
+
else if (k === 'html') node.innerHTML = attrs[k];
|
|
86
|
+
else if (k.indexOf('on') === 0) node[k] = attrs[k];
|
|
87
|
+
else if (k.indexOf('data-') === 0 || k === 'role' || k === 'aria-label')
|
|
88
|
+
node.setAttribute(k, attrs[k]);
|
|
89
|
+
else node[k] = attrs[k];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (children) {
|
|
93
|
+
for (var i = 0; i < children.length; i++) {
|
|
94
|
+
var c = children[i];
|
|
95
|
+
if (c == null) continue;
|
|
96
|
+
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return node;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function $(sel) { return document.querySelector(sel); }
|
|
103
|
+
|
|
104
|
+
function escapeHtml(s) {
|
|
105
|
+
return String(s).replace(/[&<>"']/g, function(c) {
|
|
106
|
+
return { '&':'&','<':'<','>':'>','"':'"',"'":''' }[c];
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── HTTP ────────────────────────────────────────────────────────────
|
|
111
|
+
function api(method, pathname, opts) {
|
|
112
|
+
opts = opts || {};
|
|
113
|
+
var headers = { 'Authorization': 'Bearer ' + state.token };
|
|
114
|
+
if (opts.body) headers['Content-Type'] = 'application/json';
|
|
115
|
+
return fetch(API + pathname, {
|
|
116
|
+
method: method,
|
|
117
|
+
headers: headers,
|
|
118
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
119
|
+
}).then(function(r) {
|
|
120
|
+
return r.text().then(function(t) {
|
|
121
|
+
var json = null;
|
|
122
|
+
try { json = JSON.parse(t); } catch (e) { json = { raw: t }; }
|
|
123
|
+
if (!r.ok) {
|
|
124
|
+
var msg = (json && (json.error || json.code)) || ('HTTP ' + r.status);
|
|
125
|
+
var err = new Error(msg);
|
|
126
|
+
err.status = r.status;
|
|
127
|
+
err.body = json;
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
return json;
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Toast ───────────────────────────────────────────────────────────
|
|
136
|
+
var toastEl = null;
|
|
137
|
+
function toast(msg, isError) {
|
|
138
|
+
if (!toastEl) {
|
|
139
|
+
toastEl = el('div', { class: 'eg-edit-toast' });
|
|
140
|
+
document.body.appendChild(toastEl);
|
|
141
|
+
}
|
|
142
|
+
toastEl.textContent = msg;
|
|
143
|
+
toastEl.className = 'eg-edit-toast show' + (isError ? ' error' : '');
|
|
144
|
+
clearTimeout(toastEl._t);
|
|
145
|
+
toastEl._t = setTimeout(function() {
|
|
146
|
+
toastEl.classList.remove('show');
|
|
147
|
+
}, 2400);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Bootstrap ───────────────────────────────────────────────────────
|
|
151
|
+
function bootstrap() {
|
|
152
|
+
var params = new URLSearchParams(location.search);
|
|
153
|
+
state.token = params.get('token') || sessionStorage.getItem('eg-edit-token');
|
|
154
|
+
state.docEncoded = params.get('doc');
|
|
155
|
+
|
|
156
|
+
// /edit/<base64url> path form — fall back to extracting from the path
|
|
157
|
+
if (!state.docEncoded) {
|
|
158
|
+
var m = location.pathname.match(/^\\/edit\\/([A-Za-z0-9_-]+)$/);
|
|
159
|
+
if (m) state.docEncoded = m[1];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!state.token) {
|
|
163
|
+
showStatus('Missing session token. Re-run /edit <doc> in your terminal.', true);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!state.docEncoded) {
|
|
167
|
+
showStatus('Missing doc parameter.', true);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Persist token in sessionStorage (NOT localStorage — Contract §5.3)
|
|
172
|
+
sessionStorage.setItem('eg-edit-token', state.token);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
state.doc = atob(state.docEncoded.replace(/-/g, '+').replace(/_/g, '/'));
|
|
176
|
+
} catch (e) {
|
|
177
|
+
state.doc = '(invalid encoding)';
|
|
178
|
+
}
|
|
179
|
+
$('#eg-edit-title').textContent = state.doc;
|
|
180
|
+
|
|
181
|
+
loadAll();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function showStatus(msg, isError) {
|
|
185
|
+
var body = $('#eg-edit-body');
|
|
186
|
+
body.innerHTML = '';
|
|
187
|
+
body.appendChild(el('div', { class: 'eg-edit-status' + (isError ? ' error' : ''), text: msg }));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function loadAll() {
|
|
191
|
+
showStatus('Loading…');
|
|
192
|
+
return Promise.all([
|
|
193
|
+
api('GET', '/api/edit/render?doc=' + encodeURIComponent(state.docEncoded)),
|
|
194
|
+
api('GET', '/api/edit/annotations?doc=' + encodeURIComponent(state.docEncoded)),
|
|
195
|
+
]).then(function(results) {
|
|
196
|
+
var render = results[0];
|
|
197
|
+
var sidecar = results[1];
|
|
198
|
+
state.sourceText = render.sourceText || '';
|
|
199
|
+
state.paragraphIndex = render.paragraphIndex || [];
|
|
200
|
+
state.sidecar = sidecar;
|
|
201
|
+
renderBody(render.html);
|
|
202
|
+
renderSidebar();
|
|
203
|
+
decorateAnnotations();
|
|
204
|
+
}).catch(function(err) {
|
|
205
|
+
showStatus('Load failed: ' + err.message, true);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Body rendering ──────────────────────────────────────────────────
|
|
210
|
+
function renderBody(html) {
|
|
211
|
+
var body = $('#eg-edit-body');
|
|
212
|
+
body.innerHTML = html;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Selection layer ─────────────────────────────────────────────────
|
|
216
|
+
function findParagraphIndexEntry(paragraphId) {
|
|
217
|
+
for (var i = 0; i < state.paragraphIndex.length; i++) {
|
|
218
|
+
if (state.paragraphIndex[i].paragraphId === paragraphId) return state.paragraphIndex[i];
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function findAncestorWith(node, attr) {
|
|
224
|
+
var n = node;
|
|
225
|
+
while (n && n !== document.body) {
|
|
226
|
+
if (n.nodeType === 1 && n.hasAttribute && n.hasAttribute(attr)) return n;
|
|
227
|
+
n = n.parentNode;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Compute char offset of a Range start within a [data-para-id] node by
|
|
233
|
+
// walking text nodes. The paragraph index 'text' field gives the canonical
|
|
234
|
+
// source string; we compute offsets relative to the rendered text content,
|
|
235
|
+
// which mirrors source after the inline-markdown pass.
|
|
236
|
+
function offsetWithinParagraph(paraNode, range) {
|
|
237
|
+
if (!paraNode.contains(range.startContainer)) return null;
|
|
238
|
+
var walker = document.createTreeWalker(paraNode, NodeFilter.SHOW_TEXT, null);
|
|
239
|
+
var offset = 0;
|
|
240
|
+
var n;
|
|
241
|
+
while ((n = walker.nextNode())) {
|
|
242
|
+
if (n === range.startContainer) {
|
|
243
|
+
return offset + range.startOffset;
|
|
244
|
+
}
|
|
245
|
+
offset += n.nodeValue.length;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function selectionToAnchor() {
|
|
251
|
+
var sel = window.getSelection();
|
|
252
|
+
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null;
|
|
253
|
+
var range = sel.getRangeAt(0);
|
|
254
|
+
var snippet = sel.toString();
|
|
255
|
+
if (!snippet || !snippet.trim()) return null;
|
|
256
|
+
|
|
257
|
+
// Anchor is rooted at the START paragraph. Multi-paragraph selections are
|
|
258
|
+
// supported: the end gets clamped to the start paragraph's last char and
|
|
259
|
+
// the full snippet (across paragraphs) is preserved in the fingerprint.
|
|
260
|
+
// The agent processing the annotation has the fingerprint to recover the
|
|
261
|
+
// exact span; the anchor stays single-paragraph for storage simplicity.
|
|
262
|
+
var paraNode = findAncestorWith(range.startContainer, 'data-para-id');
|
|
263
|
+
if (!paraNode) return null;
|
|
264
|
+
|
|
265
|
+
var paragraphId = paraNode.getAttribute('data-para-id');
|
|
266
|
+
var sectionNode = findAncestorWith(paraNode, 'data-section') ||
|
|
267
|
+
findRecentSection(paraNode);
|
|
268
|
+
var section = sectionNode
|
|
269
|
+
? sectionNode.getAttribute('data-section')
|
|
270
|
+
: '§0. unsectioned';
|
|
271
|
+
|
|
272
|
+
var entry = findParagraphIndexEntry(paragraphId);
|
|
273
|
+
var paraText = entry ? entry.text : (paraNode.textContent || '');
|
|
274
|
+
|
|
275
|
+
var startOffset = offsetWithinParagraph(paraNode, range);
|
|
276
|
+
if (startOffset == null) return null;
|
|
277
|
+
|
|
278
|
+
// Compute end offset within the START paragraph. If the end is outside
|
|
279
|
+
// (multi-paragraph selection), clamp to paraText length.
|
|
280
|
+
var endOffset = (function() {
|
|
281
|
+
var walker = document.createTreeWalker(paraNode, NodeFilter.SHOW_TEXT, null);
|
|
282
|
+
var off = 0;
|
|
283
|
+
var n;
|
|
284
|
+
while ((n = walker.nextNode())) {
|
|
285
|
+
if (n === range.endContainer) return off + range.endOffset;
|
|
286
|
+
off += n.nodeValue.length;
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
})();
|
|
290
|
+
|
|
291
|
+
var spansMultipleParas = false;
|
|
292
|
+
if (endOffset == null) {
|
|
293
|
+
// End is in a different paragraph — clamp to end of start paragraph.
|
|
294
|
+
endOffset = paraText.length;
|
|
295
|
+
spansMultipleParas = true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (endOffset <= startOffset) {
|
|
299
|
+
// Defensive: if the end somehow precedes the start in the para,
|
|
300
|
+
// expand to end of paragraph.
|
|
301
|
+
endOffset = paraText.length;
|
|
302
|
+
if (endOffset <= startOffset) return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
var fingerprint = snippet.trim().slice(0, 64);
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
anchor: {
|
|
309
|
+
section: section,
|
|
310
|
+
paragraphId: paragraphId,
|
|
311
|
+
charRange: [startOffset, endOffset],
|
|
312
|
+
fingerprint: fingerprint,
|
|
313
|
+
},
|
|
314
|
+
snippet: snippet,
|
|
315
|
+
paraText: paraText,
|
|
316
|
+
range: range,
|
|
317
|
+
spansMultipleParas: spansMultipleParas,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Draft highlight overlay (persists while popup is open) ──────────
|
|
322
|
+
// The browser's live selection clears when the popup textarea takes focus,
|
|
323
|
+
// so we paint absolutely-positioned overlays over the selection rects.
|
|
324
|
+
// Standard annotation-tool pattern (Hypothesis, GitHub PR comments).
|
|
325
|
+
function paintDraftHighlight(range) {
|
|
326
|
+
var rects = range.getClientRects();
|
|
327
|
+
var overlays = [];
|
|
328
|
+
for (var i = 0; i < rects.length; i++) {
|
|
329
|
+
var r = rects[i];
|
|
330
|
+
if (r.width < 1 || r.height < 1) continue;
|
|
331
|
+
var div = document.createElement('div');
|
|
332
|
+
div.className = 'eg-edit-draft-highlight';
|
|
333
|
+
div.style.top = (r.top + window.scrollY) + 'px';
|
|
334
|
+
div.style.left = (r.left + window.scrollX) + 'px';
|
|
335
|
+
div.style.width = r.width + 'px';
|
|
336
|
+
div.style.height = r.height + 'px';
|
|
337
|
+
document.body.appendChild(div);
|
|
338
|
+
overlays.push(div);
|
|
339
|
+
}
|
|
340
|
+
return function clearHighlight() {
|
|
341
|
+
for (var i = 0; i < overlays.length; i++) {
|
|
342
|
+
if (overlays[i].parentNode) overlays[i].parentNode.removeChild(overlays[i]);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function findRecentSection(node) {
|
|
348
|
+
// Walk up + previousSibling to find the last [data-section] heading.
|
|
349
|
+
var n = node;
|
|
350
|
+
while (n && n !== document.body) {
|
|
351
|
+
var sib = n.previousElementSibling;
|
|
352
|
+
while (sib) {
|
|
353
|
+
if (sib.hasAttribute && sib.hasAttribute('data-section')) return sib;
|
|
354
|
+
var inner = sib.querySelector && sib.querySelector('[data-section]');
|
|
355
|
+
if (inner) return inner;
|
|
356
|
+
sib = sib.previousElementSibling;
|
|
357
|
+
}
|
|
358
|
+
n = n.parentNode;
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Popup ───────────────────────────────────────────────────────────
|
|
364
|
+
function showPopup(payload, anchorRect) {
|
|
365
|
+
closePopup();
|
|
366
|
+
state.pendingSelection = payload;
|
|
367
|
+
|
|
368
|
+
var popup = el('div', { class: 'eg-edit-popup', id: 'eg-edit-popup' });
|
|
369
|
+
|
|
370
|
+
popup.appendChild(el('div', { class: 'eg-edit-popup-snippet', text: payload.snippet }));
|
|
371
|
+
|
|
372
|
+
var textarea = el('textarea', {
|
|
373
|
+
class: 'eg-edit-popup-textarea',
|
|
374
|
+
placeholder: 'Add a comment (try keywords: cut, tighten, keep…)',
|
|
375
|
+
});
|
|
376
|
+
popup.appendChild(textarea);
|
|
377
|
+
|
|
378
|
+
var suggestNote = el('div', { class: 'eg-edit-popup-suggest', id: 'eg-edit-suggest', text: '' });
|
|
379
|
+
popup.appendChild(suggestNote);
|
|
380
|
+
|
|
381
|
+
var colorRow = el('div', { class: 'eg-edit-color-row' });
|
|
382
|
+
var selected = { color: undefined }; // undefined = nothing chosen yet
|
|
383
|
+
function makeColorBtn(color, label) {
|
|
384
|
+
var btn = el('button', { class: 'eg-edit-color-btn', 'data-color': color === null ? 'null' : color });
|
|
385
|
+
btn.appendChild(el('span', { class: 'eg-edit-color-dot' }));
|
|
386
|
+
btn.appendChild(document.createTextNode(label));
|
|
387
|
+
btn.onclick = function() {
|
|
388
|
+
selected.color = color;
|
|
389
|
+
// userOverride if user picks something different from the suggested
|
|
390
|
+
if (suggestion && color !== suggestion) suggestion = null;
|
|
391
|
+
Array.prototype.forEach.call(colorRow.children, function(c) { c.classList.remove('selected'); });
|
|
392
|
+
btn.classList.add('selected');
|
|
393
|
+
};
|
|
394
|
+
colorRow.appendChild(btn);
|
|
395
|
+
return btn;
|
|
396
|
+
}
|
|
397
|
+
var greenBtn = makeColorBtn('green', 'green');
|
|
398
|
+
var yellowBtn = makeColorBtn('yellow', 'yellow');
|
|
399
|
+
var redBtn = makeColorBtn('red', 'red');
|
|
400
|
+
var noColorBtn = makeColorBtn(null, 'no color');
|
|
401
|
+
popup.appendChild(colorRow);
|
|
402
|
+
|
|
403
|
+
var actions = el('div', { class: 'eg-edit-popup-actions' });
|
|
404
|
+
var cancel = el('button', { class: 'eg-edit-popup-cancel', text: 'Cancel' });
|
|
405
|
+
cancel.onclick = closePopup;
|
|
406
|
+
var save = el('button', { class: 'eg-edit-popup-save', text: 'Save' });
|
|
407
|
+
save.onclick = function() {
|
|
408
|
+
saveAnnotation(textarea.value.trim(), selected.color);
|
|
409
|
+
};
|
|
410
|
+
actions.appendChild(cancel);
|
|
411
|
+
actions.appendChild(save);
|
|
412
|
+
popup.appendChild(actions);
|
|
413
|
+
|
|
414
|
+
var suggestion = null;
|
|
415
|
+
var userOverrode = false;
|
|
416
|
+
|
|
417
|
+
textarea.addEventListener('input', function() {
|
|
418
|
+
var sug = classifyByKeyword(textarea.value);
|
|
419
|
+
// refresh suggestion display only if the user hasn't picked manually
|
|
420
|
+
Array.prototype.forEach.call(colorRow.children, function(c) {
|
|
421
|
+
c.classList.remove('suggested');
|
|
422
|
+
});
|
|
423
|
+
if (sug && selected.color === undefined) {
|
|
424
|
+
suggestion = sug;
|
|
425
|
+
// mark the matching button as suggested
|
|
426
|
+
for (var i = 0; i < colorRow.children.length; i++) {
|
|
427
|
+
if (colorRow.children[i].getAttribute('data-color') === sug) {
|
|
428
|
+
colorRow.children[i].classList.add('suggested');
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
suggestNote.textContent = 'suggested: ' + sug + ' (click to accept, or pick another)';
|
|
433
|
+
} else {
|
|
434
|
+
suggestion = null;
|
|
435
|
+
suggestNote.textContent = '';
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Paint the persistent draft-highlight overlay BEFORE focusing the
|
|
440
|
+
// textarea (which would clear the live selection).
|
|
441
|
+
var clearDraftHighlight = paintDraftHighlight(payload.range);
|
|
442
|
+
|
|
443
|
+
// Multi-paragraph hint in the snippet block
|
|
444
|
+
if (payload.spansMultipleParas) {
|
|
445
|
+
var hint = el('div', {
|
|
446
|
+
class: 'eg-edit-popup-multipara-hint',
|
|
447
|
+
text: 'Spans multiple paragraphs — anchor rooted at the start; the full selection is captured in the fingerprint.',
|
|
448
|
+
});
|
|
449
|
+
popup.insertBefore(hint, popup.children[1] || null);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Position the popup near the selection
|
|
453
|
+
document.body.appendChild(popup);
|
|
454
|
+
var top = anchorRect.bottom + window.scrollY + 6;
|
|
455
|
+
var left = Math.min(anchorRect.left + window.scrollX, window.innerWidth - 340);
|
|
456
|
+
popup.style.top = top + 'px';
|
|
457
|
+
popup.style.left = Math.max(8, left) + 'px';
|
|
458
|
+
|
|
459
|
+
// Defer focus so the live selection's removal doesn't visually flash —
|
|
460
|
+
// the draft-highlight overlay is already painted, so losing the live
|
|
461
|
+
// selection is now safe.
|
|
462
|
+
setTimeout(function() { textarea.focus(); }, 0);
|
|
463
|
+
|
|
464
|
+
// Stash so saveAnnotation can read suggestion state
|
|
465
|
+
popup._getMeta = function() {
|
|
466
|
+
return {
|
|
467
|
+
suggestion: suggestion,
|
|
468
|
+
userPicked: selected.color !== undefined,
|
|
469
|
+
chosen: selected.color,
|
|
470
|
+
};
|
|
471
|
+
};
|
|
472
|
+
popup._clearDraftHighlight = clearDraftHighlight;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function closePopup() {
|
|
476
|
+
var p = document.getElementById('eg-edit-popup');
|
|
477
|
+
if (p) {
|
|
478
|
+
if (typeof p._clearDraftHighlight === 'function') {
|
|
479
|
+
try { p._clearDraftHighlight(); } catch (_) {}
|
|
480
|
+
}
|
|
481
|
+
p.remove();
|
|
482
|
+
}
|
|
483
|
+
state.pendingSelection = null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function saveAnnotation(comment, chosenColor) {
|
|
487
|
+
if (!state.pendingSelection) return;
|
|
488
|
+
if (!comment) {
|
|
489
|
+
toast('Comment required', true);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
var popup = document.getElementById('eg-edit-popup');
|
|
493
|
+
var meta = popup && popup._getMeta ? popup._getMeta() : { suggestion: null, userPicked: false, chosen: chosenColor };
|
|
494
|
+
|
|
495
|
+
var classification;
|
|
496
|
+
var autoClassified = false;
|
|
497
|
+
if (meta.userPicked) {
|
|
498
|
+
classification = meta.chosen; // null if "no color" pressed
|
|
499
|
+
// If the user picked the suggested color exactly, autoClassified = true
|
|
500
|
+
if (meta.suggestion && meta.chosen === meta.suggestion) autoClassified = true;
|
|
501
|
+
} else {
|
|
502
|
+
// No manual pick — fall back to keyword classifier (may be null)
|
|
503
|
+
var sug = classifyByKeyword(comment);
|
|
504
|
+
classification = sug;
|
|
505
|
+
autoClassified = !!sug;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
var anchor = state.pendingSelection.anchor;
|
|
509
|
+
var fingerprint = anchor.fingerprint || (state.pendingSelection.snippet || '').trim().slice(0, 64);
|
|
510
|
+
var body = {
|
|
511
|
+
selection: { ...anchor, fingerprint: fingerprint },
|
|
512
|
+
classification: classification,
|
|
513
|
+
autoClassified: autoClassified,
|
|
514
|
+
comment: comment,
|
|
515
|
+
author: window.__egEditAuthor || 'browser-user',
|
|
516
|
+
status: 'active',
|
|
517
|
+
thread: [],
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
api('POST', '/api/edit/annotations?doc=' + encodeURIComponent(state.docEncoded), { body: body })
|
|
521
|
+
.then(function(res) {
|
|
522
|
+
toast('Saved');
|
|
523
|
+
closePopup();
|
|
524
|
+
// Refresh sidecar only (HTML hasn't changed)
|
|
525
|
+
return api('GET', '/api/edit/annotations?doc=' + encodeURIComponent(state.docEncoded));
|
|
526
|
+
})
|
|
527
|
+
.then(function(sidecar) {
|
|
528
|
+
if (sidecar) {
|
|
529
|
+
state.sidecar = sidecar;
|
|
530
|
+
renderSidebar();
|
|
531
|
+
decorateAnnotations();
|
|
532
|
+
}
|
|
533
|
+
})
|
|
534
|
+
.catch(function(err) {
|
|
535
|
+
toast('Save failed: ' + err.message, true);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ── Selection trigger ───────────────────────────────────────────────
|
|
540
|
+
document.addEventListener('mouseup', function(e) {
|
|
541
|
+
// Skip if click is inside the popup itself
|
|
542
|
+
if (e.target.closest && e.target.closest('.eg-edit-popup')) return;
|
|
543
|
+
if (e.target.closest && e.target.closest('.eg-edit-sidebar')) return;
|
|
544
|
+
if (e.target.closest && e.target.closest('.eg-edit-topbar')) return;
|
|
545
|
+
setTimeout(function() {
|
|
546
|
+
var payload = selectionToAnchor();
|
|
547
|
+
if (!payload) {
|
|
548
|
+
// If user clicked elsewhere with nothing selected, close popup
|
|
549
|
+
if (!window.getSelection().toString()) closePopup();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
var rect = payload.range.getBoundingClientRect();
|
|
553
|
+
showPopup(payload, rect);
|
|
554
|
+
}, 0);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
document.addEventListener('keydown', function(e) {
|
|
558
|
+
if (e.key === 'Escape') closePopup();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// ── Sidebar ─────────────────────────────────────────────────────────
|
|
562
|
+
function renderSidebar() {
|
|
563
|
+
var list = $('#eg-edit-sidebar-list');
|
|
564
|
+
list.innerHTML = '';
|
|
565
|
+
var anns = (state.sidecar && state.sidecar.annotations) || [];
|
|
566
|
+
|
|
567
|
+
// Filter
|
|
568
|
+
var filter = state.activeFilter;
|
|
569
|
+
if (filter !== 'all') {
|
|
570
|
+
anns = anns.filter(function(a) {
|
|
571
|
+
if (filter === 'null') return a.classification == null;
|
|
572
|
+
return a.classification === filter;
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (anns.length === 0) {
|
|
577
|
+
list.appendChild(el('div', { class: 'eg-edit-status', text: 'No annotations yet. Highlight text to add one.' }));
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Group by section
|
|
582
|
+
var groups = {};
|
|
583
|
+
var sectionOrder = [];
|
|
584
|
+
anns.forEach(function(a) {
|
|
585
|
+
var s = (a.selection && a.selection.section) || '§0. unsectioned';
|
|
586
|
+
if (!groups[s]) { groups[s] = []; sectionOrder.push(s); }
|
|
587
|
+
groups[s].push(a);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
sectionOrder.forEach(function(section) {
|
|
591
|
+
var group = el('div', { class: 'eg-edit-section-group' });
|
|
592
|
+
group.appendChild(el('div', { class: 'eg-edit-section-label', text: section }));
|
|
593
|
+
groups[section].forEach(function(a) {
|
|
594
|
+
group.appendChild(renderAnnotationCard(a));
|
|
595
|
+
});
|
|
596
|
+
list.appendChild(group);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function renderAnnotationCard(a) {
|
|
601
|
+
var color = a.classification == null ? 'null' : a.classification;
|
|
602
|
+
var classes = ['eg-edit-ann-card'];
|
|
603
|
+
if (a.status === 'orphaned') classes.push('eg-orphaned');
|
|
604
|
+
|
|
605
|
+
var card = el('div', {
|
|
606
|
+
class: classes.join(' '),
|
|
607
|
+
'data-color': color,
|
|
608
|
+
'data-ann-id': a.id,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
var meta = el('div', { class: 'eg-edit-ann-meta' });
|
|
612
|
+
meta.appendChild(el('span', { class: 'eg-edit-color-badge', 'data-color': color }));
|
|
613
|
+
meta.appendChild(el('span', { class: 'eg-edit-ann-author', text: a.author || '?' }));
|
|
614
|
+
var statusText = a.status === 'orphaned' ? 'orphaned · approximate' : a.status;
|
|
615
|
+
meta.appendChild(el('span', { class: 'eg-edit-status-badge', 'data-status': a.status, text: statusText }));
|
|
616
|
+
card.appendChild(meta);
|
|
617
|
+
|
|
618
|
+
var snippet = (a.selection && a.selection.fingerprint) || '';
|
|
619
|
+
if (snippet) {
|
|
620
|
+
card.appendChild(el('div', { class: 'eg-edit-ann-snippet', text: '"' + snippet.slice(0, 40) + (snippet.length > 40 ? '…' : '') + '"' }));
|
|
621
|
+
}
|
|
622
|
+
card.appendChild(el('div', { class: 'eg-edit-ann-comment', text: a.comment || '' }));
|
|
623
|
+
|
|
624
|
+
var threadCount = (a.thread || []).length;
|
|
625
|
+
card.appendChild(el('div', { class: 'eg-edit-ann-thread-count', text: threadCount + ' repl' + (threadCount === 1 ? 'y' : 'ies') }));
|
|
626
|
+
|
|
627
|
+
// Thread block (toggled on expand)
|
|
628
|
+
var thread = el('div', { class: 'eg-edit-thread' });
|
|
629
|
+
(a.thread || []).forEach(function(r) {
|
|
630
|
+
var reply = el('div', { class: 'eg-edit-reply' });
|
|
631
|
+
reply.appendChild(el('div', { class: 'eg-edit-reply-meta', text: (r.author || '?') + ' · ' + (r.kind || '') + ' · ' + ((r.createdAt || '').slice(0, 19)) }));
|
|
632
|
+
reply.appendChild(el('div', { class: 'eg-edit-reply-text', text: r.text || '' }));
|
|
633
|
+
thread.appendChild(reply);
|
|
634
|
+
});
|
|
635
|
+
var form = el('div', { class: 'eg-edit-reply-form' });
|
|
636
|
+
var input = el('input', { class: 'eg-edit-reply-input', type: 'text', placeholder: 'Reply…' });
|
|
637
|
+
var send = el('button', { class: 'eg-edit-reply-btn', text: 'Reply' });
|
|
638
|
+
send.onclick = function(ev) {
|
|
639
|
+
ev.stopPropagation();
|
|
640
|
+
var text = input.value.trim();
|
|
641
|
+
if (!text) return;
|
|
642
|
+
postReply(a.id, text).then(function() { input.value = ''; });
|
|
643
|
+
};
|
|
644
|
+
input.onkeydown = function(ev) {
|
|
645
|
+
ev.stopPropagation();
|
|
646
|
+
if (ev.key === 'Enter') { send.click(); }
|
|
647
|
+
};
|
|
648
|
+
input.onclick = function(ev) { ev.stopPropagation(); };
|
|
649
|
+
form.appendChild(input);
|
|
650
|
+
form.appendChild(send);
|
|
651
|
+
thread.appendChild(form);
|
|
652
|
+
card.appendChild(thread);
|
|
653
|
+
|
|
654
|
+
card.onclick = function() {
|
|
655
|
+
// Expand thread
|
|
656
|
+
var alreadyExpanded = card.classList.contains('expanded');
|
|
657
|
+
// Collapse all other expanded
|
|
658
|
+
Array.prototype.forEach.call(document.querySelectorAll('.eg-edit-ann-card.expanded'), function(c) {
|
|
659
|
+
if (c !== card) c.classList.remove('expanded');
|
|
660
|
+
});
|
|
661
|
+
if (!alreadyExpanded) card.classList.add('expanded');
|
|
662
|
+
|
|
663
|
+
// Scroll body to the anchor
|
|
664
|
+
scrollToAnnotation(a);
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
return card;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function postReply(annId, text) {
|
|
671
|
+
var body = {
|
|
672
|
+
author: window.__egEditAuthor || 'browser-user',
|
|
673
|
+
kind: 'discussion',
|
|
674
|
+
text: text,
|
|
675
|
+
};
|
|
676
|
+
return api('POST', '/api/edit/replies?doc=' + encodeURIComponent(state.docEncoded) + '&id=' + encodeURIComponent(annId), { body: body })
|
|
677
|
+
.then(function() {
|
|
678
|
+
return api('GET', '/api/edit/annotations?doc=' + encodeURIComponent(state.docEncoded));
|
|
679
|
+
})
|
|
680
|
+
.then(function(sidecar) {
|
|
681
|
+
state.sidecar = sidecar;
|
|
682
|
+
renderSidebar();
|
|
683
|
+
// Re-expand the card we just replied to
|
|
684
|
+
var card = document.querySelector('[data-ann-id="' + annId + '"]');
|
|
685
|
+
if (card) card.classList.add('expanded');
|
|
686
|
+
})
|
|
687
|
+
.catch(function(err) { toast('Reply failed: ' + err.message, true); });
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function scrollToAnnotation(a) {
|
|
691
|
+
var paraId = a.selection && a.selection.paragraphId;
|
|
692
|
+
if (!paraId) return;
|
|
693
|
+
var node = document.querySelector('#eg-edit-body [data-para-id="' + cssEscape(paraId) + '"]');
|
|
694
|
+
if (!node) return;
|
|
695
|
+
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
696
|
+
// Find the highlight span if it's been decorated and flash it
|
|
697
|
+
var hl = node.querySelector('.eg-edit-highlight[data-ann-id="' + cssEscape(a.id) + '"]');
|
|
698
|
+
if (hl) {
|
|
699
|
+
hl.classList.remove('eg-flash');
|
|
700
|
+
void hl.offsetWidth; // restart animation
|
|
701
|
+
hl.classList.add('eg-flash');
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function cssEscape(s) {
|
|
706
|
+
if (window.CSS && window.CSS.escape) return window.CSS.escape(s);
|
|
707
|
+
return String(s).replace(/[^a-zA-Z0-9_-]/g, function(c) { return '\\\\' + c; });
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ── Decorate body with highlight spans ──────────────────────────────
|
|
711
|
+
function decorateAnnotations() {
|
|
712
|
+
// Strip any existing highlights first
|
|
713
|
+
Array.prototype.forEach.call(document.querySelectorAll('#eg-edit-body .eg-edit-highlight'), function(el) {
|
|
714
|
+
var parent = el.parentNode;
|
|
715
|
+
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
|
716
|
+
parent.removeChild(el);
|
|
717
|
+
parent.normalize();
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
var anns = (state.sidecar && state.sidecar.annotations) || [];
|
|
721
|
+
anns.forEach(function(a) {
|
|
722
|
+
if (a.status === 'discarded') return;
|
|
723
|
+
var paraId = a.selection && a.selection.paragraphId;
|
|
724
|
+
if (!paraId) return;
|
|
725
|
+
var node = document.querySelector('#eg-edit-body [data-para-id="' + cssEscape(paraId) + '"]');
|
|
726
|
+
if (!node) return;
|
|
727
|
+
try { wrapRange(node, a); } catch (e) { /* skip on failure */ }
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function wrapRange(paraNode, ann) {
|
|
732
|
+
var range = ann.selection.charRange;
|
|
733
|
+
if (!range) return;
|
|
734
|
+
var start = range[0], end = range[1];
|
|
735
|
+
var walker = document.createTreeWalker(paraNode, NodeFilter.SHOW_TEXT, null);
|
|
736
|
+
var off = 0;
|
|
737
|
+
var startNode = null, startOff = 0, endNode = null, endOff = 0;
|
|
738
|
+
var n;
|
|
739
|
+
while ((n = walker.nextNode())) {
|
|
740
|
+
var nlen = n.nodeValue.length;
|
|
741
|
+
if (!startNode && off + nlen >= start) {
|
|
742
|
+
startNode = n;
|
|
743
|
+
startOff = start - off;
|
|
744
|
+
}
|
|
745
|
+
if (!endNode && off + nlen >= end) {
|
|
746
|
+
endNode = n;
|
|
747
|
+
endOff = end - off;
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
off += nlen;
|
|
751
|
+
}
|
|
752
|
+
if (!startNode || !endNode) return;
|
|
753
|
+
|
|
754
|
+
var r = document.createRange();
|
|
755
|
+
r.setStart(startNode, startOff);
|
|
756
|
+
r.setEnd(endNode, endOff);
|
|
757
|
+
|
|
758
|
+
var span = document.createElement('span');
|
|
759
|
+
span.className = 'eg-edit-highlight';
|
|
760
|
+
span.setAttribute('data-color', ann.classification == null ? 'null' : ann.classification);
|
|
761
|
+
span.setAttribute('data-ann-id', ann.id);
|
|
762
|
+
span.title = (ann.author || '') + ': ' + (ann.comment || '');
|
|
763
|
+
try {
|
|
764
|
+
r.surroundContents(span);
|
|
765
|
+
} catch (e) {
|
|
766
|
+
// Range crosses element boundaries — extract and wrap
|
|
767
|
+
var frag = r.extractContents();
|
|
768
|
+
span.appendChild(frag);
|
|
769
|
+
r.insertNode(span);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ── Filter buttons ──────────────────────────────────────────────────
|
|
774
|
+
function setupFilterBar() {
|
|
775
|
+
var bar = $('#eg-edit-filter');
|
|
776
|
+
var buttons = bar.querySelectorAll('.eg-edit-filter-btn');
|
|
777
|
+
Array.prototype.forEach.call(buttons, function(btn) {
|
|
778
|
+
btn.onclick = function() {
|
|
779
|
+
Array.prototype.forEach.call(buttons, function(b) { b.classList.remove('active'); });
|
|
780
|
+
btn.classList.add('active');
|
|
781
|
+
state.activeFilter = btn.getAttribute('data-color') || 'all';
|
|
782
|
+
renderSidebar();
|
|
783
|
+
};
|
|
784
|
+
});
|
|
785
|
+
bar.querySelector('[data-color="all"]').classList.add('active');
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ── Refresh button ──────────────────────────────────────────────────
|
|
789
|
+
function setupRefresh() {
|
|
790
|
+
$('#eg-edit-refresh').onclick = function() {
|
|
791
|
+
loadAll();
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ── Initiative-mode toggle ──────────────────────────────────────────
|
|
796
|
+
function setupModeToggle() {
|
|
797
|
+
var inputs = document.querySelectorAll('input[name="eg-init-mode"]');
|
|
798
|
+
Array.prototype.forEach.call(inputs, function(inp) {
|
|
799
|
+
inp.onchange = function() {
|
|
800
|
+
if (inp.checked) state.initiativeMode = inp.value;
|
|
801
|
+
};
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ── Send to session ─────────────────────────────────────────────────
|
|
806
|
+
function setupSendButton() {
|
|
807
|
+
$('#eg-edit-send').onclick = function() {
|
|
808
|
+
var anns = (state.sidecar && state.sidecar.annotations) || [];
|
|
809
|
+
// Active annotations only (skip discarded/accepted)
|
|
810
|
+
var active = anns.filter(function(a) { return a.status === 'active' || a.status === 'orphaned'; });
|
|
811
|
+
if (active.length === 0) {
|
|
812
|
+
toast('No active annotations to send', true);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
var sessionId = (state.sidecar && state.sidecar.sessionId) || '';
|
|
816
|
+
var bundle = {
|
|
817
|
+
schemaVersion: 1,
|
|
818
|
+
docPath: state.doc,
|
|
819
|
+
sessionId: sessionId,
|
|
820
|
+
deliveredAt: new Date().toISOString(),
|
|
821
|
+
initiativeMode: state.initiativeMode,
|
|
822
|
+
annotations: active,
|
|
823
|
+
};
|
|
824
|
+
var marker = '#egregore-edit:v1 ' + state.doc;
|
|
825
|
+
var payload = marker + '\\n\\n' + JSON.stringify(bundle, null, 2);
|
|
826
|
+
copyToClipboard(payload).then(function() {
|
|
827
|
+
toast('Copied. Paste into your terminal session.');
|
|
828
|
+
}, function() {
|
|
829
|
+
toast('Copy failed — select payload manually.', true);
|
|
830
|
+
});
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function copyToClipboard(text) {
|
|
835
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
836
|
+
return navigator.clipboard.writeText(text);
|
|
837
|
+
}
|
|
838
|
+
return new Promise(function(resolve, reject) {
|
|
839
|
+
try {
|
|
840
|
+
var ta = document.createElement('textarea');
|
|
841
|
+
ta.value = text;
|
|
842
|
+
ta.style.position = 'fixed';
|
|
843
|
+
ta.style.opacity = '0';
|
|
844
|
+
document.body.appendChild(ta);
|
|
845
|
+
ta.select();
|
|
846
|
+
var ok = document.execCommand('copy');
|
|
847
|
+
document.body.removeChild(ta);
|
|
848
|
+
ok ? resolve() : reject(new Error('execCommand failed'));
|
|
849
|
+
} catch (e) { reject(e); }
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ── Init ────────────────────────────────────────────────────────────
|
|
854
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
855
|
+
setupFilterBar();
|
|
856
|
+
setupRefresh();
|
|
857
|
+
setupSendButton();
|
|
858
|
+
setupModeToggle();
|
|
859
|
+
bootstrap();
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// Expose for tests / dev
|
|
863
|
+
window.__egEditState = state;
|
|
864
|
+
})();
|
|
865
|
+
`;
|
|
866
|
+
}
|