@panphora/clayjs 1.3.0 → 1.4.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/README.md +3 -2
- package/dist/clay.standalone.js +1595 -828
- package/entries/sap.js +1 -1
- package/package.json +1 -1
- package/src/core/admin-contenteditable.js +4 -1
- package/src/core/admin-inputs.js +23 -13
- package/src/core/admin-onclick.js +5 -0
- package/src/core/is-edit-mode.js +7 -2
- package/src/core/save.js +13 -1
- package/src/core/snapshot.js +44 -1
- package/src/core/source-map.js +246 -38
- package/src/loader-logic.js +8 -2
- package/src/loader.js +2 -0
- package/src/plugins/ai-edit.js +625 -0
- package/src/plugins/source.js +150 -66
- package/src/sync/live-sync.js +3 -0
- package/src/vendor/control-serialize.vendor.js +10 -6
- package/src/vendor/hyper-morph.vendor.js +2 -2
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ai-edit — comment-to-edit AI editing over clay.wire.
|
|
3
|
+
*
|
|
4
|
+
* Direct editing stays primary: text units carry editmode:contenteditable, so a
|
|
5
|
+
* click places the caret and a figure keeps its own interactivity. This adds the
|
|
6
|
+
* comment box on demand: a floating chip near the hovered unit, Cmd+K for the unit
|
|
7
|
+
* holding the caret (the selection rides along as a quote), a click on bare section
|
|
8
|
+
* padding, or the fixed bottom-right bubble for the whole document. Units resolve to
|
|
9
|
+
* the nearest h1-h6/p/figure inside a [data-edit-id] section, falling back to the
|
|
10
|
+
* section itself.
|
|
11
|
+
*
|
|
12
|
+
* The request leaves as a named helper request on the wire
|
|
13
|
+
* (`clay.wire.send(payload, { helper: "ai-edit" })`) and comes back as the edited
|
|
14
|
+
* element's own HTML. A named request defaults to `document: "none"`, so the host
|
|
15
|
+
* writes nothing: this page morphs the result in and Keep saves it through
|
|
16
|
+
* `clay.save()`, exactly as before.
|
|
17
|
+
*
|
|
18
|
+
* Dormant unless the host lists a ready `ai-edit` helper. On HTML Clay, or with the
|
|
19
|
+
* desktop toggle off, `clay.wire.helpers()` never lists one and nothing is built at
|
|
20
|
+
* all: the page is then just a page.
|
|
21
|
+
*
|
|
22
|
+
* The bus version previewed the reply as it streamed. The wire carries status lines
|
|
23
|
+
* only — replaceable progress, capped and droppable — so the page shows throttled
|
|
24
|
+
* status lines ("Writing, 1.8 KB") and then the result once, with Keep/Revert as
|
|
25
|
+
* before. `@page` reads the saved file from disk rather than a copy in the payload:
|
|
26
|
+
* the plugin saves first and then sends `{ page: true }`, because a whole page never
|
|
27
|
+
* fits the envelope.
|
|
28
|
+
*
|
|
29
|
+
* Undo integration: observers pause at request start. On Keep the element is rewound
|
|
30
|
+
* to the snapshot while still paused, observers resume, then one final morph lands
|
|
31
|
+
* the whole AI edit as a single undoable step. On Revert the rewind happens under
|
|
32
|
+
* pause, so history never sees the edit at all.
|
|
33
|
+
*
|
|
34
|
+
* All injected chrome is runtime-only (`no-save no-watch no-snapshot`): never saved,
|
|
35
|
+
* invisible to the mutation system (its status text changes constantly), absent from
|
|
36
|
+
* every snapshot and from what peers receive.
|
|
37
|
+
*/
|
|
38
|
+
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
39
|
+
import { mergeTagRecognizers } from "../sync/merge-tags.js";
|
|
40
|
+
import Mutation from "../lib/mutation.js";
|
|
41
|
+
import { isEditMode } from "../core/is-edit-mode.js";
|
|
42
|
+
import { STRIP_FROM_SAVE } from "../lib/region-policy.js";
|
|
43
|
+
import { enableContentEditable } from "../core/admin-contenteditable.js";
|
|
44
|
+
import onDomReady from "../lib/dom-ready.js";
|
|
45
|
+
import wire from "./wire.js";
|
|
46
|
+
|
|
47
|
+
const HELPER = "ai-edit";
|
|
48
|
+
const UNIT_SELECTOR = "h1,h2,h3,h4,h5,h6,p,figure";
|
|
49
|
+
const RUNTIME_ONLY = "no-save no-watch no-snapshot";
|
|
50
|
+
// The request ceiling, kept below the host's 1 MiB envelope so the refusal happens
|
|
51
|
+
// here, where the message can say what to do about it.
|
|
52
|
+
const MAX_PAYLOAD_BYTES = 900 * 1024;
|
|
53
|
+
const TOO_LARGE = "This section is too large for AI editing; select a smaller part.";
|
|
54
|
+
|
|
55
|
+
let requestCounter = 0;
|
|
56
|
+
let session = null; // one edit at a time
|
|
57
|
+
let panel, ring, textarea, quoteEl, statusEl, warningsEl, chip, docBubble;
|
|
58
|
+
let buttons = {};
|
|
59
|
+
let anchorEl = null; // element the panel is currently anchored to
|
|
60
|
+
let chipTarget = null; // unit the hover chip currently points at
|
|
61
|
+
let chipHideTimer = null;
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------- transport
|
|
64
|
+
|
|
65
|
+
async function available() {
|
|
66
|
+
const list = await wire.helpers();
|
|
67
|
+
return list.some(h => h.name === HELPER && h.state === "ready");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------- observers (undo/autosave)
|
|
71
|
+
|
|
72
|
+
function pauseObservers() {
|
|
73
|
+
Mutation.pause();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resumeObservers() {
|
|
77
|
+
Mutation.resume();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------- units
|
|
81
|
+
|
|
82
|
+
// The editable unit for an interaction: nearest heading/paragraph/figure inside a
|
|
83
|
+
// [data-edit-id] section, else the section itself.
|
|
84
|
+
function unitFrom(target) {
|
|
85
|
+
if (!(target instanceof Element)) return null;
|
|
86
|
+
const section = target.closest('[data-edit-id]');
|
|
87
|
+
if (!section) return null;
|
|
88
|
+
const unit = target.closest(UNIT_SELECTOR);
|
|
89
|
+
return unit && section.contains(unit) ? unit : section;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function editLabel(el) {
|
|
93
|
+
if (el === document.body) return 'document';
|
|
94
|
+
const own = el.getAttribute('data-edit-id');
|
|
95
|
+
if (own) return own;
|
|
96
|
+
const section = el.closest('[data-edit-id]');
|
|
97
|
+
return (section ? section.getAttribute('data-edit-id') + ' \u203a ' : '') + el.tagName.toLowerCase();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function quoteFromSelection(scope) {
|
|
101
|
+
const selection = window.getSelection();
|
|
102
|
+
if (!selection || selection.isCollapsed) return undefined;
|
|
103
|
+
const text = selection.toString().trim();
|
|
104
|
+
if (!text) return undefined;
|
|
105
|
+
let node = selection.getRangeAt(0).commonAncestorContainer;
|
|
106
|
+
if (node.nodeType !== 1) node = node.parentElement;
|
|
107
|
+
if (scope && scope !== document.body && !scope.contains(node)) return undefined;
|
|
108
|
+
return text.length > 400 ? text.slice(0, 400) + '\u2026' : text;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function strippedBodyHTML() {
|
|
112
|
+
const clone = document.body.cloneNode(true);
|
|
113
|
+
clone.querySelectorAll(STRIP_FROM_SAVE).forEach(node => node.remove());
|
|
114
|
+
return clone.outerHTML;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------- morphing
|
|
118
|
+
|
|
119
|
+
function morphTo(el, content, scripts) {
|
|
120
|
+
return HyperMorph.morph(el, content, { morphStyle: 'outerHTML', restoreFocus: true, scripts });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Session-aware morph. Document mode morphs the whole body but vetoes removal of the
|
|
124
|
+
// chrome this plugin injected (the model never saw it, so a plain morph would delete
|
|
125
|
+
// the panel mid-flight). Either way, re-run the contenteditable pass afterwards: a
|
|
126
|
+
// reply may drop the runtime attribute.
|
|
127
|
+
// Forward morphs three-way merge mergeable script tags ([merge] + rules tags)
|
|
128
|
+
// against the snapshot the model saw, so data the user changed mid-flight survives
|
|
129
|
+
// the reply. Rewinds must restore the snapshot exactly, so they disable merging.
|
|
130
|
+
function sessionMorph(s, content, { rewind = false } = {}) {
|
|
131
|
+
const scripts = rewind
|
|
132
|
+
? { merge: false }
|
|
133
|
+
: { mergeBase: s.snapshot, mergeTags: mergeTagRecognizers };
|
|
134
|
+
if (s.docMode) {
|
|
135
|
+
if (typeof content === 'string') {
|
|
136
|
+
// Parse to an element ourselves: hyper-morph parses a body string into a
|
|
137
|
+
// full document and would insert DOMParser's synthesized empty <head>
|
|
138
|
+
// alongside the morphed body. An element gets sibling-free treatment.
|
|
139
|
+
content = new DOMParser().parseFromString(content, 'text/html').body;
|
|
140
|
+
}
|
|
141
|
+
HyperMorph.morph(document.body, content, {
|
|
142
|
+
morphStyle: 'outerHTML',
|
|
143
|
+
restoreFocus: true,
|
|
144
|
+
scripts,
|
|
145
|
+
callbacks: {
|
|
146
|
+
beforeNodeRemoved: node => !(node.nodeType === 1 && node.matches(STRIP_FROM_SAVE))
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
morphTo(s.el, content, scripts);
|
|
151
|
+
}
|
|
152
|
+
enableContentEditable();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Extract a morphable single-root candidate from a (possibly partial, possibly
|
|
156
|
+
// fenced) reply. Returns an inert element or null. Template parsing auto-closes
|
|
157
|
+
// half-open tags and never executes scripts — but silently strips <body> tags, so a
|
|
158
|
+
// body candidate parses via DOMParser (equally inert).
|
|
159
|
+
function candidateFrom(html, tag) {
|
|
160
|
+
const start = html.search(new RegExp('<' + tag + '(\\s|>)', 'i'));
|
|
161
|
+
if (start === -1) return null;
|
|
162
|
+
const cleaned = html.slice(start).replace(/\s*`{1,3}\s*$/, '');
|
|
163
|
+
if (tag.toLowerCase() === 'body') {
|
|
164
|
+
return new DOMParser().parseFromString(cleaned, 'text/html').body;
|
|
165
|
+
}
|
|
166
|
+
const template = document.createElement('template');
|
|
167
|
+
template.innerHTML = cleaned;
|
|
168
|
+
const candidate = template.content.firstElementChild;
|
|
169
|
+
if (!candidate || candidate.tagName.toLowerCase() !== tag.toLowerCase()) return null;
|
|
170
|
+
return candidate;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------- session lifecycle
|
|
174
|
+
|
|
175
|
+
function newSession(el, comment, quote) {
|
|
176
|
+
const docMode = el === document.body;
|
|
177
|
+
// Context refs are @tokens containing a dot or slash (@notes.md, @src/x.js). Bare
|
|
178
|
+
// @words are not refs: a leading one is an engine token (@fable, @codex — routed
|
|
179
|
+
// helper-side), and mid-text ones are prose.
|
|
180
|
+
const contextRefs = [...comment.matchAll(/@([\w.-]*[/.][\w./-]*)/g)]
|
|
181
|
+
.map(m => m[1])
|
|
182
|
+
.filter(ref => ref !== 'page');
|
|
183
|
+
const elementHTML = docMode ? strippedBodyHTML() : el.outerHTML;
|
|
184
|
+
const payload = {
|
|
185
|
+
id: 'req-' + Date.now().toString(36) + '-' + (++requestCounter),
|
|
186
|
+
editId: editLabel(el),
|
|
187
|
+
tag: el.tagName.toLowerCase(),
|
|
188
|
+
elementHTML,
|
|
189
|
+
comment,
|
|
190
|
+
contextRefs
|
|
191
|
+
};
|
|
192
|
+
if (quote) payload.quote = quote;
|
|
193
|
+
// Document mode reads the live page itself, and every other @page request reads
|
|
194
|
+
// the file from disk: the plugin saves first, then asks for it by flag.
|
|
195
|
+
if (!docMode && /@page\b/.test(comment)) payload.page = true;
|
|
196
|
+
return {
|
|
197
|
+
id: payload.id,
|
|
198
|
+
el,
|
|
199
|
+
docMode,
|
|
200
|
+
tag: payload.tag,
|
|
201
|
+
reassertId: docMode ? null : el.getAttribute('data-edit-id'),
|
|
202
|
+
snapshot: elementHTML,
|
|
203
|
+
payload,
|
|
204
|
+
state: 'requesting',
|
|
205
|
+
paused: false,
|
|
206
|
+
handle: null,
|
|
207
|
+
finalCandidate: null
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function sendRequest(el, comment, quote) {
|
|
212
|
+
session = newSession(el, comment, quote);
|
|
213
|
+
// Refused here, before anything is sent or paused: the envelope would refuse it
|
|
214
|
+
// anyway, and a refusal the page cannot explain is worse than one it can.
|
|
215
|
+
if (new Blob([JSON.stringify(session.payload)]).size > MAX_PAYLOAD_BYTES) {
|
|
216
|
+
session = null;
|
|
217
|
+
setStatus(TOO_LARGE, 'warn');
|
|
218
|
+
showButtons('send');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
session.paused = true;
|
|
222
|
+
pauseObservers();
|
|
223
|
+
setStatus('sending\u2026');
|
|
224
|
+
showButtons('stop');
|
|
225
|
+
// @page reads the file the host has on disk, so the page has to be on disk first.
|
|
226
|
+
if (/@page\b/.test(comment) && !session.docMode) await window.clay.save();
|
|
227
|
+
if (!session) return; // cancelled while saving
|
|
228
|
+
// The host owns the deadline; this side only renders. A named request defaults to
|
|
229
|
+
// `document: "none"`, so nothing here asks anyone to write the file.
|
|
230
|
+
// Only the live session's statuses land: a line from a request the user already
|
|
231
|
+
// cancelled or replaced must not repaint the panel.
|
|
232
|
+
const handle = wire.send(session.payload, {
|
|
233
|
+
helper: HELPER,
|
|
234
|
+
onStatus: ({ text }) => { if (session?.handle === handle) setStatus(text); }
|
|
235
|
+
});
|
|
236
|
+
session.handle = handle;
|
|
237
|
+
const outcome = await handle.done;
|
|
238
|
+
if (!session || session.handle !== handle) return;
|
|
239
|
+
if (outcome.state === 'done') onDone(outcome.result || {});
|
|
240
|
+
else if (outcome.state === 'cancelled') { revertSession(); setStatus('cancelled'); showButtons('send'); }
|
|
241
|
+
else onError(outcome.error || 'the helper reported an error');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function onDone(payload) {
|
|
245
|
+
if (!session || session.state !== 'requesting') return;
|
|
246
|
+
session.state = 'deciding';
|
|
247
|
+
|
|
248
|
+
const warnings = [];
|
|
249
|
+
let candidate = candidateFrom(payload.html || '', session.tag);
|
|
250
|
+
if (!candidate) {
|
|
251
|
+
onError(`reply is not a single <${session.tag}> element`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (!session.docMode) { // template parsing strips <body>, making this check meaningless there
|
|
255
|
+
const template = document.createElement('template');
|
|
256
|
+
template.innerHTML = payload.html || '';
|
|
257
|
+
if (template.content.children.length > 1) {
|
|
258
|
+
warnings.push('reply had extra root elements \u2014 kept the first');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (session.reassertId) candidate.setAttribute('data-edit-id', session.reassertId);
|
|
262
|
+
if (!session.docMode) { // the real body legitimately contains scripts; skip in doc mode
|
|
263
|
+
if (candidate.querySelector('script')) {
|
|
264
|
+
warnings.push('reply contains <script> \u2014 review before keeping');
|
|
265
|
+
}
|
|
266
|
+
for (const el of [candidate, ...candidate.querySelectorAll('*')]) {
|
|
267
|
+
if ([...el.attributes].some(a => a.name.toLowerCase().startsWith('on'))) {
|
|
268
|
+
warnings.push('reply contains inline event handlers \u2014 review before keeping');
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
session.finalCandidate = candidate;
|
|
275
|
+
sessionMorph(session, candidate); // authoritative morph from the full final HTML
|
|
276
|
+
positionChrome();
|
|
277
|
+
setStatus(payload.model ? `done (${payload.model})` : 'done');
|
|
278
|
+
showWarnings(warnings);
|
|
279
|
+
showButtons('keep', 'revert');
|
|
280
|
+
textarea.value = '';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function onError(message) {
|
|
284
|
+
if (!session) return;
|
|
285
|
+
revertSession();
|
|
286
|
+
setStatus(message, 'warn');
|
|
287
|
+
showButtons('send');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Rewind to the pre-edit snapshot and release observers. The paused rewind means
|
|
291
|
+
// undo history never sees the abandoned edit.
|
|
292
|
+
function revertSession() {
|
|
293
|
+
const s = session;
|
|
294
|
+
session = null;
|
|
295
|
+
if (!s) return;
|
|
296
|
+
if (s.state === 'requesting' || s.state === 'deciding') {
|
|
297
|
+
sessionMorph(s, s.snapshot, { rewind: true });
|
|
298
|
+
}
|
|
299
|
+
if (s.paused) resumeObservers();
|
|
300
|
+
positionChrome();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function keepSession() {
|
|
304
|
+
const s = session;
|
|
305
|
+
session = null;
|
|
306
|
+
sessionMorph(s, s.snapshot, { rewind: true }); // rewind while observers are still paused
|
|
307
|
+
if (s.paused) resumeObservers(); // boundary drain discards the rewind
|
|
308
|
+
sessionMorph(s, s.finalCandidate); // recorded: the whole edit = one undo step
|
|
309
|
+
positionChrome();
|
|
310
|
+
showButtons('send');
|
|
311
|
+
setStatus('saving\u2026');
|
|
312
|
+
const result = await window.clay.save();
|
|
313
|
+
setStatus((result && result.msg) || 'saved', result && result.msgType === 'error' ? 'warn' : '');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function cancelStream() {
|
|
317
|
+
// Stop is live from the moment the request starts, which includes the window where
|
|
318
|
+
// an @page save is still in flight and there is no handle yet.
|
|
319
|
+
session?.handle?.cancel();
|
|
320
|
+
revertSession();
|
|
321
|
+
setStatus('cancelled');
|
|
322
|
+
showButtons('send');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------- chrome
|
|
326
|
+
|
|
327
|
+
function markRuntimeOnly(el) {
|
|
328
|
+
el.setAttribute('clay', RUNTIME_ONLY);
|
|
329
|
+
return el;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function buildChrome() {
|
|
333
|
+
const style = document.createElement('style');
|
|
334
|
+
style.setAttribute('clay', RUNTIME_ONLY);
|
|
335
|
+
style.textContent = `
|
|
336
|
+
#hyper-edit-panel {
|
|
337
|
+
position: fixed; z-index: 99999; width: min(30rem, calc(100vw - 2rem));
|
|
338
|
+
background: #16161d; color: #e8e8f0; border: 1px solid #34343f;
|
|
339
|
+
border-radius: 10px; box-shadow: 0 12px 40px rgba(0,0,0,.45);
|
|
340
|
+
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
341
|
+
padding: 10px;
|
|
342
|
+
}
|
|
343
|
+
#hyper-edit-panel[hidden] { display: none; }
|
|
344
|
+
#hyper-edit-panel .hep-quote {
|
|
345
|
+
color: #9a9ab0; border-left: 2px solid #4a4a5a; padding-left: 8px;
|
|
346
|
+
margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
347
|
+
}
|
|
348
|
+
#hyper-edit-panel textarea {
|
|
349
|
+
width: 100%; box-sizing: border-box; min-height: 3.2em; resize: vertical;
|
|
350
|
+
background: #0e0e13; color: inherit; border: 1px solid #34343f;
|
|
351
|
+
border-radius: 6px; padding: 8px; font: inherit; outline: none;
|
|
352
|
+
}
|
|
353
|
+
#hyper-edit-panel textarea:focus { border-color: #6a6a8a; }
|
|
354
|
+
#hyper-edit-panel .hep-row {
|
|
355
|
+
display: flex; align-items: center; gap: 8px; margin-top: 8px;
|
|
356
|
+
}
|
|
357
|
+
#hyper-edit-panel .hep-status { flex: 1; color: #9a9ab0; min-width: 0; }
|
|
358
|
+
#hyper-edit-panel .hep-status.warn { color: #e8b04a; }
|
|
359
|
+
#hyper-edit-panel button {
|
|
360
|
+
background: #26262f; color: inherit; border: 1px solid #44444f;
|
|
361
|
+
border-radius: 6px; padding: 4px 12px; font: inherit; cursor: pointer;
|
|
362
|
+
}
|
|
363
|
+
#hyper-edit-panel button:hover { background: #32323d; }
|
|
364
|
+
#hyper-edit-panel .hep-keep { background: #1e3a2a; border-color: #2e5a40; }
|
|
365
|
+
#hyper-edit-panel .hep-warnings {
|
|
366
|
+
margin-top: 8px; color: #e8b04a; white-space: pre-line;
|
|
367
|
+
}
|
|
368
|
+
#hyper-edit-ring {
|
|
369
|
+
position: fixed; z-index: 99998; pointer-events: none;
|
|
370
|
+
border: 1px solid #7a7aff; border-radius: 4px; opacity: .8;
|
|
371
|
+
}
|
|
372
|
+
#hyper-edit-ring[hidden] { display: none; }
|
|
373
|
+
#hyper-edit-chip {
|
|
374
|
+
position: fixed; z-index: 99999; width: 26px; height: 26px; padding: 0;
|
|
375
|
+
display: flex; align-items: center; justify-content: center;
|
|
376
|
+
background: #16161d; color: #e8e8f0; border: 1px solid #44444f;
|
|
377
|
+
border-radius: 50%; cursor: pointer; font-size: 13px; line-height: 1;
|
|
378
|
+
box-shadow: 0 4px 14px rgba(0,0,0,.4);
|
|
379
|
+
}
|
|
380
|
+
#hyper-edit-chip[hidden] { display: none; }
|
|
381
|
+
#hyper-edit-chip:hover { background: #32323d; border-color: #6a6a8a; }
|
|
382
|
+
#hyper-edit-doc-bubble {
|
|
383
|
+
position: fixed; right: 16px; bottom: 16px; z-index: 99999;
|
|
384
|
+
width: 44px; height: 44px; padding: 0; border-radius: 50%;
|
|
385
|
+
display: flex; align-items: center; justify-content: center;
|
|
386
|
+
background: #16161d; color: #e8e8f0; border: 1px solid #44444f;
|
|
387
|
+
font-size: 18px; cursor: pointer; box-shadow: 0 6px 20px rgba(0,0,0,.45);
|
|
388
|
+
}
|
|
389
|
+
#hyper-edit-doc-bubble:hover { background: #32323d; border-color: #6a6a8a; }
|
|
390
|
+
[editmode\\:contenteditable][contenteditable]:focus {
|
|
391
|
+
outline: 1px solid #4a4a6a; outline-offset: 4px; border-radius: 2px;
|
|
392
|
+
}
|
|
393
|
+
`;
|
|
394
|
+
document.head.appendChild(style);
|
|
395
|
+
|
|
396
|
+
ring = markRuntimeOnly(document.createElement('div'));
|
|
397
|
+
ring.id = 'hyper-edit-ring';
|
|
398
|
+
ring.hidden = true;
|
|
399
|
+
|
|
400
|
+
panel = markRuntimeOnly(document.createElement('div'));
|
|
401
|
+
panel.id = 'hyper-edit-panel';
|
|
402
|
+
panel.hidden = true;
|
|
403
|
+
panel.innerHTML = `
|
|
404
|
+
<div class="hep-quote" hidden></div>
|
|
405
|
+
<textarea rows="2" placeholder="Describe the change\u2026 (@file.ext adds context, @fable / @claude picks the agent)"></textarea>
|
|
406
|
+
<div class="hep-row">
|
|
407
|
+
<span class="hep-status"></span>
|
|
408
|
+
<button class="hep-send" type="button">Send</button>
|
|
409
|
+
<button class="hep-stop" type="button" hidden>Stop</button>
|
|
410
|
+
<button class="hep-revert" type="button" hidden>Revert</button>
|
|
411
|
+
<button class="hep-keep" type="button" hidden>Keep</button>
|
|
412
|
+
</div>
|
|
413
|
+
<div class="hep-warnings" hidden></div>
|
|
414
|
+
`;
|
|
415
|
+
|
|
416
|
+
chip = markRuntimeOnly(document.createElement('button'));
|
|
417
|
+
chip.id = 'hyper-edit-chip';
|
|
418
|
+
chip.type = 'button';
|
|
419
|
+
chip.title = 'Comment on this (\u2318K)';
|
|
420
|
+
chip.textContent = '\ud83d\udcac';
|
|
421
|
+
chip.hidden = true;
|
|
422
|
+
|
|
423
|
+
docBubble = markRuntimeOnly(document.createElement('button'));
|
|
424
|
+
docBubble.id = 'hyper-edit-doc-bubble';
|
|
425
|
+
docBubble.type = 'button';
|
|
426
|
+
docBubble.title = 'Comment on the whole page';
|
|
427
|
+
docBubble.textContent = '\ud83d\udcac';
|
|
428
|
+
|
|
429
|
+
document.body.append(ring, panel, chip, docBubble);
|
|
430
|
+
|
|
431
|
+
quoteEl = panel.querySelector('.hep-quote');
|
|
432
|
+
textarea = panel.querySelector('textarea');
|
|
433
|
+
statusEl = panel.querySelector('.hep-status');
|
|
434
|
+
warningsEl = panel.querySelector('.hep-warnings');
|
|
435
|
+
for (const name of ['send', 'stop', 'keep', 'revert']) {
|
|
436
|
+
buttons[name] = panel.querySelector('.hep-' + name);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
buttons.send.addEventListener('click', submit);
|
|
440
|
+
buttons.stop.addEventListener('click', cancelStream);
|
|
441
|
+
buttons.keep.addEventListener('click', keepSession);
|
|
442
|
+
buttons.revert.addEventListener('click', () => { revertSession(); setStatus('reverted'); showButtons('send'); });
|
|
443
|
+
textarea.addEventListener('keydown', (event) => {
|
|
444
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
445
|
+
event.preventDefault();
|
|
446
|
+
if (!buttons.send.hidden) submit();
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
chip.addEventListener('click', () => {
|
|
451
|
+
const target = chipTarget;
|
|
452
|
+
hideChip();
|
|
453
|
+
if (target && !session) openPanel(target, quoteFromSelection(target));
|
|
454
|
+
});
|
|
455
|
+
docBubble.addEventListener('click', () => {
|
|
456
|
+
if (session) return;
|
|
457
|
+
openPanel(document.body, quoteFromSelection(document.body));
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
window.addEventListener('scroll', () => { positionChrome(); hideChip(); }, { passive: true });
|
|
461
|
+
window.addEventListener('resize', positionChrome, { passive: true });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------- hover chip
|
|
465
|
+
|
|
466
|
+
function showChipFor(unit) {
|
|
467
|
+
clearTimeout(chipHideTimer);
|
|
468
|
+
chipHideTimer = null;
|
|
469
|
+
chipTarget = unit;
|
|
470
|
+
const rect = unit.getBoundingClientRect();
|
|
471
|
+
chip.hidden = false;
|
|
472
|
+
chip.style.left = Math.min(rect.right + 8, window.innerWidth - 34) + 'px';
|
|
473
|
+
chip.style.top = Math.max(8, rect.top) + 'px';
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function hideChip() {
|
|
477
|
+
clearTimeout(chipHideTimer);
|
|
478
|
+
chipHideTimer = null;
|
|
479
|
+
chip.hidden = true;
|
|
480
|
+
chipTarget = null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function scheduleChipHide() {
|
|
484
|
+
if (chip.hidden || chipHideTimer) return;
|
|
485
|
+
chipHideTimer = setTimeout(hideChip, 400);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function setStatus(text, tone) {
|
|
489
|
+
statusEl.textContent = text || '';
|
|
490
|
+
statusEl.classList.toggle('warn', tone === 'warn');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function showButtons(...names) {
|
|
494
|
+
for (const [name, button] of Object.entries(buttons)) {
|
|
495
|
+
button.hidden = !names.includes(name);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function showWarnings(warnings) {
|
|
500
|
+
warningsEl.hidden = !warnings.length;
|
|
501
|
+
warningsEl.textContent = warnings.map(w => '\u26a0 ' + w).join('\n');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function positionChrome() {
|
|
505
|
+
if (!anchorEl || panel.hidden) return;
|
|
506
|
+
if (!anchorEl.isConnected) { closePanel(); return; }
|
|
507
|
+
if (anchorEl === document.body) {
|
|
508
|
+
// document mode: no ring, panel pinned above the bottom-right bubble
|
|
509
|
+
ring.hidden = true;
|
|
510
|
+
panel.style.left = Math.max(16, window.innerWidth - (panel.offsetWidth || 480) - 16) + 'px';
|
|
511
|
+
panel.style.top = Math.max(16, window.innerHeight - (panel.offsetHeight || 120) - 76) + 'px';
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
515
|
+
ring.hidden = false;
|
|
516
|
+
ring.style.left = rect.left - 4 + 'px';
|
|
517
|
+
ring.style.top = rect.top - 4 + 'px';
|
|
518
|
+
ring.style.width = rect.width + 6 + 'px';
|
|
519
|
+
ring.style.height = rect.height + 6 + 'px';
|
|
520
|
+
const panelWidth = panel.offsetWidth || 480;
|
|
521
|
+
const left = Math.max(16, Math.min(rect.left, window.innerWidth - panelWidth - 16));
|
|
522
|
+
let top = rect.bottom + 10;
|
|
523
|
+
const panelHeight = panel.offsetHeight || 120;
|
|
524
|
+
if (top + panelHeight > window.innerHeight - 16) {
|
|
525
|
+
top = Math.max(16, rect.top - panelHeight - 10);
|
|
526
|
+
}
|
|
527
|
+
panel.style.left = left + 'px';
|
|
528
|
+
panel.style.top = top + 'px';
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function openPanel(el, quote) {
|
|
532
|
+
anchorEl = el;
|
|
533
|
+
hideChip();
|
|
534
|
+
panel.hidden = false;
|
|
535
|
+
quoteEl.hidden = !quote;
|
|
536
|
+
quoteEl.textContent = quote ? `\u201c${quote}\u201d` : '';
|
|
537
|
+
panel.dataset.quote = quote || '';
|
|
538
|
+
setStatus('');
|
|
539
|
+
showWarnings([]);
|
|
540
|
+
showButtons('send');
|
|
541
|
+
positionChrome();
|
|
542
|
+
textarea.focus();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function closePanel() {
|
|
546
|
+
if (session) revertSession();
|
|
547
|
+
anchorEl = null;
|
|
548
|
+
panel.hidden = true;
|
|
549
|
+
ring.hidden = true;
|
|
550
|
+
textarea.value = '';
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function submit() {
|
|
554
|
+
const comment = textarea.value.trim();
|
|
555
|
+
if (!comment || !anchorEl || session) return;
|
|
556
|
+
sendRequest(anchorEl, comment, panel.dataset.quote || undefined);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ---------------------------------------------------------------- interactions
|
|
560
|
+
|
|
561
|
+
function wireInteractions() {
|
|
562
|
+
// Clicks: text units are contenteditable (caret) and figures keep their own
|
|
563
|
+
// interactivity, so neither is intercepted. Only a click on bare section padding
|
|
564
|
+
// opens the comment box; a click away from an open, empty panel closes it.
|
|
565
|
+
document.addEventListener('click', (event) => {
|
|
566
|
+
if (panel.contains(event.target) || chip.contains(event.target) || docBubble.contains(event.target)) return;
|
|
567
|
+
if (session) return; // one edit at a time
|
|
568
|
+
const section = event.target.closest?.('[data-edit-id]');
|
|
569
|
+
const unit = section ? unitFrom(event.target) : null;
|
|
570
|
+
if (section && unit === section) {
|
|
571
|
+
if (section !== anchorEl || panel.hidden) openPanel(section, quoteFromSelection(section));
|
|
572
|
+
} else if (!panel.hidden && !textarea.value.trim() && !(anchorEl && anchorEl.contains(event.target))) {
|
|
573
|
+
closePanel();
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// Hover chip: follows the unit under the cursor while no panel is open.
|
|
578
|
+
let lastMove = 0;
|
|
579
|
+
document.addEventListener('mousemove', (event) => {
|
|
580
|
+
const now = Date.now();
|
|
581
|
+
if (now - lastMove < 80) return;
|
|
582
|
+
lastMove = now;
|
|
583
|
+
if (session || !panel.hidden) { scheduleChipHide(); return; }
|
|
584
|
+
if (chip.contains(event.target)) { clearTimeout(chipHideTimer); chipHideTimer = null; return; }
|
|
585
|
+
const unit = unitFrom(event.target);
|
|
586
|
+
if (unit) showChipFor(unit);
|
|
587
|
+
else scheduleChipHide();
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
document.addEventListener('keydown', (event) => {
|
|
591
|
+
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') {
|
|
592
|
+
if (session) return;
|
|
593
|
+
event.preventDefault();
|
|
594
|
+
const selection = window.getSelection();
|
|
595
|
+
let node = selection && selection.anchorNode;
|
|
596
|
+
if (node && node.nodeType !== 1) node = node.parentElement;
|
|
597
|
+
const target = (node && unitFrom(node)) || chipTarget;
|
|
598
|
+
if (target) openPanel(target, quoteFromSelection(target));
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (event.key !== 'Escape' || panel.hidden) return;
|
|
602
|
+
if (session?.state === 'requesting') {
|
|
603
|
+
cancelStream();
|
|
604
|
+
} else if (session?.state === 'deciding') {
|
|
605
|
+
revertSession();
|
|
606
|
+
setStatus('reverted');
|
|
607
|
+
showButtons('send');
|
|
608
|
+
} else {
|
|
609
|
+
closePanel();
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ---------------------------------------------------------------- boot
|
|
615
|
+
|
|
616
|
+
async function init() {
|
|
617
|
+
if (!isEditMode) return; // an owner/editor feature, nothing else
|
|
618
|
+
if (!(await available())) return; // no ai-edit helper (e.g. HTML Clay, or the toggle off): stay dormant
|
|
619
|
+
buildChrome();
|
|
620
|
+
wireInteractions();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
onDomReady(() => { init().catch(() => {}); });
|
|
624
|
+
|
|
625
|
+
export const aiEdit = { init };
|