dsh-edit-turn 0.2.1 → 0.2.5
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 +418 -62
- package/lib/client.js +641 -129
- package/lib/index.js +194 -64
- package/package.json +6 -2
package/lib/client.js
CHANGED
|
@@ -30,27 +30,25 @@ window.__ModuleLoader__.load({
|
|
|
30
30
|
const ROUTE_PREFIX = '/dsh-edit-turn'
|
|
31
31
|
|
|
32
32
|
/** Keep in sync with package.json and lib/index.js. */
|
|
33
|
-
const PLUGIN_VERSION = '0.2.
|
|
33
|
+
const PLUGIN_VERSION = '0.2.5'
|
|
34
34
|
|
|
35
35
|
// --- copy -----------------------------------------------------------------
|
|
36
36
|
|
|
37
37
|
const zh = {
|
|
38
|
-
'action.edit': '
|
|
38
|
+
'action.edit': '编辑这条消息',
|
|
39
39
|
'action.edit-reply': '编辑这条回答',
|
|
40
40
|
'editor.title': '编辑这条消息',
|
|
41
41
|
'editor.title-reply': '编辑这条回答',
|
|
42
|
-
'editor.placeholder': '
|
|
42
|
+
'editor.placeholder': '改成你希望自己当时说的内容。保存后这条消息之后的轮次会从模型上下文中移除,改后的内容成为新的起点。',
|
|
43
43
|
'editor.placeholder-reply': '改成你希望模型说过的内容,保存后它就成为这条回答。',
|
|
44
|
-
'editor.save': '
|
|
45
|
-
'editor.save-
|
|
44
|
+
'editor.save': '保存',
|
|
45
|
+
'editor.save-rerun': '保存并重跑',
|
|
46
|
+
'editor.save-reply': '保存',
|
|
46
47
|
'editor.review': '确认执行',
|
|
47
48
|
'editor.cancel': '取消',
|
|
48
|
-
'editor.pending': '
|
|
49
|
+
'editor.pending': '正在保存…',
|
|
49
50
|
'editor.warn.attachments': '这条消息包含图片或文件附件,改写会丢弃它们,只保留文字。',
|
|
50
51
|
'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
|
|
51
|
-
'editor.hint': '保存后,这条消息之后的全部内容(含当轮回复与工具调用)会从模型上下文中移除,并从这里重新开始。原始会话日志不会改写。',
|
|
52
|
-
'editor.hint-reply': '保存后,这条回答会被替换为你写的内容,它之后的内容一并移除。模型会把你写的内容当成自己说过的话,对话可以继续下去。原始会话日志不会改写。',
|
|
53
|
-
'confirm.title': '确认执行?',
|
|
54
52
|
'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
|
|
55
53
|
'error.invalid': '请求无效,请刷新后重试。',
|
|
56
54
|
'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
|
|
@@ -62,29 +60,27 @@ window.__ModuleLoader__.load({
|
|
|
62
60
|
'error.forbidden': '请求来源不被允许。',
|
|
63
61
|
'error.method': '请求方式不被接受,请刷新后重试。',
|
|
64
62
|
'error.generic': '编辑失败,请重试。',
|
|
65
|
-
'error.prompt': '
|
|
63
|
+
'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
|
|
66
64
|
'error.reply': '已回退,但替换没有落地。请重试。',
|
|
67
65
|
'error.empty': '改写后的内容不能为空。',
|
|
68
66
|
'error.internal': '服务器内部错误,请稍后重试。',
|
|
69
67
|
}
|
|
70
68
|
|
|
71
69
|
const en = {
|
|
72
|
-
'action.edit': 'Edit this message
|
|
70
|
+
'action.edit': 'Edit this message',
|
|
73
71
|
'action.edit-reply': 'Edit this reply',
|
|
74
72
|
'editor.title': 'Edit this message',
|
|
75
73
|
'editor.title-reply': 'Edit this reply',
|
|
76
74
|
'editor.placeholder': 'Saving rolls the conversation back to just before this message and this turn runs again.',
|
|
77
75
|
'editor.placeholder-reply': 'Write what you want the model to have said; saving makes it this reply.',
|
|
78
|
-
'editor.save': 'Save
|
|
79
|
-
'editor.save-
|
|
76
|
+
'editor.save': 'Save',
|
|
77
|
+
'editor.save-rerun': 'Save and re-run',
|
|
78
|
+
'editor.save-reply': 'Save',
|
|
80
79
|
'editor.review': 'Confirm',
|
|
81
80
|
'editor.cancel': 'Cancel',
|
|
82
81
|
'editor.pending': 'Working...',
|
|
83
82
|
'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
|
|
84
83
|
'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
|
|
85
|
-
'editor.hint': 'Saving removes everything after this message (including that turn\'s reply and tool calls) from the model context and starts again from here. The original session log is never rewritten.',
|
|
86
|
-
'editor.hint-reply': 'Saving replaces this reply with your text and removes everything after it. The model goes on treating your words as its own, so the conversation can continue from here. The original session log is never rewritten.',
|
|
87
|
-
'confirm.title': 'Confirm?',
|
|
88
84
|
'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
|
|
89
85
|
'error.invalid': 'Invalid request; refresh and try again.',
|
|
90
86
|
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
@@ -129,28 +125,77 @@ window.__ModuleLoader__.load({
|
|
|
129
125
|
const CSS = [
|
|
130
126
|
':root{--dshet-panel:rgba(255,255,255,.96);--dshet-field:rgba(244,247,252,.98);--dshet-chip:rgba(255,255,255,.82);--dshet-line:rgba(16,24,40,.16);--dshet-ink:#0f1524;--dshet-ink-dim:#4b5872;--dshet-hover:rgba(16,24,40,.07);--dshet-shadow:0 12px 32px rgba(9,18,40,.22);--dshet-accent:#4d6bfe;--dshet-on-accent:#fff}',
|
|
131
127
|
'body[data-ds-dark-theme]{--dshet-panel:rgba(22,28,44,.96);--dshet-field:rgba(14,19,33,.98);--dshet-chip:rgba(22,28,44,.82);--dshet-line:rgba(255,255,255,.18);--dshet-ink:#eef2f8;--dshet-ink-dim:#a6b1c6;--dshet-hover:rgba(255,255,255,.11);--dshet-shadow:0 12px 32px rgba(0,0,0,.5)}',
|
|
132
|
-
'
|
|
133
|
-
'
|
|
128
|
+
// The pencil sits among the platform's own action icons, so it takes the
|
|
129
|
+
// platform's icon colour: our own `--dshet-ink-dim` is a blue-grey and
|
|
130
|
+
// read as a different, "active" icon next to the neutral grey ones. The
|
|
131
|
+
// variable resolves on the action bar (and everywhere above it); the
|
|
132
|
+
// fallback is the value it resolves to in the default theme.
|
|
133
|
+
'.dshet-action{width:28px;height:28px;padding:6px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,#81858c);cursor:pointer;transition:background-color .12s,color .12s}',
|
|
134
|
+
'.dshet-action:hover:not(:disabled){background:var(--dshet-chip);box-shadow:inset 0 0 0 1px var(--dshet-line);color:var(--dsw-alias-label-primary,var(--dshet-ink))}',
|
|
134
135
|
'.dshet-action:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
|
|
135
136
|
'.dshet-action:disabled{cursor:default;opacity:.4}',
|
|
136
137
|
'.dshet-action svg{width:15px;height:15px}',
|
|
137
|
-
|
|
138
|
+
// The reply pencil leads its strip. The host draws the copy button ahead of
|
|
139
|
+
// the slot this entry is rendered into, and the strip lays its children out
|
|
140
|
+
// in DOM order, so in DOM position the pencil can only ever come second -
|
|
141
|
+
// behind the copy button, which is where the user does not look for it.
|
|
142
|
+
// `order` reorders the flex line without touching the host's own tree.
|
|
143
|
+
'.dshet-reply-action{order:-1}',
|
|
144
|
+
// A prompt this plugin rewrote in place: the platform renders no bubble for
|
|
145
|
+
// a replacement message, so this one is ours. Styled from the plugin own
|
|
146
|
+
// namespaced variables, which follow the active theme without borrowing
|
|
147
|
+
// CSS-module hashes that change between builds.
|
|
148
|
+
'.dshet-revision{box-sizing:border-box;align-self:flex-end;width:fit-content;max-width:min(72ch,86%);margin:6px 0 6px auto;padding:9px 14px;border-radius:16px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;line-height:inherit;white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}',
|
|
149
|
+
'.dshet-revision:empty{display:none}',
|
|
150
|
+
'.dshet-revision{position:relative}',
|
|
151
|
+
// Fallback only: a row with no action bar to keep is displayed away
|
|
152
|
+
// entirely, and then its pencil has nowhere but the bubble. It sits in a
|
|
153
|
+
// gutter the bubble reserves for it, never over the text - an absolutely
|
|
154
|
+
// positioned button inside the bubble used to land on the last line, which
|
|
155
|
+
// is how the rewritten prompt got covered up.
|
|
156
|
+
'.dshet-revision.dshet-revision-action{padding-inline-end:40px}',
|
|
157
|
+
'.dshet-revision .dshet-action-host{position:absolute;inset-inline-end:6px;top:6px;opacity:0;transition:opacity .12s ease}',
|
|
158
|
+
'.dshet-revision:hover .dshet-action-host,.dshet-revision:focus-within .dshet-action-host{opacity:1}',
|
|
159
|
+
// A sibling plugin can append its own button (dsh-delete-turn's bin) into
|
|
160
|
+
// the same bar at any time, which left this pencil one slot short of the
|
|
161
|
+
// right end: DOM insertion order can never promise that position, so flex
|
|
162
|
+
// order does. The bar is a flex container (`display:flex`) and every
|
|
163
|
+
// platform item in it is `order:0`; the gutter and floating placements
|
|
164
|
+
// ignore order, being absolutely positioned or in a block box.
|
|
165
|
+
'.dshet-action-host{display:inline-flex;align-items:center;justify-content:center;order:9}',
|
|
138
166
|
'.dshet-row{position:relative}',
|
|
139
167
|
'.dshet-floating{position:absolute;top:2px;right:6px;z-index:2;opacity:0;transition:opacity .12s}',
|
|
140
168
|
'.dshet-row:hover .dshet-floating,.dshet-floating:focus-within{opacity:1}',
|
|
141
169
|
'.dshet-collapsing{overflow:hidden;transition:height .2s ease,opacity .14s ease,margin .2s ease,padding .2s ease}',
|
|
142
|
-
|
|
143
|
-
'
|
|
144
|
-
|
|
145
|
-
'
|
|
170
|
+
// A row this plugin rolled back loses its message, not its action strip:
|
|
171
|
+
// the platform's time / copy / delete buttons (and every other plugin's)
|
|
172
|
+
// stay reachable, so only rows with nothing worth keeping go fully away.
|
|
173
|
+
'[data-dshet-hidden="1"]:not([data-dshet-keep-actions]){display:none!important}',
|
|
174
|
+
// One rounded container, the way the platform's own composer box is
|
|
175
|
+
// built: the input is transparent and borderless inside it, the actions
|
|
176
|
+
// sit at its bottom-right, and focus is shown on the container rather
|
|
177
|
+
// than on a second box inside it. The input grows with its text (the
|
|
178
|
+
// render/input paths call `fitEditorHeight`); `display:block` removes the
|
|
179
|
+
// inline-block baseline gap a textarea would otherwise leave.
|
|
180
|
+
// The editor lives OUTSIDE the message row, on a layer of its own pinned
|
|
181
|
+
// to the body: the host re-renders that row whenever it likes, and an
|
|
182
|
+
// input inside a subtree it rebuilds loses its drag selection, its IME
|
|
183
|
+
// composition and its clicks a piece at a time. The layer covers the
|
|
184
|
+
// viewport but only the box itself takes pointer events, and the box is
|
|
185
|
+
// positioned under its row by `placeEditor`.
|
|
186
|
+
'.dshet-layer{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:40}',
|
|
187
|
+
'.dshet-layer .dshet-editor{position:absolute;pointer-events:auto}',
|
|
188
|
+
'.dshet-editor{margin:0;border:1px solid var(--dshet-line);border-radius:16px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink);transition:border-color .12s}',
|
|
189
|
+
'.dshet-editor:focus-within{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
|
|
190
|
+
'.dshet-editor textarea{display:block;width:100%;min-height:47px;max-height:46vh;overflow-y:auto;resize:none;box-sizing:border-box;padding:11px 14px 0;border:0;background:transparent;color:inherit;font:inherit;font-size:15px;line-height:25px;outline:none}',
|
|
146
191
|
'.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
|
|
147
|
-
'.dshet-editor textarea:focus{outline:none;border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
|
|
148
192
|
'.dshet-editor textarea:disabled{opacity:.65}',
|
|
149
|
-
'.dshet-note{margin:
|
|
150
|
-
'.dshet-
|
|
151
|
-
'.dshet-
|
|
152
|
-
'.dshet-
|
|
153
|
-
'.dshet-
|
|
193
|
+
'.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
|
|
194
|
+
'.dshet-note{color:var(--dshet-ink-dim)}',
|
|
195
|
+
'.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
|
|
196
|
+
'.dshet-error{color:var(--dsw-alias-state-error-primary,#c93a31)}',
|
|
197
|
+
'.dshet-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:4px 10px 10px}',
|
|
198
|
+
'.dshet-btn{padding:6px 16px;border:1px solid var(--dshet-line);border-radius:999px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background-color .12s,border-color .12s}',
|
|
154
199
|
'.dshet-btn:hover:not(:disabled){background:var(--dshet-hover)}',
|
|
155
200
|
'.dshet-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
|
|
156
201
|
'.dshet-btn:disabled{cursor:default;opacity:.5}',
|
|
@@ -208,12 +253,22 @@ window.__ModuleLoader__.load({
|
|
|
208
253
|
this.animateOnce = false
|
|
209
254
|
this.rowCount = -1
|
|
210
255
|
this.editor = null
|
|
256
|
+
// The live textarea and whether it currently holds the user's focus.
|
|
257
|
+
// Focus is only ever taken on open, or handed back to a REBUILT box
|
|
258
|
+
// when the user was typing in the one that was just replaced - never
|
|
259
|
+
// taken from somewhere else. Rebuilding used to call `focus()`
|
|
260
|
+
// unconditionally, so any host re-render of the row pulled the caret
|
|
261
|
+
// out of whatever the user was typing in (the composer included).
|
|
211
262
|
this.editorElement = null
|
|
263
|
+
this.editorBox = null
|
|
264
|
+
this.editorRow = null
|
|
265
|
+
this.editorFocus = false
|
|
212
266
|
this.editorDraft = ''
|
|
213
267
|
this.confirming = false
|
|
214
268
|
this.noticeTimer = null
|
|
215
269
|
this.view = Object.freeze({
|
|
216
270
|
hidden: new Map(),
|
|
271
|
+
revisions: new Map(),
|
|
217
272
|
editable: new Map(),
|
|
218
273
|
replies: new Map(),
|
|
219
274
|
repliesByMessage: new Map(),
|
|
@@ -229,6 +284,7 @@ window.__ModuleLoader__.load({
|
|
|
229
284
|
// Matches the host's default: one click applies. Only an explicit
|
|
230
285
|
// `confirm: true` in the profile turns the second step back on.
|
|
231
286
|
confirmStep: false,
|
|
287
|
+
rerun: false,
|
|
232
288
|
revision: 0,
|
|
233
289
|
})
|
|
234
290
|
}
|
|
@@ -260,7 +316,11 @@ window.__ModuleLoader__.load({
|
|
|
260
316
|
}
|
|
261
317
|
|
|
262
318
|
load(force) {
|
|
263
|
-
|
|
319
|
+
// A refresh asked for while one is already running has to land *after*
|
|
320
|
+
// it: handing back the in-flight response would answer with a snapshot
|
|
321
|
+
// the host may have taken before a save, wiping out the very state that
|
|
322
|
+
// save just published.
|
|
323
|
+
if (this.inflight !== null) return this.inflight.then(() => this.load(force))
|
|
264
324
|
if (this.view.loaded && force !== true) return Promise.resolve()
|
|
265
325
|
const url = `${ROUTE_PREFIX}/state?sessionId=${encodeURIComponent(this.sessionId)}`
|
|
266
326
|
const pending = fetch(url, { headers: { accept: 'application/json' } })
|
|
@@ -268,8 +328,14 @@ window.__ModuleLoader__.load({
|
|
|
268
328
|
const data = await res.json().catch(() => ({}))
|
|
269
329
|
if (!res.ok || !data.ok) throw new Error(data && data.error ? String(data.error) : `HTTP ${res.status}`)
|
|
270
330
|
const hidden = new Map()
|
|
331
|
+
// seq -> the seq of the message that now stands in its place. A prompt
|
|
332
|
+
// this plugin rewrote travels as a replacement carrier, so its revised
|
|
333
|
+
// text has to be shown exactly where the old message was shown.
|
|
334
|
+
const revisions = new Map()
|
|
271
335
|
for (const item of Array.isArray(data.hidden) ? data.hidden : []) {
|
|
272
|
-
if (item
|
|
336
|
+
if (!item || typeof item.seq !== 'number') continue
|
|
337
|
+
hidden.set(item.seq, item.turn)
|
|
338
|
+
if (typeof item.replacement === 'number') revisions.set(item.seq, item.replacement)
|
|
273
339
|
}
|
|
274
340
|
const editable = new Map()
|
|
275
341
|
for (const turn of Array.isArray(data.turns) ? data.turns : []) {
|
|
@@ -289,6 +355,7 @@ window.__ModuleLoader__.load({
|
|
|
289
355
|
}
|
|
290
356
|
this.publish({
|
|
291
357
|
hidden,
|
|
358
|
+
revisions,
|
|
292
359
|
editable,
|
|
293
360
|
replies,
|
|
294
361
|
repliesByMessage,
|
|
@@ -296,6 +363,9 @@ window.__ModuleLoader__.load({
|
|
|
296
363
|
loaded: true,
|
|
297
364
|
loadError: false,
|
|
298
365
|
confirmStep: !(data.config && data.config.confirm === false),
|
|
366
|
+
// Off by default: saving writes the revision into the context and waits
|
|
367
|
+
// for the next message rather than spending a model call.
|
|
368
|
+
rerun: Boolean(data.config && data.config.rerun === true),
|
|
299
369
|
})
|
|
300
370
|
})
|
|
301
371
|
.catch(() => {
|
|
@@ -309,16 +379,38 @@ window.__ModuleLoader__.load({
|
|
|
309
379
|
}
|
|
310
380
|
|
|
311
381
|
open(target) {
|
|
382
|
+
// Pressing the pencil again for the SAME message re-focuses the open
|
|
383
|
+
// box instead of starting over: resetting the draft to the original
|
|
384
|
+
// text silently threw away whatever had been typed.
|
|
385
|
+
const same = this.editor !== null && this.editor.seq === target.seq && this.editor.mode === target.mode
|
|
386
|
+
const previous = this.editorElement
|
|
312
387
|
this.load()
|
|
313
388
|
this.editor = target
|
|
314
|
-
this.editorDraft = target.text
|
|
389
|
+
if (!same) this.editorDraft = target.text
|
|
315
390
|
this.confirming = false
|
|
316
391
|
this.editorElement = null
|
|
317
|
-
|
|
392
|
+
// The intent to keep the user's caret in this box. It is set when the
|
|
393
|
+
// editor opens and cleared only when the user presses somewhere
|
|
394
|
+
// outside it - never by a blur: the host rebuilding the row (or a
|
|
395
|
+
// temporary disable while saving) removes or disables the node, and
|
|
396
|
+
// treating that as "the user left" dropped the caret for good.
|
|
397
|
+
this.editorFocus = true
|
|
398
|
+
if (same && previous !== null && typeof previous.focus === 'function') {
|
|
399
|
+
try {
|
|
400
|
+
previous.focus()
|
|
401
|
+
} catch {
|
|
402
|
+
/* focus is best-effort */
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
this.publish({ editing: target, draft: this.editorDraft, confirming: false, failure: null })
|
|
318
406
|
}
|
|
319
407
|
|
|
320
408
|
close() {
|
|
321
409
|
if (this.view.pending) return
|
|
410
|
+
this.editorElement = null
|
|
411
|
+
this.editorBox = null
|
|
412
|
+
this.editorRow = null
|
|
413
|
+
this.editorFocus = false
|
|
322
414
|
this.editor = null
|
|
323
415
|
this.editorDraft = ''
|
|
324
416
|
this.confirming = false
|
|
@@ -374,12 +466,31 @@ window.__ModuleLoader__.load({
|
|
|
374
466
|
this.publish({ pending: false, failure: data && data.code ? String(data.code) : 'generic' })
|
|
375
467
|
return
|
|
376
468
|
}
|
|
469
|
+
const shadowed = Array.isArray(data.shadowed) ? data.shadowed : []
|
|
377
470
|
const hidden = new Map(this.view.hidden)
|
|
378
|
-
for (const seq of
|
|
471
|
+
for (const seq of shadowed) hidden.set(seq, target.turn)
|
|
472
|
+
// A rewritten prompt lands as the rollback's replacement carrier, so
|
|
473
|
+
// its revised text has to be shown exactly where the old message was
|
|
474
|
+
// shown - and it has to be shown right away, because the row is hidden
|
|
475
|
+
// in this very pass. A rewritten reply is appended as its own new
|
|
476
|
+
// reply instead, so it gets no standing-in entry here: that text
|
|
477
|
+
// arrives with the refresh below, on a row of its own.
|
|
478
|
+
const standsIn = typeof data.replacementSeq === 'number' && data.kind !== 'reply'
|
|
479
|
+
const revisions = new Map(this.view.revisions)
|
|
480
|
+
if (standsIn) for (const seq of shadowed) revisions.set(seq, data.replacementSeq)
|
|
379
481
|
const editable = new Map(this.view.editable)
|
|
380
482
|
for (const entry of editable.values()) {
|
|
381
483
|
if (hidden.has(entry.seq)) editable.delete(entry.seq)
|
|
382
484
|
}
|
|
485
|
+
if (standsIn) {
|
|
486
|
+
editable.set(data.replacementSeq, {
|
|
487
|
+
seq: data.replacementSeq,
|
|
488
|
+
turn: target.turn,
|
|
489
|
+
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
490
|
+
text,
|
|
491
|
+
attachments: typeof target.attachments === 'number' ? target.attachments : 0,
|
|
492
|
+
})
|
|
493
|
+
}
|
|
383
494
|
// A reply edit shadows its own reply too, so it leaves the editable
|
|
384
495
|
// replies here and re-enters through the next /state refresh (the
|
|
385
496
|
// appended correction is itself an editable reply).
|
|
@@ -391,12 +502,16 @@ window.__ModuleLoader__.load({
|
|
|
391
502
|
this.editor = null
|
|
392
503
|
this.editorDraft = ''
|
|
393
504
|
this.editorElement = null
|
|
505
|
+
this.editorBox = null
|
|
506
|
+
this.editorRow = null
|
|
507
|
+
this.editorFocus = false
|
|
394
508
|
this.confirming = false
|
|
395
509
|
this.publish({
|
|
396
510
|
pending: false,
|
|
397
511
|
editing: null,
|
|
398
512
|
confirming: false,
|
|
399
513
|
hidden,
|
|
514
|
+
revisions,
|
|
400
515
|
editable,
|
|
401
516
|
replies,
|
|
402
517
|
failure: null,
|
|
@@ -404,7 +519,14 @@ window.__ModuleLoader__.load({
|
|
|
404
519
|
// The rollback has landed, so the editor is gone with its row: a
|
|
405
520
|
// failed second half has to be reported somewhere that survives that.
|
|
406
521
|
if (data.kind === 'reply' && data.applied === false) this.notify('reply')
|
|
407
|
-
if (data.kind !== 'reply' && data.
|
|
522
|
+
if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
|
|
523
|
+
// The view above is complete enough to render, but the host is the
|
|
524
|
+
// authority on what the session now looks like: only its answer knows
|
|
525
|
+
// how many rows the platform decided to draw, and what the appended
|
|
526
|
+
// correction or the re-run actually contains. Without this the client
|
|
527
|
+
// kept the optimistic snapshot until something else happened to move
|
|
528
|
+
// the row count, which for a rewritten message was never.
|
|
529
|
+
await this.load(true)
|
|
408
530
|
} catch {
|
|
409
531
|
this.publish({ pending: false, failure: 'generic' })
|
|
410
532
|
}
|
|
@@ -430,6 +552,9 @@ window.__ModuleLoader__.load({
|
|
|
430
552
|
if (this.noticeTimer !== null) window.clearTimeout(this.noticeTimer)
|
|
431
553
|
this.noticeTimer = null
|
|
432
554
|
this.editorElement = null
|
|
555
|
+
this.editorBox = null
|
|
556
|
+
this.editorRow = null
|
|
557
|
+
this.editorFocus = false
|
|
433
558
|
}
|
|
434
559
|
}
|
|
435
560
|
|
|
@@ -519,10 +644,187 @@ window.__ModuleLoader__.load({
|
|
|
519
644
|
|
|
520
645
|
const rowActions = new WeakMap()
|
|
521
646
|
|
|
647
|
+
// Show a rewritten prompt where its original row is.
|
|
648
|
+
//
|
|
649
|
+
// The bubble is cloned from a real user bubble's classes at runtime - the
|
|
650
|
+
// platform's class names are CSS-module hashes that change between builds, so
|
|
651
|
+
// copying a live one keeps skins and themes working without hardcoding them.
|
|
652
|
+
//
|
|
653
|
+
// Returns whether the row may be hidden now. `false` means this is a user
|
|
654
|
+
// message with a replacement recorded for it but no replacement text on
|
|
655
|
+
// screen - a snapshot that has not caught up yet - and hiding it would leave
|
|
656
|
+
// the transcript with nothing where the message was.
|
|
657
|
+
function renderRevision(row, node, view, hidden, controller, t) {
|
|
658
|
+
let revisionSeq = null
|
|
659
|
+
for (const seq of seqsFor(node)) {
|
|
660
|
+
if (view.revisions.has(seq)) {
|
|
661
|
+
revisionSeq = seq
|
|
662
|
+
break
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const existing = ownRevisionBubble(row)
|
|
666
|
+
if (revisionSeq === null || !hidden) {
|
|
667
|
+
if (existing !== null) existing.remove()
|
|
668
|
+
return true
|
|
669
|
+
}
|
|
670
|
+
const entry = view.editable.get(view.revisions.get(revisionSeq))
|
|
671
|
+
const text = entry && typeof entry.text === 'string' ? entry.text : ''
|
|
672
|
+
if (text === '') {
|
|
673
|
+
if (existing !== null) existing.remove()
|
|
674
|
+
return node.kind !== 'user'
|
|
675
|
+
}
|
|
676
|
+
const bubble = existing === null ? cloneUserBubble() : existing
|
|
677
|
+
if (existing === null) bubble.dataset.dshetRevision = '1'
|
|
678
|
+
placeRevisionBubble(row, bubble)
|
|
679
|
+
if (bubble.textContent !== text) bubble.textContent = text
|
|
680
|
+
// The edit entry goes where every other action of this row lives: the
|
|
681
|
+
// platform's own bar, which the collapse keeps standing, at its right end
|
|
682
|
+
// behind the clock and the copy button - the row the user reads as "this
|
|
683
|
+
// message", so that is where its actions belong. The bubble's gutter is
|
|
684
|
+
// only the fallback for a row with no bar at all: that row is displayed
|
|
685
|
+
// away entirely and the bubble stands before it, so there is nothing to
|
|
686
|
+
// join. Injected after the text assignment so the host is not wiped.
|
|
687
|
+
if (entry) {
|
|
688
|
+
const editTarget = { ...entry, mode: 'prompt' }
|
|
689
|
+
if (row.querySelector('[class*="_actions"]') !== null) {
|
|
690
|
+
injectRowAction(row, editTarget, controller, t)
|
|
691
|
+
removeRowAction(bubble)
|
|
692
|
+
bubble.classList.remove('dshet-revision-action')
|
|
693
|
+
} else {
|
|
694
|
+
injectRowAction(bubble, editTarget, controller, t, { gutter: true })
|
|
695
|
+
removeRowAction(row)
|
|
696
|
+
bubble.classList.add('dshet-revision-action')
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
removeRowAction(bubble)
|
|
700
|
+
bubble.classList.remove('dshet-revision-action')
|
|
701
|
+
}
|
|
702
|
+
return true
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// A copy of a live user bubble element, classes and all.
|
|
706
|
+
function cloneUserBubble() {
|
|
707
|
+
const sampleRow = document.querySelector('[data-chat-flow-kind="user"]')
|
|
708
|
+
const sample = sampleRow === null ? null : sampleRow.firstElementChild
|
|
709
|
+
const bubble = document.createElement('div')
|
|
710
|
+
bubble.className = 'dshet-revision'
|
|
711
|
+
return bubble
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// The standing-in bubble this row was given, wherever the last pass put it.
|
|
715
|
+
//
|
|
716
|
+
// It lives inside the row - the platform's own message, not a sibling of it -
|
|
717
|
+
// but a row that could not keep its action bar is displayed away entirely and
|
|
718
|
+
// takes its children with it, so the bubble is then parked before the row.
|
|
719
|
+
// Both positions have to be recognised or every pass would plant a second one.
|
|
720
|
+
function ownRevisionBubble(row) {
|
|
721
|
+
for (const child of row.children) {
|
|
722
|
+
if (child.classList && child.classList.contains('dshet-revision')) return child
|
|
723
|
+
}
|
|
724
|
+
const previous = row.previousElementSibling
|
|
725
|
+
if (previous !== null && previous.classList && previous.classList.contains('dshet-revision')) {
|
|
726
|
+
return previous
|
|
727
|
+
}
|
|
728
|
+
return null
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Plant the bubble where the message it replaces used to be: inside the row,
|
|
732
|
+
// ahead of the node that carries the action bar, so the rewritten prompt reads
|
|
733
|
+
// above the timestamp and the copy button - the order the platform draws a
|
|
734
|
+
// message and its bar in. Planting it after the row put the bar on top of the
|
|
735
|
+
// text, as if the time and the copy belonged to whatever came next.
|
|
736
|
+
//
|
|
737
|
+
// Idempotent, and only ever moves the node when the position is wrong: this
|
|
738
|
+
// runs on every DOM pass, and rewriting the tree the observer watches would
|
|
739
|
+
// never settle.
|
|
740
|
+
function placeRevisionBubble(row, bubble) {
|
|
741
|
+
const strip = row.querySelector('[class*="_actions"]')
|
|
742
|
+
let holder = strip
|
|
743
|
+
while (strip !== null && holder !== null && holder.parentElement !== row) {
|
|
744
|
+
holder = holder.parentElement
|
|
745
|
+
}
|
|
746
|
+
const inside = strip !== null && holder !== null && holder.parentElement === row
|
|
747
|
+
if (!inside && row.parentElement === null) return
|
|
748
|
+
const parent = inside ? row : row.parentElement
|
|
749
|
+
const before = inside ? holder : row
|
|
750
|
+
if (bubble.parentElement !== parent || bubble.nextElementSibling !== before) {
|
|
751
|
+
parent.insertBefore(bubble, before)
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Collapse a row's message while leaving its action strip standing.
|
|
756
|
+
//
|
|
757
|
+
// The platform's own time / copy / delete bar - and every other plugin's
|
|
758
|
+
// buttons - live in the same row as the prompt this plugin rolls back.
|
|
759
|
+
// Displaying the row away took them with it, so a rewritten prompt left the
|
|
760
|
+
// user no way to copy, read or delete the original. The row therefore stays:
|
|
761
|
+
// everything that leads to the strip collapses, the strip does not, and the
|
|
762
|
+
// marker attribute tells the stylesheet that the row itself must survive.
|
|
763
|
+
//
|
|
764
|
+
// Structure-agnostic on purpose - the platform's row classes are CSS-module
|
|
765
|
+
// hashes that change between builds, so the walk starts from the strip (whose
|
|
766
|
+
// `_actions` suffix is stable) and hides that strip's ancestors' other
|
|
767
|
+
// children up to the row. Returns false when there is no strip to keep, and
|
|
768
|
+
// false is what tells the caller to hide the whole row instead.
|
|
769
|
+
function collapseRowContent(row, hide) {
|
|
770
|
+
const strip = row.querySelector('[class*="_actions"]')
|
|
771
|
+
if (strip === null) return false
|
|
772
|
+
let node = strip
|
|
773
|
+
while (node !== null && node !== row) {
|
|
774
|
+
const parent = node.parentElement
|
|
775
|
+
if (parent === null) return false
|
|
776
|
+
for (const child of parent.children) {
|
|
777
|
+
if (child === node) continue
|
|
778
|
+
// This plugin's own furniture lives beside the message too: the
|
|
779
|
+
// in-place editor, a floating pencil and the bubble standing in for
|
|
780
|
+
// the rewritten prompt are not message content. The bubble is what
|
|
781
|
+
// the transcript has left of the message once this walk is done -
|
|
782
|
+
// hiding it here would take the text away a second time.
|
|
783
|
+
if (
|
|
784
|
+
child.classList &&
|
|
785
|
+
(child.classList.contains('dshet-editor') ||
|
|
786
|
+
child.classList.contains('dshet-action-host') ||
|
|
787
|
+
child.classList.contains('dshet-revision'))
|
|
788
|
+
) {
|
|
789
|
+
continue
|
|
790
|
+
}
|
|
791
|
+
if (hide) {
|
|
792
|
+
// Written only when the value changes: this runs on every DOM pass,
|
|
793
|
+
// and an attribute write the observer can see would never settle.
|
|
794
|
+
if (child.style.display !== 'none') child.style.display = 'none'
|
|
795
|
+
if (child.dataset.dshetCollapsed !== '1') child.dataset.dshetCollapsed = '1'
|
|
796
|
+
} else if (child.dataset.dshetCollapsed === '1') {
|
|
797
|
+
child.style.display = ''
|
|
798
|
+
delete child.dataset.dshetCollapsed
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
node = parent
|
|
802
|
+
}
|
|
803
|
+
return true
|
|
804
|
+
}
|
|
805
|
+
|
|
522
806
|
function setRowHidden(row, hide, animate) {
|
|
807
|
+
// A turn tail is not a message row. It is the strip a turn's end renders
|
|
808
|
+
// (duration, usage, actions); when the turn behind it is rolled back the
|
|
809
|
+
// strip is an orphan with nothing left to act on. Keeping it, which is
|
|
810
|
+
// right for a message row, stacked one empty strip per edit and pushed
|
|
811
|
+
// the conversation down the page - the report was "every save moves the
|
|
812
|
+
// model's answer further down".
|
|
813
|
+
const isTail = row.getAttribute('data-chat-flow-kind') === 'turn-tail'
|
|
523
814
|
if (hide) {
|
|
524
|
-
|
|
815
|
+
const keepsStrip = isTail ? false : collapseRowContent(row, true)
|
|
816
|
+
if (keepsStrip) {
|
|
817
|
+
if (row.dataset.dshetKeepActions !== '1') row.dataset.dshetKeepActions = '1'
|
|
818
|
+
} else if (row.dataset.dshetKeepActions === '1') {
|
|
819
|
+
// Older passes marked this tail as keep-actions; with the rule fixed
|
|
820
|
+
// the attribute has to go, or the row stays visible through it.
|
|
821
|
+
delete row.dataset.dshetKeepActions
|
|
822
|
+
}
|
|
823
|
+
if (row.dataset.dshetHidden === '1') {
|
|
824
|
+
if (keepsStrip || row.style.display === 'none') return
|
|
825
|
+
}
|
|
525
826
|
row.dataset.dshetHidden = '1'
|
|
827
|
+
if (keepsStrip) return
|
|
526
828
|
if (!animate || typeof requestAnimationFrame !== 'function') {
|
|
527
829
|
row.style.display = 'none'
|
|
528
830
|
return
|
|
@@ -546,8 +848,10 @@ window.__ModuleLoader__.load({
|
|
|
546
848
|
}, 240)
|
|
547
849
|
return
|
|
548
850
|
}
|
|
851
|
+
collapseRowContent(row, false)
|
|
549
852
|
if (row.dataset.dshetHidden !== '1') return
|
|
550
853
|
delete row.dataset.dshetHidden
|
|
854
|
+
if (row.dataset.dshetKeepActions === '1') delete row.dataset.dshetKeepActions
|
|
551
855
|
row.classList.remove('dshet-collapsing')
|
|
552
856
|
row.style.display = ''
|
|
553
857
|
row.style.height = ''
|
|
@@ -581,26 +885,45 @@ window.__ModuleLoader__.load({
|
|
|
581
885
|
// itself - then the next pass put it back, which reads as flicker.
|
|
582
886
|
if (options.fallback === true) button.dataset.dshetFallback = '1'
|
|
583
887
|
host.appendChild(button)
|
|
584
|
-
entry = { host, button }
|
|
888
|
+
entry = { host, button, target }
|
|
889
|
+
// Wired once, and the action reads the CURRENT target off the entry: the
|
|
890
|
+
// pass runs again on every snapshot, and re-wiring would reset the
|
|
891
|
+
// press guard between a pointerdown and the click it swallows.
|
|
892
|
+
pressable(button, () => controller.open(entry.target))
|
|
585
893
|
rowActions.set(row, entry)
|
|
586
894
|
}
|
|
895
|
+
entry.target = target
|
|
587
896
|
const { host, button } = entry
|
|
588
897
|
if (button.getAttribute('aria-label') !== label) {
|
|
589
898
|
button.setAttribute('aria-label', label)
|
|
590
899
|
button.setAttribute('title', label)
|
|
591
900
|
}
|
|
592
|
-
button.onclick = (event) => {
|
|
593
|
-
event.preventDefault()
|
|
594
|
-
event.stopPropagation()
|
|
595
|
-
controller.open(target)
|
|
596
|
-
}
|
|
597
901
|
// A caller that already picked the right bar (the turn tail's own strip)
|
|
598
902
|
// hands it over; otherwise the first actions container in the row wins.
|
|
599
|
-
const anchor =
|
|
903
|
+
const anchor =
|
|
904
|
+
options.gutter === true ? null : options.anchor ?? row.querySelector('[class*="_actions"]')
|
|
600
905
|
if (anchor) {
|
|
601
906
|
host.classList.remove('dshet-floating')
|
|
602
907
|
row.classList.remove('dshet-row')
|
|
603
|
-
|
|
908
|
+
// The clock and the usage pill trail the actions in that strip, so a
|
|
909
|
+
// plain append parks an action behind the row's trailing info. Sit after
|
|
910
|
+
// the LAST platform action icon instead - that is where the host seats
|
|
911
|
+
// its own extra actions, and where the user row keeps its pencil.
|
|
912
|
+
const lastAction = lastPlatformAction(anchor)
|
|
913
|
+
// On the second pass this pencil is already `lastAction.nextSibling`, and
|
|
914
|
+
// inserting a node before itself is not a no-op in every engine - skip
|
|
915
|
+
// past ourselves so the position stays put instead of drifting to the end.
|
|
916
|
+
let next = lastAction === null ? null : lastAction.nextSibling
|
|
917
|
+
if (next === host) next = next.nextSibling
|
|
918
|
+
if (host.parentElement !== anchor || host.nextSibling !== next) anchor.insertBefore(host, next)
|
|
919
|
+
} else if (options.gutter === true) {
|
|
920
|
+
// The revision bubble is ours, not the platform's: it has no action bar
|
|
921
|
+
// to join, so the pencil goes into the bubble's own reserved gutter
|
|
922
|
+
// (`.dshet-revision-action`). Never `.dshet-floating` - that rule is
|
|
923
|
+
// positioned against the row, which put the button on top of the text.
|
|
924
|
+
host.classList.remove('dshet-floating')
|
|
925
|
+
row.classList.remove('dshet-row')
|
|
926
|
+
if (host.parentElement !== row) row.appendChild(host)
|
|
604
927
|
} else {
|
|
605
928
|
host.classList.add('dshet-floating')
|
|
606
929
|
row.classList.add('dshet-row')
|
|
@@ -608,11 +931,89 @@ window.__ModuleLoader__.load({
|
|
|
608
931
|
}
|
|
609
932
|
}
|
|
610
933
|
|
|
934
|
+
// The platform's own action icons all carry a hashed class ending in
|
|
935
|
+
// `_action`; the clock, the usage pill and anything this plugin adds do not
|
|
936
|
+
// (ours are hyphenated). Matching that suffix avoids CSS-module hashes
|
|
937
|
+
// entirely - they change between builds.
|
|
938
|
+
function lastPlatformAction(bar) {
|
|
939
|
+
const children = Array.from(bar.children)
|
|
940
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
941
|
+
const child = children[index]
|
|
942
|
+
const className = typeof child.className === 'string' ? child.className : ''
|
|
943
|
+
if (/_action\b/.test(className)) return child
|
|
944
|
+
}
|
|
945
|
+
return null
|
|
946
|
+
}
|
|
947
|
+
|
|
611
948
|
function clearEditor() {
|
|
612
949
|
const elements = document.querySelectorAll('.dshet-editor')
|
|
613
950
|
for (const element of elements) element.remove()
|
|
614
951
|
}
|
|
615
952
|
|
|
953
|
+
// The layer the editor is hosted on. It is created once, hangs off the
|
|
954
|
+
// body - where the host's React tree cannot reach it - and survives every
|
|
955
|
+
// rebuild of the conversation.
|
|
956
|
+
let editorLayer = null
|
|
957
|
+
function ensureEditorLayer() {
|
|
958
|
+
if (editorLayer !== null && editorLayer.isConnected === true) return editorLayer
|
|
959
|
+
editorLayer = document.createElement('div')
|
|
960
|
+
editorLayer.className = 'dshet-layer'
|
|
961
|
+
document.body.appendChild(editorLayer)
|
|
962
|
+
return editorLayer
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Put the box under the row it belongs to: same width, just below its lower
|
|
966
|
+
// edge. Called on render and whenever the page scrolls or resizes, since
|
|
967
|
+
// the layer is fixed to the viewport.
|
|
968
|
+
function placeEditor(row, box) {
|
|
969
|
+
const layer = ensureEditorLayer()
|
|
970
|
+
if (box.parentElement !== layer) layer.appendChild(box)
|
|
971
|
+
const rect = row.getBoundingClientRect()
|
|
972
|
+
box.style.left = `${Math.round(rect.left)}px`
|
|
973
|
+
box.style.top = `${Math.round(rect.bottom + 6)}px`
|
|
974
|
+
box.style.width = `${Math.round(rect.width)}px`
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Grow the textarea to its content, the way a normal input box behaves.
|
|
978
|
+
// Without this it sits at a fixed `rows` height and a two-line edit floats
|
|
979
|
+
// in half an empty box. The stylesheet owns the limits (`min-height`,
|
|
980
|
+
// `max-height`); the element only ever gets the measured height, and the
|
|
981
|
+
// `+2` is the top and bottom border `scrollHeight` does not count.
|
|
982
|
+
function fitEditorHeight(area) {
|
|
983
|
+
area.style.height = 'auto'
|
|
984
|
+
const height = area.scrollHeight
|
|
985
|
+
if (typeof height === 'number' && height > 0) area.style.height = `${height + 2}px`
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Wire an action so a host re-render cannot eat the click.
|
|
989
|
+
//
|
|
990
|
+
// The host owns the row and rebuilds it freely - while a turn streams it
|
|
991
|
+
// re-renders constantly - so an injected button can be replaced between
|
|
992
|
+
// mousedown and mouseup, and a click only fires when both land on the same
|
|
993
|
+
// node. That is what "the button does nothing" looks like from outside, and
|
|
994
|
+
// it is unguessable from the state. The action therefore rides on
|
|
995
|
+
// pointerdown; the click path stays for the keyboard, de-duplicated so one
|
|
996
|
+
// press cannot run the action twice.
|
|
997
|
+
function pressable(button, action) {
|
|
998
|
+
let pressed = false
|
|
999
|
+
button.onpointerdown = (event) => {
|
|
1000
|
+
if (typeof event.button === 'number' && event.button !== 0) return
|
|
1001
|
+
pressed = true
|
|
1002
|
+
event.preventDefault()
|
|
1003
|
+
event.stopPropagation()
|
|
1004
|
+
action()
|
|
1005
|
+
}
|
|
1006
|
+
button.onclick = (event) => {
|
|
1007
|
+
event.preventDefault()
|
|
1008
|
+
event.stopPropagation()
|
|
1009
|
+
if (pressed) {
|
|
1010
|
+
pressed = false
|
|
1011
|
+
return
|
|
1012
|
+
}
|
|
1013
|
+
action()
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
616
1017
|
// The editor is plain DOM on purpose: the host row is a React subtree and
|
|
617
1018
|
// injecting a second controlled tree into it would fight its reconciler.
|
|
618
1019
|
// The draft is mirrored into the controller on every input event, so a
|
|
@@ -636,49 +1037,98 @@ window.__ModuleLoader__.load({
|
|
|
636
1037
|
// Every string the editor shows depends on what is being edited: a prompt is
|
|
637
1038
|
// re-run, a reply is replaced. Keeping them in one place keeps the two modes
|
|
638
1039
|
// from drifting apart in wording.
|
|
639
|
-
function editorTexts(t, target) {
|
|
1040
|
+
function editorTexts(t, target, rerun) {
|
|
640
1041
|
const reply = target.mode === 'reply'
|
|
641
1042
|
return {
|
|
642
1043
|
title: t(reply ? 'editor.title-reply' : 'editor.title'),
|
|
643
1044
|
placeholder: t(reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
|
|
644
|
-
hint: t(reply ? 'editor.hint-reply' : 'editor.hint'),
|
|
645
1045
|
warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
|
|
646
|
-
|
|
1046
|
+
// Saving writes the revision into the context; only the opt-in config asks
|
|
1047
|
+
// the model again, and the button says which one will happen.
|
|
1048
|
+
save: t(reply ? 'editor.save-reply' : rerun === true ? 'editor.save-rerun' : 'editor.save'),
|
|
647
1049
|
}
|
|
648
1050
|
}
|
|
649
1051
|
|
|
650
1052
|
function renderEditor(row, controller, view, t) {
|
|
651
1053
|
const target = view.editing
|
|
652
|
-
const texts = editorTexts(t, target)
|
|
1054
|
+
const texts = editorTexts(t, target, view.rerun)
|
|
653
1055
|
const marker = editorMarker(target, view)
|
|
654
|
-
const existing =
|
|
655
|
-
|
|
1056
|
+
const existing = controller.editorBox !== null && controller.editorBox.isConnected === true
|
|
1057
|
+
? controller.editorBox
|
|
1058
|
+
: null
|
|
1059
|
+
if (existing && existing.dataset.dshetFor === marker) {
|
|
1060
|
+
placeEditor(row, existing)
|
|
1061
|
+
return
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Focus policy. The host re-renders this row freely, so the box gets
|
|
1065
|
+
// rebuilt; taking focus at that moment is what pulled the caret out of
|
|
1066
|
+
// the composer (or whatever else the user was typing in). Focus is only
|
|
1067
|
+
// taken when the editor was just opened, or handed back to the rebuild
|
|
1068
|
+
// when the user was typing in the box that was just replaced - with the
|
|
1069
|
+
// caret where it was.
|
|
1070
|
+
const previous = controller.editorElement
|
|
1071
|
+
const fresh = previous === null
|
|
1072
|
+
const wasFocused = !fresh && (controller.editorFocus === true || document.activeElement === previous)
|
|
1073
|
+
const caret = wasFocused && typeof previous.selectionStart === 'number'
|
|
1074
|
+
? [previous.selectionStart, previous.selectionEnd]
|
|
1075
|
+
: null
|
|
656
1076
|
|
|
657
1077
|
clearEditor()
|
|
658
1078
|
const box = document.createElement('div')
|
|
659
1079
|
box.className = 'dshet-editor'
|
|
660
1080
|
box.dataset.dshetFor = marker
|
|
661
1081
|
|
|
662
|
-
const title = document.createElement('p')
|
|
663
|
-
title.className = 'dshet-editor-title'
|
|
664
|
-
title.textContent = view.confirming ? t('confirm.title') : texts.title
|
|
665
|
-
box.appendChild(title)
|
|
666
|
-
|
|
667
1082
|
const area = document.createElement('textarea')
|
|
668
|
-
area.rows =
|
|
1083
|
+
area.rows = 1
|
|
669
1084
|
area.value = controller.editorDraft
|
|
670
1085
|
area.disabled = view.pending || view.confirming
|
|
671
1086
|
area.spellcheck = false
|
|
672
1087
|
area.setAttribute('aria-label', texts.title)
|
|
673
1088
|
if (!view.confirming) area.placeholder = texts.placeholder
|
|
674
|
-
area.addEventListener('input', () =>
|
|
1089
|
+
area.addEventListener('input', (event) => {
|
|
1090
|
+
controller.setDraft(area.value)
|
|
1091
|
+
// Never relayout mid-composition: the IME owns the field until it
|
|
1092
|
+
// commits, and moving it under the candidate window is how a box
|
|
1093
|
+
// "eats" pinyin.
|
|
1094
|
+
if (event.isComposing === true) return
|
|
1095
|
+
fitEditorHeight(area)
|
|
1096
|
+
})
|
|
1097
|
+
area.addEventListener('compositionend', () => fitEditorHeight(area))
|
|
1098
|
+
area.addEventListener('focus', () => {
|
|
1099
|
+
controller.editorFocus = true
|
|
1100
|
+
})
|
|
1101
|
+
// The keyboard of every input box in this app: Enter acts (the composer
|
|
1102
|
+
// sends), Shift+Enter breaks the line, Escape leaves (one step back when
|
|
1103
|
+
// the confirmation step is showing). The guard is the platform's own
|
|
1104
|
+
// (`isComposing` OR keyCode 229): the Enter that commits a 拼音/かな
|
|
1105
|
+
// candidate can arrive with `isComposing === false` and keyCode 229 - and
|
|
1106
|
+
// missing that half is what saved the box on every candidate pick.
|
|
1107
|
+
area.addEventListener('keydown', (event) => {
|
|
1108
|
+
if (event.isComposing === true || event.keyCode === 229) return
|
|
1109
|
+
if (event.key === 'Enter' && event.shiftKey !== true) {
|
|
1110
|
+
event.preventDefault()
|
|
1111
|
+
if (view.confirming) controller.confirm()
|
|
1112
|
+
else controller.review()
|
|
1113
|
+
return
|
|
1114
|
+
}
|
|
1115
|
+
if (event.key === 'Escape') {
|
|
1116
|
+
event.preventDefault()
|
|
1117
|
+
if (view.confirming) controller.back()
|
|
1118
|
+
else controller.close()
|
|
1119
|
+
}
|
|
1120
|
+
})
|
|
675
1121
|
box.appendChild(area)
|
|
676
1122
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
1123
|
+
// Only what the user has to know right now: what the replacement will
|
|
1124
|
+
// discard, and what went wrong. The running explanation lives in the
|
|
1125
|
+
// README and in the buttons' tooltips, the way a normal input box does it.
|
|
1126
|
+
if (view.confirming) {
|
|
1127
|
+
const note = document.createElement('p')
|
|
1128
|
+
note.className = 'dshet-note'
|
|
1129
|
+
note.textContent = t('confirm.body')
|
|
1130
|
+
box.appendChild(note)
|
|
1131
|
+
}
|
|
682
1132
|
if (target.attachments > 0) {
|
|
683
1133
|
const warn = document.createElement('p')
|
|
684
1134
|
warn.className = 'dshet-warn'
|
|
@@ -700,13 +1150,12 @@ window.__ModuleLoader__.load({
|
|
|
700
1150
|
cancel.type = 'button'
|
|
701
1151
|
cancel.className = 'dshet-btn'
|
|
702
1152
|
cancel.textContent = t('editor.cancel')
|
|
1153
|
+
cancel.title = `${t('editor.cancel')} (Esc)`
|
|
703
1154
|
cancel.disabled = view.pending
|
|
704
|
-
cancel
|
|
705
|
-
event.preventDefault()
|
|
706
|
-
event.stopPropagation()
|
|
1155
|
+
pressable(cancel, () => {
|
|
707
1156
|
if (view.confirming) controller.back()
|
|
708
1157
|
else controller.close()
|
|
709
|
-
}
|
|
1158
|
+
})
|
|
710
1159
|
|
|
711
1160
|
const submit = document.createElement('button')
|
|
712
1161
|
submit.type = 'button'
|
|
@@ -716,23 +1165,28 @@ window.__ModuleLoader__.load({
|
|
|
716
1165
|
: view.confirming
|
|
717
1166
|
? t('editor.review')
|
|
718
1167
|
: texts.save
|
|
1168
|
+
submit.title = `${submit.textContent} (Enter)`
|
|
719
1169
|
submit.disabled = view.pending
|
|
720
|
-
submit
|
|
721
|
-
event.preventDefault()
|
|
722
|
-
event.stopPropagation()
|
|
1170
|
+
pressable(submit, () => {
|
|
723
1171
|
if (view.confirming) controller.confirm()
|
|
724
1172
|
else controller.review()
|
|
725
|
-
}
|
|
1173
|
+
})
|
|
726
1174
|
|
|
727
1175
|
footer.appendChild(cancel)
|
|
728
1176
|
footer.appendChild(submit)
|
|
729
1177
|
box.appendChild(footer)
|
|
730
1178
|
|
|
731
|
-
row
|
|
732
|
-
|
|
1179
|
+
placeEditor(row, box)
|
|
1180
|
+
controller.editorBox = box
|
|
1181
|
+
controller.editorRow = row
|
|
1182
|
+
controller.editorElement = area
|
|
1183
|
+
fitEditorHeight(area)
|
|
1184
|
+
if (!view.confirming && !view.pending && (fresh || wasFocused)) {
|
|
733
1185
|
try {
|
|
734
1186
|
area.focus()
|
|
735
|
-
area.
|
|
1187
|
+
const end = typeof area.value.length === 'number' ? area.value.length : 0
|
|
1188
|
+
const [start, stop] = caret === null ? [end, end] : caret
|
|
1189
|
+
area.setSelectionRange(Math.min(start, end), Math.min(stop, end))
|
|
736
1190
|
} catch {
|
|
737
1191
|
/* focus is best-effort */
|
|
738
1192
|
}
|
|
@@ -765,55 +1219,35 @@ window.__ModuleLoader__.load({
|
|
|
765
1219
|
const node = snapshot.nodes.get(key)
|
|
766
1220
|
if (!node) continue
|
|
767
1221
|
const hidden = isRowHidden(view.hidden, seqsFor(node))
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
const slotPencil = bar === undefined ? null : bar.querySelector('.dshet-row-action:not([data-dshet-fallback])')
|
|
794
|
-
if (covered !== undefined && bar !== undefined && slotPencil === null) {
|
|
795
|
-
const entry = view.replies.get(covered)
|
|
796
|
-
injectRowAction(
|
|
797
|
-
row,
|
|
798
|
-
{
|
|
799
|
-
seq: covered,
|
|
800
|
-
mode: 'reply',
|
|
801
|
-
turn: entry.turn,
|
|
802
|
-
messageId: entry.messageId,
|
|
803
|
-
text: typeof entry.text === 'string' ? entry.text : '',
|
|
804
|
-
attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
|
|
805
|
-
},
|
|
806
|
-
controller,
|
|
807
|
-
t,
|
|
808
|
-
{ fallback: true, anchor: bar },
|
|
809
|
-
)
|
|
810
|
-
} else {
|
|
811
|
-
// Either the strip's own entry is already there, or this row holds
|
|
812
|
-
// nothing editable: any spare pencil of ours has to go.
|
|
813
|
-
removeRowAction(row)
|
|
814
|
-
}
|
|
1222
|
+
// A prompt this plugin rewrote in place is not a discard: the platform keeps
|
|
1223
|
+
// the old row (it keeps transcript rows for messages that left the surface),
|
|
1224
|
+
// and the revision belongs exactly there - not at the tail the carrier's
|
|
1225
|
+
// sequence number would sort to. Our own bubble stands in for it.
|
|
1226
|
+
// Never let a rendering nicety take the whole browser half down: without
|
|
1227
|
+
// this plugin there are no edit entries at all. What it must not do is
|
|
1228
|
+
// take the message down with it either - a failed draw leaves the row
|
|
1229
|
+
// up, so the worst case is the old text showing for another refresh.
|
|
1230
|
+
let canHide = true
|
|
1231
|
+
try {
|
|
1232
|
+
canHide = renderRevision(row, node, view, hidden, controller, t)
|
|
1233
|
+
} catch {
|
|
1234
|
+
canHide = node.kind !== 'user'
|
|
1235
|
+
}
|
|
1236
|
+
const visible = hidden && canHide
|
|
1237
|
+
setRowHidden(row, visible, animate)
|
|
1238
|
+
// A collapsed row's entry is placed by `renderRevision`: the rewritten
|
|
1239
|
+
// prompt stands in for the message and the pencil goes into the bar that
|
|
1240
|
+
// survives alongside it. Tearing that down here would take back out what
|
|
1241
|
+
// that call has only just put there - so the row keeps its entry only
|
|
1242
|
+
// while something of ours is standing in for it. A row that collapsed
|
|
1243
|
+
// with no replacement on screen (a rollback this plugin knows nothing
|
|
1244
|
+
// about) is not editable any more and loses the pencil it had.
|
|
1245
|
+
if (visible) {
|
|
1246
|
+
if (ownRevisionBubble(row) === null) removeRowAction(row)
|
|
815
1247
|
} else {
|
|
816
|
-
|
|
1248
|
+
const target = targetFor(node, view)
|
|
1249
|
+
if (target !== null) injectRowAction(row, target, controller, t)
|
|
1250
|
+
else removeRowAction(row)
|
|
817
1251
|
}
|
|
818
1252
|
// The editor anchors to whichever row covers the seq being edited, not to
|
|
819
1253
|
// the row that owns the entry: a reply's entry now lives in the turn-tail
|
|
@@ -827,7 +1261,28 @@ window.__ModuleLoader__.load({
|
|
|
827
1261
|
renderEditor(row, controller, view, t)
|
|
828
1262
|
}
|
|
829
1263
|
}
|
|
830
|
-
if (view.editing === null)
|
|
1264
|
+
if (view.editing === null) {
|
|
1265
|
+
clearEditor()
|
|
1266
|
+
controller.editorBox = null
|
|
1267
|
+
controller.editorRow = null
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// A snapshot this entry can read, or null when the controller cannot be
|
|
1272
|
+
// trusted to have one.
|
|
1273
|
+
//
|
|
1274
|
+
// The slot runtime abdicates an entry that throws during render - permanently
|
|
1275
|
+
// for the life of the page: the cell renders an empty placeholder and this
|
|
1276
|
+
// pencil never comes back, with no error surfaced to the user. One missing
|
|
1277
|
+
// prop therefore has to cost this button alone, so every read below is
|
|
1278
|
+
// guarded and nothing here can throw.
|
|
1279
|
+
function readReplyView(controller) {
|
|
1280
|
+
if (controller === null || controller === undefined || typeof controller.getSnapshot !== 'function') return null
|
|
1281
|
+
const view = controller.getSnapshot()
|
|
1282
|
+
if (view === null || typeof view !== 'object') return null
|
|
1283
|
+
if (!view.repliesByMessage || typeof view.repliesByMessage.get !== 'function') return null
|
|
1284
|
+
if (!view.hidden || typeof view.hidden.has !== 'function') return null
|
|
1285
|
+
return view
|
|
831
1286
|
}
|
|
832
1287
|
|
|
833
1288
|
// The reply edit entry, registered in the host's own assistant-actions strip.
|
|
@@ -837,11 +1292,35 @@ window.__ModuleLoader__.load({
|
|
|
837
1292
|
// keeps the platform's own reveal behaviour (always for the latest turn, hover
|
|
838
1293
|
// for older ones). Injecting instead put the pencil in a different place from
|
|
839
1294
|
// where every other action for that row lives, which is what users noticed.
|
|
840
|
-
function ReplyActionEntry(
|
|
841
|
-
const
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
const
|
|
1295
|
+
function ReplyActionEntry(input) {
|
|
1296
|
+
const props = input !== null && typeof input === 'object' ? input : {}
|
|
1297
|
+
const messageId = props.messageId
|
|
1298
|
+
const controller = props.controller
|
|
1299
|
+
const t = typeof props.t === 'function' ? props.t : null
|
|
1300
|
+
const [view, setView] = react.useState(() => {
|
|
1301
|
+
try {
|
|
1302
|
+
return readReplyView(controller)
|
|
1303
|
+
} catch {
|
|
1304
|
+
return null
|
|
1305
|
+
}
|
|
1306
|
+
})
|
|
1307
|
+
react.useEffect(() => {
|
|
1308
|
+
let unsubscribe = null
|
|
1309
|
+
try {
|
|
1310
|
+
setView(readReplyView(controller))
|
|
1311
|
+
if (controller !== null && controller !== undefined && typeof controller.subscribe === 'function') {
|
|
1312
|
+
unsubscribe = controller.subscribe(() => setView(readReplyView(controller)))
|
|
1313
|
+
}
|
|
1314
|
+
} catch {
|
|
1315
|
+
setView(null)
|
|
1316
|
+
}
|
|
1317
|
+
return () => {
|
|
1318
|
+
if (typeof unsubscribe === 'function') unsubscribe()
|
|
1319
|
+
}
|
|
1320
|
+
}, [controller])
|
|
1321
|
+
const entry = view === null ? undefined : view.repliesByMessage.get(messageId)
|
|
1322
|
+
if (entry === undefined || entry === null || view.hidden.has(entry.seq)) return null
|
|
1323
|
+
const label = t === null ? '' : t('action.edit-reply')
|
|
845
1324
|
const open = (event) => {
|
|
846
1325
|
event.preventDefault()
|
|
847
1326
|
event.stopPropagation()
|
|
@@ -854,13 +1333,16 @@ window.__ModuleLoader__.load({
|
|
|
854
1333
|
attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
|
|
855
1334
|
})
|
|
856
1335
|
}
|
|
1336
|
+
// The config object is always an object: this runtime's `jsx` reads
|
|
1337
|
+
// `config.key` without a null check, so `jsx(PencilIcon, null)` threw and
|
|
1338
|
+
// the boundary retired the entry for the whole page.
|
|
857
1339
|
return jsx('button', {
|
|
858
1340
|
type: 'button',
|
|
859
|
-
className: 'dshet-action dshet-row-action',
|
|
1341
|
+
className: 'dshet-action dshet-row-action dshet-reply-action',
|
|
860
1342
|
'aria-label': label,
|
|
861
1343
|
title: label,
|
|
862
1344
|
onClick: open,
|
|
863
|
-
children: jsx(PencilIcon,
|
|
1345
|
+
children: jsx(PencilIcon, {}),
|
|
864
1346
|
})
|
|
865
1347
|
}
|
|
866
1348
|
|
|
@@ -874,6 +1356,36 @@ window.__ModuleLoader__.load({
|
|
|
874
1356
|
controller.load()
|
|
875
1357
|
}, [controller])
|
|
876
1358
|
|
|
1359
|
+
// Pressing outside the editor is the one signal that means "the user
|
|
1360
|
+
// left". Blur cannot be that signal: a host re-render replaces the node
|
|
1361
|
+
// (blur without a related target) and a save temporarily disables it -
|
|
1362
|
+
// both used to drop the caret for good.
|
|
1363
|
+
react.useEffect(() => {
|
|
1364
|
+
if (typeof document.addEventListener !== 'function' || typeof document.removeEventListener !== 'function') {
|
|
1365
|
+
return undefined
|
|
1366
|
+
}
|
|
1367
|
+
const onPointerDown = (event) => {
|
|
1368
|
+
const target = event.target
|
|
1369
|
+
const inside = target !== null && target !== undefined && typeof target.closest === 'function'
|
|
1370
|
+
&& target.closest('.dshet-editor') !== null
|
|
1371
|
+
if (!inside) controller.editorFocus = false
|
|
1372
|
+
}
|
|
1373
|
+
const keepPlaced = () => {
|
|
1374
|
+
const row = controller.editorRow
|
|
1375
|
+
const box = controller.editorBox
|
|
1376
|
+
if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box)
|
|
1377
|
+
}
|
|
1378
|
+
document.addEventListener('pointerdown', onPointerDown, true)
|
|
1379
|
+
// Scroll events do not bubble; capture reaches the inner scroller too.
|
|
1380
|
+
document.addEventListener('scroll', keepPlaced, { capture: true, passive: true })
|
|
1381
|
+
window.addEventListener('resize', keepPlaced)
|
|
1382
|
+
return () => {
|
|
1383
|
+
document.removeEventListener('pointerdown', onPointerDown, true)
|
|
1384
|
+
document.removeEventListener('scroll', keepPlaced, true)
|
|
1385
|
+
window.removeEventListener('resize', keepPlaced)
|
|
1386
|
+
}
|
|
1387
|
+
}, [controller])
|
|
1388
|
+
|
|
877
1389
|
react.useEffect(() => {
|
|
878
1390
|
if (snapshot === undefined) return undefined
|
|
879
1391
|
let scheduled = false
|