@panphora/clayjs 1.5.3 → 1.6.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 +5 -3
- package/THIRD-PARTY-NOTICES.md +6 -0
- package/dist/clay.standalone.js +14965 -10714
- package/package.json +9 -4
- package/src/core/conflict-download.js +91 -0
- package/src/core/conflict-notice.js +720 -0
- package/src/core/conflict-presentation.js +301 -0
- package/src/core/save-conflict-notice.js +2 -192
- package/src/core/save-feedback.js +6 -0
- package/src/core/save.js +53 -1
- package/src/core/snapshot.js +21 -0
- package/src/core/stale-host-notice.js +21 -24
- package/src/core/unsaved-warning.js +35 -10
- package/src/lib/content-dom.js +4 -2
- package/src/lib/dirty-gate.js +9 -0
- package/src/lib/unsaved-state.js +55 -0
- package/src/loader-logic.js +26 -15
- package/src/loader.js +4 -3
- package/src/plugins/ai-edit.js +489 -179
- package/src/plugins/cms.js +12 -0
- package/src/plugins/indicator.js +35 -16
- package/src/plugins/quickcrop.js +103 -0
- package/src/plugins/richclay.js +21 -0
- package/src/plugins/source.js +13 -6
- package/src/plugins/wire.js +4 -0
- package/src/sync/conflict-revert.js +1138 -0
- package/src/sync/conflicts.js +159 -0
- package/src/sync/live-sync.js +130 -109
- package/src/sync/presence.js +42 -36
- package/src/sync/section-notice.js +53 -28
- package/src/ui/bevel-controls.js +277 -0
- package/src/ui/bevel-dialog.js +112 -0
- package/src/ui/bevel.js +247 -0
- package/src/ui/dialogs.js +63 -86
- package/src/ui/index.js +22 -11
- package/src/ui/modal.js +80 -387
- package/src/ui/skins/cms-toggle.js +15 -0
- package/src/ui/skins/cms.js +4 -0
- package/src/ui/skins/quickcrop.js +4 -0
- package/src/ui/skins/richclay.js +4 -0
- package/src/ui/toast.js +116 -401
- package/src/ui/vendor-skin.js +65 -0
- package/src/vendor/hyper-morph.vendor.js +4 -4
- package/src/vendor/quickcrop.vendor.js +14 -8
- package/src/vendor/richclay.vendor.js +22 -41
package/src/plugins/ai-edit.js
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Direct editing stays primary: text units carry editmode:contenteditable, so a
|
|
5
5
|
* click places the caret and a figure keeps its own interactivity. This adds the
|
|
6
|
-
* comment box on demand:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* section itself.
|
|
6
|
+
* comment box on demand: Cmd+J (Ctrl+J elsewhere) or the small chip at the end of a
|
|
7
|
+
* selection, for the block around it; on pages built from [data-edit-id] sections,
|
|
8
|
+
* also a hover chip near the unit, a click on bare section padding, and the fixed
|
|
9
|
+
* bottom-right bubble for the whole document.
|
|
11
10
|
*
|
|
12
11
|
* The request leaves as a named helper request on the wire
|
|
13
12
|
* (`clay.wire.send(payload, { helper: "ai-edit" })`) and comes back as the edited
|
|
@@ -15,9 +14,10 @@
|
|
|
15
14
|
* writes nothing: this page morphs the result in and Keep saves it through
|
|
16
15
|
* `clay.save()`, exactly as before.
|
|
17
16
|
*
|
|
18
|
-
* Dormant unless the host lists
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* Dormant unless the host lists an `ai-edit` helper: then nothing is built and the
|
|
18
|
+
* page is just a page. Listed as unavailable (the host's toggle is off), the
|
|
19
|
+
* shortcut opens the panel with a note and no Send, and the state is checked again
|
|
20
|
+
* each time the panel opens.
|
|
21
21
|
*
|
|
22
22
|
* The bus version previewed the reply as it streamed. The wire carries status lines
|
|
23
23
|
* only — replaceable progress, capped and droppable — so the page shows throttled
|
|
@@ -39,14 +39,23 @@ import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
|
39
39
|
import { mergeTagRecognizers } from "../sync/merge-tags.js";
|
|
40
40
|
import Mutation from "../lib/mutation.js";
|
|
41
41
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
42
|
+
import { holdAllSaves, releaseAllSaves } from "../core/save.js";
|
|
42
43
|
import { STRIP_FROM_SAVE } from "../lib/region-policy.js";
|
|
43
44
|
import { enableContentEditable } from "../core/admin-contenteditable.js";
|
|
44
45
|
import onDomReady from "../lib/dom-ready.js";
|
|
45
46
|
import wire from "./wire.js";
|
|
47
|
+
import { set } from "../lib/hostile-css.js";
|
|
48
|
+
import { bevelBox, bevelButton, bevelSurface, bevelText, bevelInput, pageScheme, setShown, RUNTIME_ONLY } from "../ui/bevel-controls.js";
|
|
49
|
+
import { TOKENS, FONT_SANS, FONT_MONO, SHADOW } from "../ui/bevel.js";
|
|
46
50
|
|
|
47
51
|
const HELPER = "ai-edit";
|
|
48
52
|
const UNIT_SELECTOR = "h1,h2,h3,h4,h5,h6,p,figure";
|
|
49
|
-
|
|
53
|
+
// Outside [data-edit-id] sections: the nearest text block that holds the whole
|
|
54
|
+
// selection, else the nearest container that does. A selection across top-level blocks
|
|
55
|
+
// edits the whole page.
|
|
56
|
+
const BLOCK_SELECTOR = "p,h1,h2,h3,h4,h5,h6,li,dt,dd,blockquote,pre,figcaption,td,th";
|
|
57
|
+
const CONTAINER_SELECTOR = "section,article,aside,header,footer,nav,main,div,ul,ol,dl,table,figure,form";
|
|
58
|
+
const NOT_TEXT = "script,style,template,textarea,input,select,button,iframe,svg,math";
|
|
50
59
|
// The request ceiling, kept below the host's 1 MiB envelope so the refusal happens
|
|
51
60
|
// here, where the message can say what to do about it.
|
|
52
61
|
const MAX_PAYLOAD_BYTES = 900 * 1024;
|
|
@@ -54,17 +63,30 @@ const TOO_LARGE = "This section is too large for AI editing; select a smaller pa
|
|
|
54
63
|
|
|
55
64
|
let requestCounter = 0;
|
|
56
65
|
let session = null; // one edit at a time
|
|
57
|
-
let panel, ring, textarea,
|
|
66
|
+
let panel, ring, textarea, statusEl, warningsEl, pointer, chip, docBubble;
|
|
58
67
|
let buttons = {};
|
|
59
68
|
let anchorEl = null; // element the panel is currently anchored to
|
|
60
69
|
let chipTarget = null; // unit the hover chip currently points at
|
|
61
70
|
let chipHideTimer = null;
|
|
71
|
+
let pendingSelection; // { text, start, end } for the open panel, or undefined
|
|
72
|
+
|
|
73
|
+
let chipSelection; // the selection the chip was raised for; undefined for the hover chip
|
|
74
|
+
let selectionTimer = null;
|
|
75
|
+
let anchorRange = null; // the selection the open panel was raised for
|
|
76
|
+
let returnFocus = null; // where focus goes back to when the panel closes
|
|
77
|
+
let helperState = null; // "ready" or "unavailable"
|
|
78
|
+
|
|
79
|
+
const HIGHLIGHT = 'clay-ai-edit';
|
|
80
|
+
const OFF_MESSAGE = 'AI editing is turned off. Turn it on from the app’s menu.';
|
|
62
81
|
|
|
63
82
|
// ---------------------------------------------------------------- transport
|
|
64
83
|
|
|
65
|
-
|
|
84
|
+
// The helper's state as this host lists it: "ready", "unavailable" (listed, but the
|
|
85
|
+
// host's toggle is off), or null when there is no ai-edit helper to talk to.
|
|
86
|
+
async function helperStateNow() {
|
|
66
87
|
const list = await wire.helpers();
|
|
67
|
-
|
|
88
|
+
const helper = list.find(h => h.name === HELPER);
|
|
89
|
+
return helper && (helper.state === 'ready' || helper.state === 'unavailable') ? helper.state : null;
|
|
68
90
|
}
|
|
69
91
|
|
|
70
92
|
// ---------------------------------------------------------------- observers (undo/autosave)
|
|
@@ -94,18 +116,52 @@ function editLabel(el) {
|
|
|
94
116
|
const own = el.getAttribute('data-edit-id');
|
|
95
117
|
if (own) return own;
|
|
96
118
|
const section = el.closest('[data-edit-id]');
|
|
97
|
-
|
|
119
|
+
if (!section) return el.id ? '#' + el.id : el.tagName.toLowerCase();
|
|
120
|
+
return section.getAttribute('data-edit-id') + ' \u203a ' + el.tagName.toLowerCase();
|
|
98
121
|
}
|
|
99
122
|
|
|
100
|
-
function
|
|
123
|
+
function liveRange() {
|
|
101
124
|
const selection = window.getSelection();
|
|
102
|
-
if (!selection || selection.isCollapsed) return
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
125
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
|
|
126
|
+
return selection.getRangeAt(0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The element a selection edits. Section pages keep their unit rules; everywhere
|
|
130
|
+
// else it is the block around the whole selection, so a backward selection and a
|
|
131
|
+
// forward one land on the same element.
|
|
132
|
+
function targetFromRange(range) {
|
|
133
|
+
let node = range.commonAncestorContainer;
|
|
106
134
|
if (node.nodeType !== 1) node = node.parentElement;
|
|
107
|
-
if (
|
|
108
|
-
|
|
135
|
+
if (!node || !document.body.contains(node)) return null;
|
|
136
|
+
// A selection across top-level blocks has only the page around it.
|
|
137
|
+
if (node === document.body) return document.body;
|
|
138
|
+
if (node.closest('[data-clay-ai-edit]') || node.closest(STRIP_FROM_SAVE) || node.closest(NOT_TEXT)) return null;
|
|
139
|
+
if (node.closest('[data-edit-id]')) return unitFrom(node);
|
|
140
|
+
const target = node.closest(BLOCK_SELECTOR) || node.closest(CONTAINER_SELECTOR);
|
|
141
|
+
return target && target !== document.body ? target : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The selected text, whole, with its character offsets inside the target's text,
|
|
145
|
+
// so a phrase that appears twice is unambiguous.
|
|
146
|
+
function selectionIn(range, target) {
|
|
147
|
+
if (!range || !target || !target.contains(range.commonAncestorContainer)) return undefined;
|
|
148
|
+
const text = range.toString();
|
|
149
|
+
if (!text.trim()) return undefined;
|
|
150
|
+
const before = document.createRange();
|
|
151
|
+
before.selectNodeContents(target);
|
|
152
|
+
before.setEnd(range.startContainer, range.startOffset);
|
|
153
|
+
const start = before.toString().length;
|
|
154
|
+
return { text, start, end: start + text.length };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function quoteFromSelection(scope) {
|
|
158
|
+
const range = liveRange();
|
|
159
|
+
if (!range) return undefined;
|
|
160
|
+
if (scope === document.body) {
|
|
161
|
+
const text = range.toString();
|
|
162
|
+
return text.trim() ? { text, start: undefined, end: undefined } : undefined;
|
|
163
|
+
}
|
|
164
|
+
return selectionIn(range, scope);
|
|
109
165
|
}
|
|
110
166
|
|
|
111
167
|
function strippedBodyHTML() {
|
|
@@ -128,9 +184,11 @@ function morphTo(el, content, scripts) {
|
|
|
128
184
|
// against the snapshot the model saw, so data the user changed mid-flight survives
|
|
129
185
|
// the reply. Rewinds must restore the snapshot exactly, so they disable merging.
|
|
130
186
|
function sessionMorph(s, content, { rewind = false } = {}) {
|
|
187
|
+
// handle:false: a reply's scripts never run, so the preview cannot execute code
|
|
188
|
+
// before Keep, and neither can a rewind.
|
|
131
189
|
const scripts = rewind
|
|
132
|
-
? { merge: false }
|
|
133
|
-
: { mergeBase: s.snapshot, mergeTags: mergeTagRecognizers };
|
|
190
|
+
? { merge: false, handle: false }
|
|
191
|
+
: { mergeBase: s.snapshot, mergeTags: mergeTagRecognizers, handle: false };
|
|
134
192
|
if (s.docMode) {
|
|
135
193
|
if (typeof content === 'string') {
|
|
136
194
|
// Parse to an element ourselves: hyper-morph parses a body string into a
|
|
@@ -177,9 +235,11 @@ function newSession(el, comment, quote) {
|
|
|
177
235
|
// Context refs are @tokens containing a dot or slash (@notes.md, @src/x.js). Bare
|
|
178
236
|
// @words are not refs: a leading one is an engine token (@fable, @codex — routed
|
|
179
237
|
// helper-side), and mid-text ones are prose.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
.
|
|
238
|
+
// The @ has to start a word, so an email address or a URL is prose too.
|
|
239
|
+
const contextRefs = [...comment.matchAll(/(?<![\w\u00C0-\u024F.@\/:-])@([\w.-]*[/.][\w./-]*)/g)]
|
|
240
|
+
.map(m => m[1].replace(/\.+$/, '')) // a sentence's full stop is not part of the name
|
|
241
|
+
.filter(ref => ref !== 'page')
|
|
242
|
+
.filter(ref => /[/.]/.test(ref));
|
|
183
243
|
const elementHTML = docMode ? strippedBodyHTML() : el.outerHTML;
|
|
184
244
|
const payload = {
|
|
185
245
|
id: 'req-' + Date.now().toString(36) + '-' + (++requestCounter),
|
|
@@ -189,10 +249,13 @@ function newSession(el, comment, quote) {
|
|
|
189
249
|
comment,
|
|
190
250
|
contextRefs
|
|
191
251
|
};
|
|
192
|
-
if (quote)
|
|
252
|
+
if (quote) {
|
|
253
|
+
payload.quote = quote.text;
|
|
254
|
+
if (Number.isInteger(quote.start)) payload.selection = { start: quote.start, end: quote.end };
|
|
255
|
+
}
|
|
193
256
|
// Document mode reads the live page itself, and every other @page request reads
|
|
194
257
|
// the file from disk: the plugin saves first, then asks for it by flag.
|
|
195
|
-
if (!docMode &&
|
|
258
|
+
if (!docMode && /(?<![\w\u00C0-\u024F.@\/:-])@page(?![\w\/-]|\.\w)/.test(comment)) payload.page = true;
|
|
196
259
|
return {
|
|
197
260
|
id: payload.id,
|
|
198
261
|
el,
|
|
@@ -223,7 +286,7 @@ async function sendRequest(el, comment, quote) {
|
|
|
223
286
|
setStatus('sending\u2026');
|
|
224
287
|
showButtons('stop');
|
|
225
288
|
// @page reads the file the host has on disk, so the page has to be on disk first.
|
|
226
|
-
if (
|
|
289
|
+
if (session.payload.page) await window.clay.save();
|
|
227
290
|
if (!session) return; // cancelled while saving
|
|
228
291
|
// The host owns the deadline; this side only renders. A named request defaults to
|
|
229
292
|
// `document: "none"`, so nothing here asks anyone to write the file.
|
|
@@ -241,6 +304,63 @@ async function sendRequest(el, comment, quote) {
|
|
|
241
304
|
else onError(outcome.error || 'the helper reported an error');
|
|
242
305
|
}
|
|
243
306
|
|
|
307
|
+
// What a reply may not add: anything that runs code. Each part is counted, and the
|
|
308
|
+
// reply is refused when it holds more of any part than the element the model was
|
|
309
|
+
// given, so a page's own scripts and handlers are not a reason to refuse an edit
|
|
310
|
+
// near them, while a copied handler or a changed script is.
|
|
311
|
+
const ACTIVE_ATTR = /^(on|hx-on|x-|@|:|v-on:|v-bind:|data-on[-:])/;
|
|
312
|
+
const ACTIVE_TAGS = new Set(['iframe', 'frame', 'object', 'embed', 'base', 'link']);
|
|
313
|
+
|
|
314
|
+
// URL parsing removes tab, CR and LF anywhere and controls and spaces at the start,
|
|
315
|
+
// so "java	script:" is still a javascript: URL.
|
|
316
|
+
function isScriptURL(value) {
|
|
317
|
+
const url = value.replace(/[\t\n\r]/g, '').replace(/^[\u0000- ]+/, '').toLowerCase();
|
|
318
|
+
return url.startsWith('javascript:') || url.startsWith('vbscript:');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// A script whose type the browser does not run (JSON, a template) is data.
|
|
322
|
+
function runsAsCode(script) {
|
|
323
|
+
const type = (script.getAttribute('type') || '').trim().toLowerCase();
|
|
324
|
+
return !type || /javascript|ecmascript/.test(type) || ['module', 'importmap', 'speculationrules'].includes(type);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function attrsOf(el) {
|
|
328
|
+
return [...el.attributes].map(a => a.name.toLowerCase() + '=' + a.value).sort().join(' ');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function activeParts(root) {
|
|
332
|
+
const parts = new Map();
|
|
333
|
+
const add = (part) => parts.set(part, (parts.get(part) || 0) + 1);
|
|
334
|
+
for (const el of [root, ...root.querySelectorAll('*')]) {
|
|
335
|
+
const tag = el.tagName.toLowerCase();
|
|
336
|
+
if (tag === 'script') {
|
|
337
|
+
if (runsAsCode(el)) add('script ' + attrsOf(el) + '|' + el.textContent.trim());
|
|
338
|
+
} else if (ACTIVE_TAGS.has(tag) || (tag === 'meta' && el.hasAttribute('http-equiv'))) {
|
|
339
|
+
add(tag + ' ' + attrsOf(el));
|
|
340
|
+
} else if ((tag === 'animate' || tag === 'set') && /href/i.test(el.getAttribute('attributeName') || '')) {
|
|
341
|
+
add(tag + ' ' + attrsOf(el));
|
|
342
|
+
}
|
|
343
|
+
for (const attr of el.attributes) {
|
|
344
|
+
const name = attr.name.toLowerCase();
|
|
345
|
+
if (ACTIVE_ATTR.test(name)) add(tag + ' ' + name + '=' + attr.value.trim());
|
|
346
|
+
else if (name === 'srcdoc') add(tag + ' srcdoc=' + attr.value);
|
|
347
|
+
else if (isScriptURL(attr.value)) add(tag + ' ' + name + '=' + attr.value);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return parts;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function addsActiveContent(candidate, snapshot, docMode) {
|
|
354
|
+
const original = docMode
|
|
355
|
+
? new DOMParser().parseFromString(snapshot, 'text/html').body
|
|
356
|
+
: (() => { const t = document.createElement('template'); t.innerHTML = snapshot; return t.content.firstElementChild; })();
|
|
357
|
+
const before = original ? activeParts(original) : new Map();
|
|
358
|
+
for (const [part, count] of activeParts(candidate)) if (count > (before.get(part) || 0)) return true;
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const ACTIVE_REFUSED = 'The reply adds a script or event handler, so it was not applied. AI editing changes content, not code.';
|
|
363
|
+
|
|
244
364
|
function onDone(payload) {
|
|
245
365
|
if (!session || session.state !== 'requesting') return;
|
|
246
366
|
session.state = 'deciding';
|
|
@@ -259,25 +379,27 @@ function onDone(payload) {
|
|
|
259
379
|
}
|
|
260
380
|
}
|
|
261
381
|
if (session.reassertId) candidate.setAttribute('data-edit-id', session.reassertId);
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
}
|
|
382
|
+
if (addsActiveContent(candidate, session.snapshot, session.docMode)) {
|
|
383
|
+
onError(ACTIVE_REFUSED);
|
|
384
|
+
return;
|
|
272
385
|
}
|
|
273
386
|
|
|
387
|
+
holdAllSaves();
|
|
388
|
+
session.held = true;
|
|
389
|
+
|
|
274
390
|
session.finalCandidate = candidate;
|
|
275
|
-
|
|
391
|
+
try {
|
|
392
|
+
sessionMorph(session, candidate); // authoritative morph from the full final HTML
|
|
393
|
+
} catch (error) {
|
|
394
|
+
onError('the reply could not be applied: ' + (error?.message || error));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
276
397
|
positionChrome();
|
|
277
398
|
setStatus(payload.model ? `done (${payload.model})` : 'done');
|
|
278
399
|
showWarnings(warnings);
|
|
279
400
|
showButtons('keep', 'revert');
|
|
280
401
|
textarea.value = '';
|
|
402
|
+
fitTextarea();
|
|
281
403
|
}
|
|
282
404
|
|
|
283
405
|
function onError(message) {
|
|
@@ -293,18 +415,26 @@ function revertSession() {
|
|
|
293
415
|
const s = session;
|
|
294
416
|
session = null;
|
|
295
417
|
if (!s) return;
|
|
296
|
-
|
|
297
|
-
|
|
418
|
+
try {
|
|
419
|
+
if (s.state === 'requesting' || s.state === 'deciding') {
|
|
420
|
+
sessionMorph(s, s.snapshot, { rewind: true });
|
|
421
|
+
}
|
|
422
|
+
} finally {
|
|
423
|
+
if (s.paused) resumeObservers();
|
|
424
|
+
if (s.held) releaseAllSaves();
|
|
425
|
+
positionChrome();
|
|
298
426
|
}
|
|
299
|
-
if (s.paused) resumeObservers();
|
|
300
|
-
positionChrome();
|
|
301
427
|
}
|
|
302
428
|
|
|
303
429
|
async function keepSession() {
|
|
304
430
|
const s = session;
|
|
305
431
|
session = null;
|
|
306
|
-
|
|
307
|
-
|
|
432
|
+
try {
|
|
433
|
+
sessionMorph(s, s.snapshot, { rewind: true }); // rewind while observers are still paused
|
|
434
|
+
} finally {
|
|
435
|
+
if (s.paused) resumeObservers(); // boundary drain discards the rewind
|
|
436
|
+
if (s.held) releaseAllSaves({ replay: false });
|
|
437
|
+
}
|
|
308
438
|
sessionMorph(s, s.finalCandidate); // recorded: the whole edit = one undo step
|
|
309
439
|
positionChrome();
|
|
310
440
|
showButtons('send');
|
|
@@ -324,8 +454,15 @@ function cancelStream() {
|
|
|
324
454
|
|
|
325
455
|
// ---------------------------------------------------------------- chrome
|
|
326
456
|
|
|
327
|
-
|
|
328
|
-
|
|
457
|
+
// Everything below is drawn on somebody else's page, so it follows the hostile-CSS
|
|
458
|
+
// contract: runtime-only, no ids or classes, every declaration inline and !important
|
|
459
|
+
// from all:initial, in Bevel material from the generated subset. Show and hide go
|
|
460
|
+
// through setShown and positions through set(), both !important, so no page rule can
|
|
461
|
+
// reveal a hidden part or move a placed one.
|
|
462
|
+
const FLOATING = ['z-index:99999', 'padding:0', 'display:inline-grid', 'place-items:center', 'line-height:1', `box-shadow:${SHADOW}`];
|
|
463
|
+
|
|
464
|
+
function marked(el, name, value) {
|
|
465
|
+
el.setAttribute(name, value);
|
|
329
466
|
return el;
|
|
330
467
|
}
|
|
331
468
|
|
|
@@ -333,108 +470,85 @@ function buildChrome() {
|
|
|
333
470
|
const style = document.createElement('style');
|
|
334
471
|
style.setAttribute('clay', RUNTIME_ONLY);
|
|
335
472
|
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
473
|
[editmode\\:contenteditable][contenteditable]:focus {
|
|
391
474
|
outline: 1px solid #4a4a6a; outline-offset: 4px; border-radius: 2px;
|
|
392
475
|
}
|
|
476
|
+
::highlight(clay-ai-edit) { background-color: ${TOKENS['brass-soft']} !important; }
|
|
393
477
|
`;
|
|
394
478
|
document.head.appendChild(style);
|
|
395
479
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
ring
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
480
|
+
const scheme = `color-scheme:${pageScheme()}`;
|
|
481
|
+
|
|
482
|
+
ring = marked(bevelBox('div', [
|
|
483
|
+
'box-sizing:content-box', 'position:fixed', 'z-index:99998', 'pointer-events:none',
|
|
484
|
+
`border:2px solid ${TOKENS.brass}`, 'border-radius:0', scheme,
|
|
485
|
+
]), 'data-clay-ai-edit', 'ring');
|
|
486
|
+
setShown(ring, false);
|
|
487
|
+
|
|
488
|
+
panel = marked(bevelSurface('div', [
|
|
489
|
+
'position:fixed', 'z-index:99999', 'width:min(28rem, calc(100vw - 32px))',
|
|
490
|
+
'padding:12px', 'border-radius:12px', `box-shadow:${SHADOW}`, `font:13px/1.5 ${FONT_SANS}`, scheme,
|
|
491
|
+
]), 'data-clay-ai-edit', 'panel');
|
|
492
|
+
setShown(panel, false);
|
|
493
|
+
panel.setAttribute('role', 'dialog');
|
|
494
|
+
panel.setAttribute('aria-label', 'AI edit');
|
|
495
|
+
|
|
496
|
+
const part = (el, name) => marked(el, 'data-clay-ai-edit-part', name);
|
|
497
|
+
|
|
498
|
+
// The notch on the panel's edge that points at the selected words.
|
|
499
|
+
pointer = part(bevelBox('div', [
|
|
500
|
+
'position:absolute', 'width:12px', 'height:12px', 'pointer-events:none',
|
|
501
|
+
`background:${TOKENS.surface}`, `border-left:1px solid ${TOKENS['line-2']}`, `border-top:1px solid ${TOKENS['line-2']}`,
|
|
502
|
+
]), 'pointer');
|
|
503
|
+
setShown(pointer, false);
|
|
504
|
+
|
|
505
|
+
textarea = part(bevelInput('textarea', {
|
|
506
|
+
rules: ['flex:1', 'min-width:0', 'min-height:40px', 'max-height:9.5em', 'resize:none', 'overflow-y:auto',
|
|
507
|
+
'padding:8px 12px', `font:15px/1.45 ${FONT_SANS}`],
|
|
508
|
+
}), 'input');
|
|
509
|
+
textarea.rows = 1;
|
|
510
|
+
textarea.placeholder = 'Describe the change';
|
|
511
|
+
textarea.setAttribute('aria-label', 'Describe the change');
|
|
512
|
+
textarea.title = 'Enter sends · Shift+Enter adds a line · @fable or @codex picks another agent · @file.ext adds context';
|
|
513
|
+
|
|
514
|
+
statusEl = part(bevelText('div', [
|
|
515
|
+
'display:block', 'margin-top:0', `color:${TOKENS.muted}`, `font:12.5px/1.5 ${FONT_MONO}`,
|
|
516
|
+
]), 'status');
|
|
517
|
+
statusEl.setAttribute('role', 'status');
|
|
518
|
+
statusEl.setAttribute('aria-live', 'polite');
|
|
519
|
+
|
|
520
|
+
const variants = { send: 'primary', stop: 'default', revert: 'quiet', keep: 'primary' };
|
|
521
|
+
const labels = { send: 'Send', stop: 'Stop', revert: 'Revert', keep: 'Keep' };
|
|
522
|
+
for (const name of ['send', 'stop', 'revert', 'keep']) {
|
|
523
|
+
buttons[name] = part(bevelButton(labels[name], { variant: variants[name], extra: ['min-height:40px', 'flex:none'] }), name);
|
|
524
|
+
setShown(buttons[name], name === 'send', 'inline-flex');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const row = bevelBox('div', ['display:flex', 'align-items:flex-start', 'gap:8px']);
|
|
528
|
+
row.append(textarea, buttons.send, buttons.stop, buttons.revert, buttons.keep);
|
|
415
529
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
chip.textContent = '\ud83d\udcac';
|
|
421
|
-
chip.hidden = true;
|
|
530
|
+
warningsEl = part(bevelText('div', [
|
|
531
|
+
'display:block', 'margin-top:8px', `color:${TOKENS.ox}`, 'white-space:pre-line',
|
|
532
|
+
]), 'warnings');
|
|
533
|
+
setShown(warningsEl, false);
|
|
422
534
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
535
|
+
panel.append(pointer, row, statusEl, warningsEl);
|
|
536
|
+
|
|
537
|
+
chip = marked(bevelButton('AI', {
|
|
538
|
+
small: true,
|
|
539
|
+
extra: ['position:fixed', 'width:26px', 'height:26px', `font:600 11px/1 ${FONT_SANS}`, ...FLOATING, scheme],
|
|
540
|
+
}), 'data-clay-ai-edit', 'chip');
|
|
541
|
+
chip.title = 'Comment on this (\u2318J)';
|
|
542
|
+
setShown(chip, false, 'inline-grid');
|
|
543
|
+
|
|
544
|
+
docBubble = marked(bevelButton('AI', {
|
|
545
|
+
extra: ['position:fixed', 'width:40px', 'height:40px', `font:600 14px/1 ${FONT_SANS}`, ...FLOATING, scheme],
|
|
546
|
+
}), 'data-clay-ai-edit', 'bubble');
|
|
426
547
|
docBubble.title = 'Comment on the whole page';
|
|
427
|
-
|
|
548
|
+
placeBubble();
|
|
428
549
|
|
|
429
550
|
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
|
-
}
|
|
551
|
+
syncBubble();
|
|
438
552
|
|
|
439
553
|
buttons.send.addEventListener('click', submit);
|
|
440
554
|
buttons.stop.addEventListener('click', cancelStream);
|
|
@@ -447,18 +561,110 @@ function buildChrome() {
|
|
|
447
561
|
}
|
|
448
562
|
});
|
|
449
563
|
|
|
564
|
+
// One line to start, growing with the request up to the max-height.
|
|
565
|
+
textarea.addEventListener('input', fitTextarea);
|
|
566
|
+
|
|
567
|
+
chip.addEventListener('mousedown', (event) => event.preventDefault()); // keeps the page's selection alive
|
|
450
568
|
chip.addEventListener('click', () => {
|
|
451
569
|
const target = chipTarget;
|
|
570
|
+
const selection = chipSelection;
|
|
452
571
|
hideChip();
|
|
453
|
-
if (target && !session) openPanel(target, quoteFromSelection(target));
|
|
572
|
+
if (target && !session) openPanel(target, selection || quoteFromSelection(target));
|
|
454
573
|
});
|
|
455
574
|
docBubble.addEventListener('click', () => {
|
|
456
575
|
if (session) return;
|
|
576
|
+
// Like a click outside, a second click keeps a typed comment: it closes an empty panel only.
|
|
577
|
+
if (!panel.hidden && anchorEl === document.body) {
|
|
578
|
+
if (textarea.value.trim()) textarea.focus();
|
|
579
|
+
else closePanel();
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
457
582
|
openPanel(document.body, quoteFromSelection(document.body));
|
|
458
583
|
});
|
|
459
584
|
|
|
460
585
|
window.addEventListener('scroll', () => { positionChrome(); hideChip(); }, { passive: true });
|
|
461
|
-
window.addEventListener('resize', positionChrome, { passive: true });
|
|
586
|
+
window.addEventListener('resize', () => { placeBubble(); if (panel && !panel.hidden) fitTextarea(); positionChrome(); }, { passive: true });
|
|
587
|
+
// The CMS toggle can arrive after this chrome, and a docked sidebar slides it left.
|
|
588
|
+
const replace = () => { placeBubble(); positionChrome(); };
|
|
589
|
+
document.addEventListener('hcms:open', () => { replace(); setTimeout(replace, 350); });
|
|
590
|
+
document.addEventListener('hcms:close', () => { replace(); setTimeout(replace, 350); });
|
|
591
|
+
new MutationObserver((records) => {
|
|
592
|
+
syncBubble();
|
|
593
|
+
const touched = (nodes) => [...nodes].some((n) => n.nodeType === 1 && n.hasAttribute('data-hcms-toggle-host'));
|
|
594
|
+
if (records.some((r) => touched(r.addedNodes) || touched(r.removedNodes))) replace();
|
|
595
|
+
}).observe(document.body, { childList: true });
|
|
596
|
+
|
|
597
|
+
// A target removed from the page ends its edit, so the save hold and the paused
|
|
598
|
+
// observers never outlive it.
|
|
599
|
+
new MutationObserver(() => {
|
|
600
|
+
if (anchorEl && !anchorEl.isConnected && !panel.hidden) closePanel();
|
|
601
|
+
}).observe(document.body, { childList: true, subtree: true });
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// The panel's height changes as the box grows and the status line fills, so it is
|
|
605
|
+
// placed again after each change.
|
|
606
|
+
function placePanelAgain() {
|
|
607
|
+
if (anchorEl?.isConnected) positionChrome();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function fitTextarea() {
|
|
611
|
+
set(textarea, 'height', 'auto');
|
|
612
|
+
if (textarea.value) set(textarea, 'height', textarea.scrollHeight + 2 + 'px');
|
|
613
|
+
placePanelAgain();
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// The whole-page bubble belongs to pages built from [data-edit-id] sections. A plain
|
|
617
|
+
// page gets the selection chip and the shortcut instead.
|
|
618
|
+
function syncBubble() {
|
|
619
|
+
const doc = docBubble?.ownerDocument;
|
|
620
|
+
if (!doc) return;
|
|
621
|
+
setShown(docBubble, helperState === 'ready' && !!doc.querySelector('[data-edit-id]'), 'inline-grid');
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// The selected words stay marked while the person types, through the CSS Custom
|
|
625
|
+
// Highlight API, so the page's DOM never changes.
|
|
626
|
+
function highlight(range) {
|
|
627
|
+
if (!range || typeof window.Highlight !== 'function' || !window.CSS?.highlights) return;
|
|
628
|
+
window.CSS.highlights.set(HIGHLIGHT, new window.Highlight(range));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function clearHighlight() {
|
|
632
|
+
window.CSS?.highlights?.delete(HIGHLIGHT);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function applyHelperState() {
|
|
636
|
+
const on = helperState === 'ready';
|
|
637
|
+
textarea.disabled = !on;
|
|
638
|
+
if (on) {
|
|
639
|
+
if (statusEl.textContent === OFF_MESSAGE) setStatus('');
|
|
640
|
+
if (!session) showButtons('send');
|
|
641
|
+
} else {
|
|
642
|
+
setStatus(OFF_MESSAGE, 'warn');
|
|
643
|
+
showButtons();
|
|
644
|
+
}
|
|
645
|
+
syncBubble();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// The host's toggle can change while the page is open, so each opening asks again.
|
|
649
|
+
function refreshHelperState() {
|
|
650
|
+
helperStateNow().then((state) => {
|
|
651
|
+
helperState = state || 'unavailable';
|
|
652
|
+
if (!panel.hidden && !session) applyHelperState();
|
|
653
|
+
else syncBubble();
|
|
654
|
+
}).catch(() => {});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// The bubble sits in the bottom-right corner, or just left of the CMS's "Edit content"
|
|
658
|
+
// toggle when the page has one, bottoms aligned, so the two never overlap.
|
|
659
|
+
function placeBubble() {
|
|
660
|
+
if (!docBubble) return;
|
|
661
|
+
const host = document.querySelector('[data-hcms-toggle-host]');
|
|
662
|
+
const rect = host && host.getBoundingClientRect();
|
|
663
|
+
const beside = !!rect && rect.width > 0 && rect.height > 0;
|
|
664
|
+
docBubble.pin({
|
|
665
|
+
right: beside ? `${Math.round((document.documentElement.clientWidth || window.innerWidth) - rect.left + 8)}px` : '16px',
|
|
666
|
+
bottom: beside ? `${Math.round((document.documentElement.clientHeight || window.innerHeight) - rect.bottom)}px` : '16px',
|
|
667
|
+
});
|
|
462
668
|
}
|
|
463
669
|
|
|
464
670
|
// ---------------------------------------------------------------- hover chip
|
|
@@ -467,17 +673,22 @@ function showChipFor(unit) {
|
|
|
467
673
|
clearTimeout(chipHideTimer);
|
|
468
674
|
chipHideTimer = null;
|
|
469
675
|
chipTarget = unit;
|
|
676
|
+
chipSelection = undefined;
|
|
470
677
|
const rect = unit.getBoundingClientRect();
|
|
471
|
-
chip.
|
|
472
|
-
|
|
473
|
-
|
|
678
|
+
chip.pin({
|
|
679
|
+
left: Math.min(rect.right + 8, window.innerWidth - 34) + 'px',
|
|
680
|
+
top: Math.max(8, rect.top) + 'px',
|
|
681
|
+
'color-scheme': pageScheme(),
|
|
682
|
+
});
|
|
683
|
+
setShown(chip, true, 'inline-grid');
|
|
474
684
|
}
|
|
475
685
|
|
|
476
686
|
function hideChip() {
|
|
477
687
|
clearTimeout(chipHideTimer);
|
|
478
688
|
chipHideTimer = null;
|
|
479
|
-
chip
|
|
689
|
+
setShown(chip, false, 'inline-grid');
|
|
480
690
|
chipTarget = null;
|
|
691
|
+
chipSelection = undefined;
|
|
481
692
|
}
|
|
482
693
|
|
|
483
694
|
function scheduleChipHide() {
|
|
@@ -485,79 +696,163 @@ function scheduleChipHide() {
|
|
|
485
696
|
chipHideTimer = setTimeout(hideChip, 400);
|
|
486
697
|
}
|
|
487
698
|
|
|
699
|
+
// The chip at the end of a selection: for people who do not know the shortcut, and
|
|
700
|
+
// for browsers that keep Ctrl+J for themselves.
|
|
701
|
+
function showChipForSelection() {
|
|
702
|
+
if (session || !panel.hidden || helperState !== 'ready' || pageOwnsFocus()) return;
|
|
703
|
+
const range = liveRange();
|
|
704
|
+
const target = range && targetFromRange(range);
|
|
705
|
+
const selection = target && selectionIn(range, target);
|
|
706
|
+
if (!selection) {
|
|
707
|
+
if (chipSelection) hideChip();
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const rects = typeof range.getClientRects === 'function' ? range.getClientRects() : [];
|
|
711
|
+
const end = rects.length ? rects[rects.length - 1] : target.getBoundingClientRect();
|
|
712
|
+
clearTimeout(chipHideTimer);
|
|
713
|
+
chipHideTimer = null;
|
|
714
|
+
chipTarget = target;
|
|
715
|
+
chipSelection = selection;
|
|
716
|
+
chip.pin({
|
|
717
|
+
left: Math.min(end.right + 6, window.innerWidth - 34) + 'px',
|
|
718
|
+
top: Math.max(8, end.top + end.height / 2 - 13) + 'px',
|
|
719
|
+
'color-scheme': pageScheme(),
|
|
720
|
+
});
|
|
721
|
+
setShown(chip, true, 'inline-grid');
|
|
722
|
+
}
|
|
723
|
+
|
|
488
724
|
function setStatus(text, tone) {
|
|
725
|
+
set(statusEl, 'margin-top', text ? '8px' : '0');
|
|
489
726
|
statusEl.textContent = text || '';
|
|
490
|
-
statusEl.
|
|
727
|
+
if (tone === 'warn') statusEl.setAttribute('data-tone', 'warn');
|
|
728
|
+
else statusEl.removeAttribute('data-tone');
|
|
729
|
+
set(statusEl, 'color', tone === 'warn' ? TOKENS.ox : TOKENS.muted);
|
|
730
|
+
placePanelAgain();
|
|
491
731
|
}
|
|
492
732
|
|
|
493
733
|
function showButtons(...names) {
|
|
494
734
|
for (const [name, button] of Object.entries(buttons)) {
|
|
495
|
-
button
|
|
735
|
+
setShown(button, names.includes(name), 'inline-flex');
|
|
496
736
|
}
|
|
737
|
+
fitTextarea();
|
|
497
738
|
}
|
|
498
739
|
|
|
499
740
|
function showWarnings(warnings) {
|
|
500
|
-
warningsEl
|
|
741
|
+
setShown(warningsEl, warnings.length > 0);
|
|
501
742
|
warningsEl.textContent = warnings.map(w => '\u26a0 ' + w).join('\n');
|
|
743
|
+
placePanelAgain();
|
|
502
744
|
}
|
|
503
745
|
|
|
504
746
|
function positionChrome() {
|
|
505
747
|
if (!anchorEl || panel.hidden) return;
|
|
506
748
|
if (!anchorEl.isConnected) { closePanel(); return; }
|
|
507
749
|
if (anchorEl === document.body) {
|
|
508
|
-
// document mode: no ring, panel pinned above the
|
|
509
|
-
ring
|
|
510
|
-
|
|
511
|
-
|
|
750
|
+
// document mode: no ring, panel pinned above the bubble
|
|
751
|
+
setShown(ring, false);
|
|
752
|
+
setShown(pointer, false);
|
|
753
|
+
const bubble = docBubble.getBoundingClientRect();
|
|
754
|
+
const width = panel.offsetWidth || 480;
|
|
755
|
+
set(panel, 'left', Math.max(16, Math.min((bubble.right || window.innerWidth - 16) - width, window.innerWidth - width - 16)) + 'px');
|
|
756
|
+
set(panel, 'top', Math.max(16, (bubble.top || window.innerHeight - 56) - (panel.offsetHeight || 120) - 12) + 'px');
|
|
512
757
|
return;
|
|
513
758
|
}
|
|
514
759
|
const rect = anchorEl.getBoundingClientRect();
|
|
515
|
-
ring
|
|
516
|
-
ring
|
|
517
|
-
ring
|
|
518
|
-
ring
|
|
519
|
-
ring
|
|
760
|
+
// With selected words the highlight marks them until Send; the ring is for a whole element and for a running edit.
|
|
761
|
+
setShown(ring, !pendingSelection || !!session);
|
|
762
|
+
set(ring, 'left', rect.left - 5 + 'px');
|
|
763
|
+
set(ring, 'top', rect.top - 5 + 'px');
|
|
764
|
+
set(ring, 'width', rect.width + 6 + 'px');
|
|
765
|
+
set(ring, 'height', rect.height + 6 + 'px');
|
|
766
|
+
// Under the selection while it is still in the page, else under the element.
|
|
767
|
+
const at = anchorRange && anchorRange.startContainer.isConnected && typeof anchorRange.getBoundingClientRect === 'function'
|
|
768
|
+
? anchorRange.getBoundingClientRect() : null;
|
|
769
|
+
const place = at && (at.width || at.height) ? at : rect;
|
|
520
770
|
const panelWidth = panel.offsetWidth || 480;
|
|
521
|
-
const left = Math.max(16, Math.min(
|
|
522
|
-
let top =
|
|
771
|
+
const left = Math.max(16, Math.min(place.left, window.innerWidth - panelWidth - 16));
|
|
772
|
+
let top = place.bottom + 10;
|
|
523
773
|
const panelHeight = panel.offsetHeight || 120;
|
|
524
774
|
if (top + panelHeight > window.innerHeight - 16) {
|
|
525
|
-
top = Math.max(16,
|
|
775
|
+
top = Math.max(16, place.top - panelHeight - 10);
|
|
526
776
|
}
|
|
527
|
-
panel
|
|
528
|
-
panel
|
|
777
|
+
set(panel, 'left', left + 'px');
|
|
778
|
+
set(panel, 'top', top + 'px');
|
|
779
|
+
|
|
780
|
+
// The pointer sits above the start of the selection, on the top edge, or on the
|
|
781
|
+
// bottom edge when the panel had to open above it.
|
|
782
|
+
const below = top >= place.bottom;
|
|
783
|
+
const tip = Math.max(14, Math.min(place.left + 10 - left, panelWidth - 28));
|
|
784
|
+
setShown(pointer, true);
|
|
785
|
+
set(pointer, 'left', tip + 'px');
|
|
786
|
+
set(pointer, 'top', below ? '-7px' : (panelHeight - 6) + 'px');
|
|
787
|
+
set(pointer, 'transform', below ? 'rotate(45deg)' : 'rotate(225deg)');
|
|
529
788
|
}
|
|
530
789
|
|
|
531
790
|
function openPanel(el, quote) {
|
|
791
|
+
const range = quote ? liveRange() : null; // read before focus moves into the panel
|
|
792
|
+
const active = document.activeElement;
|
|
793
|
+
returnFocus = active && active !== document.body && !panel.contains(active) ? active : null;
|
|
532
794
|
anchorEl = el;
|
|
795
|
+
anchorRange = range ? range.cloneRange() : null;
|
|
533
796
|
hideChip();
|
|
534
|
-
panel
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
797
|
+
set(panel, 'color-scheme', pageScheme());
|
|
798
|
+
set(ring, 'color-scheme', pageScheme());
|
|
799
|
+
setShown(panel, true);
|
|
800
|
+
pendingSelection = quote;
|
|
801
|
+
const shown = quote ? (quote.text.trim().length > 400 ? quote.text.trim().slice(0, 400) + '\u2026' : quote.text.trim()) : '';
|
|
802
|
+
panel.dataset.quote = shown;
|
|
538
803
|
setStatus('');
|
|
539
804
|
showWarnings([]);
|
|
540
805
|
showButtons('send');
|
|
806
|
+
highlight(anchorRange);
|
|
807
|
+
applyHelperState();
|
|
541
808
|
positionChrome();
|
|
542
809
|
textarea.focus();
|
|
810
|
+
refreshHelperState();
|
|
543
811
|
}
|
|
544
812
|
|
|
545
813
|
function closePanel() {
|
|
546
|
-
if (session) revertSession();
|
|
814
|
+
if (session) { session.handle?.cancel(); revertSession(); }
|
|
815
|
+
const back = returnFocus;
|
|
816
|
+
const hadFocus = panel.contains(document.activeElement);
|
|
547
817
|
anchorEl = null;
|
|
548
|
-
|
|
549
|
-
|
|
818
|
+
anchorRange = null;
|
|
819
|
+
returnFocus = null;
|
|
820
|
+
pendingSelection = undefined;
|
|
821
|
+
clearHighlight();
|
|
822
|
+
setShown(panel, false);
|
|
823
|
+
setShown(ring, false);
|
|
550
824
|
textarea.value = '';
|
|
825
|
+
fitTextarea();
|
|
826
|
+
if (hadFocus && back && back.isConnected) back.focus({ preventScroll: true });
|
|
551
827
|
}
|
|
552
828
|
|
|
553
829
|
function submit() {
|
|
554
830
|
const comment = textarea.value.trim();
|
|
555
|
-
if (!comment || !anchorEl || session) return;
|
|
556
|
-
|
|
831
|
+
if (!comment || !anchorEl || session || helperState !== 'ready') return;
|
|
832
|
+
clearHighlight();
|
|
833
|
+
sendRequest(anchorEl, comment, pendingSelection);
|
|
557
834
|
}
|
|
558
835
|
|
|
559
836
|
// ---------------------------------------------------------------- interactions
|
|
560
837
|
|
|
838
|
+
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
|
|
839
|
+
|
|
840
|
+
// Cmd+J on a Mac, Ctrl+J elsewhere. event.code covers keyboard layouts where the
|
|
841
|
+
// J key types another letter.
|
|
842
|
+
function isShortcut(event) {
|
|
843
|
+
const modifier = IS_MAC ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
|
|
844
|
+
if (!modifier || event.shiftKey || event.altKey || event.repeat || event.isComposing) return false;
|
|
845
|
+
return event.code === 'KeyJ' || (event.key || '').toLowerCase() === 'j';
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// A field the page owns keeps its own Ctrl+J, and so does the panel's own box.
|
|
849
|
+
function pageOwnsFocus() {
|
|
850
|
+
const el = document.activeElement;
|
|
851
|
+
if (!el || el === document.body) return false;
|
|
852
|
+
if (panel && panel.contains(el)) return !panel.hidden;
|
|
853
|
+
return el.matches('input,textarea,select');
|
|
854
|
+
}
|
|
855
|
+
|
|
561
856
|
function wireInteractions() {
|
|
562
857
|
// Clicks: text units are contenteditable (caret) and figures keep their own
|
|
563
858
|
// interactivity, so neither is intercepted. Only a click on bare section padding
|
|
@@ -581,6 +876,7 @@ function wireInteractions() {
|
|
|
581
876
|
if (now - lastMove < 80) return;
|
|
582
877
|
lastMove = now;
|
|
583
878
|
if (session || !panel.hidden) { scheduleChipHide(); return; }
|
|
879
|
+
if (helperState !== 'ready' || chipSelection) return;
|
|
584
880
|
if (chip.contains(event.target)) { clearTimeout(chipHideTimer); chipHideTimer = null; return; }
|
|
585
881
|
const unit = unitFrom(event.target);
|
|
586
882
|
if (unit) showChipFor(unit);
|
|
@@ -588,14 +884,22 @@ function wireInteractions() {
|
|
|
588
884
|
});
|
|
589
885
|
|
|
590
886
|
document.addEventListener('keydown', (event) => {
|
|
591
|
-
if ((event
|
|
592
|
-
if (session)
|
|
887
|
+
if (isShortcut(event)) {
|
|
888
|
+
if (!session && anchorEl && !anchorEl.isConnected) closePanel();
|
|
889
|
+
if (session || pageOwnsFocus()) return;
|
|
890
|
+
const range = liveRange();
|
|
891
|
+
let target = range ? targetFromRange(range) : null;
|
|
892
|
+
if (!target) {
|
|
893
|
+
// Section pages: a caret inside a unit, or the unit under the hover chip.
|
|
894
|
+
const selection = window.getSelection();
|
|
895
|
+
let node = selection && selection.anchorNode;
|
|
896
|
+
if (node && node.nodeType !== 1) node = node.parentElement;
|
|
897
|
+
target = (node && unitFrom(node)) || chipTarget;
|
|
898
|
+
}
|
|
899
|
+
// Nothing to open: leave the key to the browser.
|
|
900
|
+
if (!target) return;
|
|
593
901
|
event.preventDefault();
|
|
594
|
-
|
|
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));
|
|
902
|
+
openPanel(target, quoteFromSelection(target));
|
|
599
903
|
return;
|
|
600
904
|
}
|
|
601
905
|
if (event.key !== 'Escape' || panel.hidden) return;
|
|
@@ -609,13 +913,19 @@ function wireInteractions() {
|
|
|
609
913
|
closePanel();
|
|
610
914
|
}
|
|
611
915
|
});
|
|
916
|
+
|
|
917
|
+
document.addEventListener('selectionchange', () => {
|
|
918
|
+
clearTimeout(selectionTimer);
|
|
919
|
+
selectionTimer = setTimeout(showChipForSelection, 150);
|
|
920
|
+
});
|
|
612
921
|
}
|
|
613
922
|
|
|
614
923
|
// ---------------------------------------------------------------- boot
|
|
615
924
|
|
|
616
925
|
async function init() {
|
|
617
|
-
if (!isEditMode) return;
|
|
618
|
-
|
|
926
|
+
if (!isEditMode || panel) return; // an owner/editor feature, built once
|
|
927
|
+
helperState = await helperStateNow();
|
|
928
|
+
if (!helperState) return; // no ai-edit helper on this host: stay dormant
|
|
619
929
|
buildChrome();
|
|
620
930
|
wireInteractions();
|
|
621
931
|
}
|