dsh-paste-code-block 0.1.3 → 0.2.6
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/LICENSE +21 -21
- package/README.md +118 -112
- package/README.zh-CN.md +118 -112
- package/cordis.patch.yml +4 -4
- package/docs/design.md +373 -205
- package/docs/images/hero.svg +3 -3
- package/docs/images/languages.svg +3 -3
- package/docs/images/render.cjs +20 -20
- package/docs/images/workflow.svg +3 -3
- package/package.json +5 -4
- package/src/chip-nodes.js +46 -46
- package/src/client.js +856 -16
- package/src/i18n.js +140 -121
- package/src/index.js +11 -11
- package/src/parse.js +223 -6
- package/src/projection.js +54 -54
- package/tests/chip-nodes.test.js +71 -0
- package/tests/fixtures.js +9 -0
- package/tests/fold-smoke.test.js +642 -0
- package/tests/fold.test.js +147 -0
- package/tests/i18n.test.js +112 -0
- package/tests/lexical-chip-probe.mjs +109 -0
- package/tests/parse.test.js +183 -0
- package/tests/projection.test.js +64 -0
package/src/client.js
CHANGED
|
@@ -17,6 +17,20 @@ window.__ModuleLoader__.load({
|
|
|
17
17
|
const DETAIL_NS = 'dsh-pcb-detail'
|
|
18
18
|
const CHIP_X_CLASS = 'dsh-pcb-chip-x'
|
|
19
19
|
|
|
20
|
+
// ===== diagnostics =======================================================
|
|
21
|
+
// Off by default. Set `window.__PCB_DEBUG__ = true` in the console (then
|
|
22
|
+
// paste / send) to trace paste parsing and fold decisions. The on-screen
|
|
23
|
+
// badge that used to sit above the composer was scaffolding for one
|
|
24
|
+
// debugging session and is gone.
|
|
25
|
+
const PCB_DEBUG = (() => {
|
|
26
|
+
try { return typeof window !== 'undefined' && window.__PCB_DEBUG__ === true } catch (e) { return false }
|
|
27
|
+
})()
|
|
28
|
+
function pcbDiag(kind, info) {
|
|
29
|
+
if (!PCB_DEBUG) return
|
|
30
|
+
try { console.log('[pcb-diag]', kind + (info ? ': ' + info : '')) } catch (e) {}
|
|
31
|
+
}
|
|
32
|
+
// =========================================================================
|
|
33
|
+
|
|
20
34
|
// ----- shared chip-DOM helpers ------------------------------------------
|
|
21
35
|
// Module level on purpose: the chip sync scan and the controller's removal
|
|
22
36
|
// path both need to map a chip host element back to its label (and its
|
|
@@ -109,6 +123,9 @@ window.__ModuleLoader__.load({
|
|
|
109
123
|
'edit.aria': '编辑代码块内容',
|
|
110
124
|
'error.stale': '代码块已失效,请重新粘贴',
|
|
111
125
|
'error.remove': '无法删除该块,请重试',
|
|
126
|
+
'fold.code': '代码块',
|
|
127
|
+
'fold.text': '文本块',
|
|
128
|
+
'fold.aria': '展开或折叠该块',
|
|
112
129
|
},
|
|
113
130
|
en: {
|
|
114
131
|
'block.code': 'Code #{n}',
|
|
@@ -126,6 +143,9 @@ window.__ModuleLoader__.load({
|
|
|
126
143
|
'edit.aria': 'Edit block content',
|
|
127
144
|
'error.stale': 'This block is no longer valid — paste it again',
|
|
128
145
|
'error.remove': 'Could not remove the block — please retry',
|
|
146
|
+
'fold.code': 'Code',
|
|
147
|
+
'fold.text': 'Text',
|
|
148
|
+
'fold.aria': 'Expand or collapse this block',
|
|
129
149
|
},
|
|
130
150
|
}
|
|
131
151
|
|
|
@@ -138,6 +158,13 @@ window.__ModuleLoader__.load({
|
|
|
138
158
|
}
|
|
139
159
|
}
|
|
140
160
|
|
|
161
|
+
// Sent-message fold card title (embedded copy of src/i18n.js#foldTitle).
|
|
162
|
+
function foldTitle(t, type, lang, lines) {
|
|
163
|
+
const meta = t(lines === 1 ? 'lines.one' : 'lines.other', { count: lines })
|
|
164
|
+
const name = type === 'code' ? (lang || t('fold.code')) : t('fold.text')
|
|
165
|
+
return `${name} · ${meta}`
|
|
166
|
+
}
|
|
167
|
+
|
|
141
168
|
function detectBrowserLocale() {
|
|
142
169
|
try {
|
|
143
170
|
const langs = [...(navigator.languages || []), navigator.language].filter(Boolean).map((l) => String(l).toLowerCase())
|
|
@@ -205,6 +232,25 @@ window.__ModuleLoader__.load({
|
|
|
205
232
|
return ''
|
|
206
233
|
}
|
|
207
234
|
|
|
235
|
+
/**
|
|
236
|
+
* The single complete fence of `text`, or null when the text is not exactly
|
|
237
|
+
* one fenced block (blank lines around it tolerated, real prose not).
|
|
238
|
+
* Keep in sync with src/parse.js#wholeFence.
|
|
239
|
+
*/
|
|
240
|
+
function wholeFence(text) {
|
|
241
|
+
const segs = splitFencedSegments(text)
|
|
242
|
+
let fence = null
|
|
243
|
+
for (const seg of segs) {
|
|
244
|
+
if (seg.kind === 'fence') {
|
|
245
|
+
if (fence) return null
|
|
246
|
+
fence = seg
|
|
247
|
+
} else if (seg.text.trim() !== '') {
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return fence
|
|
252
|
+
}
|
|
253
|
+
|
|
208
254
|
/**
|
|
209
255
|
* Parse pasted text into a block, or null when it is ordinary short prose
|
|
210
256
|
* that should paste plainly. `isCode` drives both the chip label
|
|
@@ -215,12 +261,42 @@ window.__ModuleLoader__.load({
|
|
|
215
261
|
const text = String(raw || '').replace(/^\uFEFF/, '')
|
|
216
262
|
if (!text) return null
|
|
217
263
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
264
|
+
// (1) exactly one complete fence: this plugin's own output shape. The
|
|
265
|
+
// fence's info string decides the type, so an explicit ```text paste is
|
|
266
|
+
// a TEXT block and round-trips instead of degrading into bare code.
|
|
267
|
+
const whole = wholeFence(text)
|
|
268
|
+
if (whole) {
|
|
269
|
+
const content = whole.body.replace(/\n+$/, '')
|
|
222
270
|
if (!content) return null
|
|
223
|
-
|
|
271
|
+
const infoLang = (whole.lang || '').toLowerCase()
|
|
272
|
+
return {
|
|
273
|
+
lang: infoLang === 'text' ? '' : whole.lang,
|
|
274
|
+
content,
|
|
275
|
+
lines: content.split(/\r?\n/),
|
|
276
|
+
isCode: infoLang !== 'text',
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// (2) a paste that OPENS with a fence but carries trailing prose — the
|
|
281
|
+
// copy-a-whole-previous-message case. Keep it as ONE block and take the
|
|
282
|
+
// type from the leading fence's info string, so the chip and the
|
|
283
|
+
// transcript agree. (0.2.5 treated it as code and re-wrapped the
|
|
284
|
+
// already-fenced text in a bare fence: two nested fences in the bubble.)
|
|
285
|
+
const segs = splitFencedSegments(text)
|
|
286
|
+
let leadFence = -1
|
|
287
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
288
|
+
if (segs[i].kind === 'fence') { leadFence = i; break }
|
|
289
|
+
if (segs[i].text.trim() !== '') break
|
|
290
|
+
}
|
|
291
|
+
if (leadFence >= 0) {
|
|
292
|
+
const leadLang = (segs[leadFence].lang || '').toLowerCase()
|
|
293
|
+
const content = text.replace(/\n+$/, '')
|
|
294
|
+
return {
|
|
295
|
+
lang: leadLang === 'text' ? '' : segs[leadFence].lang,
|
|
296
|
+
content,
|
|
297
|
+
lines: content.split(/\r?\n/),
|
|
298
|
+
isCode: leadLang !== 'text',
|
|
299
|
+
}
|
|
224
300
|
}
|
|
225
301
|
|
|
226
302
|
const multi = /\r?\n/.test(text)
|
|
@@ -264,9 +340,225 @@ window.__ModuleLoader__.load({
|
|
|
264
340
|
return n
|
|
265
341
|
}
|
|
266
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Backtick marker long enough to wrap `content` unambiguously: longer than
|
|
345
|
+
* the longest backtick run inside it. Keep in sync with
|
|
346
|
+
* src/parse.js#fenceMarkerFor.
|
|
347
|
+
*/
|
|
348
|
+
function fenceMarkerFor(content) {
|
|
349
|
+
const src = String(content == null ? '' : content)
|
|
350
|
+
let longest = 0
|
|
351
|
+
const runs = src.match(/`+/g)
|
|
352
|
+
if (runs) for (const run of runs) if (run.length > longest) longest = run.length
|
|
353
|
+
return '`'.repeat(Math.max(3, longest + 1))
|
|
354
|
+
}
|
|
355
|
+
|
|
267
356
|
function serializeBlock(block) {
|
|
268
357
|
const lang = block.isCode ? (block.lang || '') : 'text'
|
|
269
|
-
|
|
358
|
+
const marker = fenceMarkerFor(block.content)
|
|
359
|
+
return `\n${marker}${lang}\n${block.content}\n${marker}\n`
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ----- fence segmentation (embedded copy of src/parse.js) ----------------
|
|
363
|
+
// Split a message text into ordered prose/fence segments (GFM-style ``` or
|
|
364
|
+
// ~~~ fences, opener indented <=3 spaces, closer >= opener length alone on
|
|
365
|
+
// its line). Unterminated openers stay prose. Keep in sync with parse.js.
|
|
366
|
+
function splitFencedSegments(text) {
|
|
367
|
+
const src = String(text == null ? '' : text)
|
|
368
|
+
if (!src) return []
|
|
369
|
+
const lines = src.split(/\r?\n/)
|
|
370
|
+
const segments = []
|
|
371
|
+
const OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
|
|
372
|
+
let proseStart = 0
|
|
373
|
+
let i = 0
|
|
374
|
+
const flushProse = (end) => {
|
|
375
|
+
if (end > proseStart) segments.push({ kind: 'prose', text: lines.slice(proseStart, end).join('\n') })
|
|
376
|
+
}
|
|
377
|
+
while (i < lines.length) {
|
|
378
|
+
const m = OPEN.exec(lines[i])
|
|
379
|
+
if (!m) { i += 1; continue }
|
|
380
|
+
const marker = m[1]
|
|
381
|
+
const ch = marker[0]
|
|
382
|
+
const info = m[2].trim()
|
|
383
|
+
if (ch === '`' && info.includes('`')) { i += 1; continue }
|
|
384
|
+
const closeRe = new RegExp('^ {0,3}' + (ch === '`' ? '`' : '~') + '{' + marker.length + ',}[ \\t]*$')
|
|
385
|
+
let j = i + 1
|
|
386
|
+
let closed = false
|
|
387
|
+
while (j < lines.length) {
|
|
388
|
+
if (closeRe.test(lines[j])) { closed = true; break }
|
|
389
|
+
j += 1
|
|
390
|
+
}
|
|
391
|
+
if (!closed) { i += 1; continue }
|
|
392
|
+
flushProse(i)
|
|
393
|
+
const bodyLines = lines.slice(i + 1, j)
|
|
394
|
+
segments.push({
|
|
395
|
+
kind: 'fence',
|
|
396
|
+
lang: info ? info.split(/\s+/)[0] : '',
|
|
397
|
+
info,
|
|
398
|
+
body: bodyLines.join('\n'),
|
|
399
|
+
raw: lines.slice(i, j + 1).join('\n'),
|
|
400
|
+
})
|
|
401
|
+
i = j + 1
|
|
402
|
+
proseStart = i
|
|
403
|
+
}
|
|
404
|
+
flushProse(lines.length)
|
|
405
|
+
return segments
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Character ranges of every complete fence in `text`, in order, as
|
|
410
|
+
* { start, end } (end exclusive, both fence lines included). Same pairing
|
|
411
|
+
* rules as splitFencedSegments. Keep in sync with src/parse.js#fenceRanges.
|
|
412
|
+
*/
|
|
413
|
+
function fenceRanges(text) {
|
|
414
|
+
const src = String(text == null ? '' : text)
|
|
415
|
+
const out = []
|
|
416
|
+
if (!src) return out
|
|
417
|
+
const starts = [0]
|
|
418
|
+
for (let i = 0; i < src.length; i += 1) if (src.charCodeAt(i) === 10) starts.push(i + 1)
|
|
419
|
+
const lineEnd = (n) => (n + 1 < starts.length ? starts[n + 1] - 1 : src.length)
|
|
420
|
+
const lineText = (n) => src.slice(starts[n], lineEnd(n)).replace(/\r$/, '')
|
|
421
|
+
const OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
|
|
422
|
+
let i = 0
|
|
423
|
+
while (i < starts.length) {
|
|
424
|
+
const m = OPEN.exec(lineText(i))
|
|
425
|
+
if (!m) { i += 1; continue }
|
|
426
|
+
const marker = m[1]
|
|
427
|
+
const ch = marker[0]
|
|
428
|
+
const info = m[2].trim()
|
|
429
|
+
if (ch === '`' && info.includes('`')) { i += 1; continue }
|
|
430
|
+
const closeRe = new RegExp('^ {0,3}' + (ch === '`' ? '`' : '~') + '{' + marker.length + ',}[ \\t]*$')
|
|
431
|
+
let j = i + 1
|
|
432
|
+
let closed = false
|
|
433
|
+
while (j < starts.length) {
|
|
434
|
+
if (closeRe.test(lineText(j))) { closed = true; break }
|
|
435
|
+
j += 1
|
|
436
|
+
}
|
|
437
|
+
if (!closed) { i += 1; continue }
|
|
438
|
+
out.push({ start: starts[i], end: starts[j] + lineText(j).length })
|
|
439
|
+
i = j + 1
|
|
440
|
+
}
|
|
441
|
+
return out
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* How the sent-message fold scan should fold `text`, given the run texts
|
|
446
|
+
* DSH actually rendered it as. Keep in sync with src/parse.js#planFoldRuns.
|
|
447
|
+
* Returns null when nothing is foldable, else
|
|
448
|
+
* { mode:'single', segments, runs } — every fence lies inside ONE run
|
|
449
|
+
* { mode:'bubble', segments, fences } — a fence straddles run boundaries
|
|
450
|
+
*/
|
|
451
|
+
function planFoldRuns(text, runTexts) {
|
|
452
|
+
const src = String(text == null ? '' : text)
|
|
453
|
+
const segments = splitFencedSegments(src)
|
|
454
|
+
const fenceSegs = segments.filter((seg) => seg.kind === 'fence')
|
|
455
|
+
const spans = fenceRanges(src)
|
|
456
|
+
if (fenceSegs.length !== spans.length) return null
|
|
457
|
+
const fences = []
|
|
458
|
+
fenceSegs.forEach((seg, k) => { if (seg.body.trim() !== '') fences.push(spans[k]) })
|
|
459
|
+
if (!fences.length) return null
|
|
460
|
+
|
|
461
|
+
const bounds = []
|
|
462
|
+
let at = 0
|
|
463
|
+
for (const rt of (runTexts || [])) {
|
|
464
|
+
const len = String(rt == null ? '' : rt).length
|
|
465
|
+
bounds.push([at, at + len])
|
|
466
|
+
at += len
|
|
467
|
+
}
|
|
468
|
+
const runOf = (off) => {
|
|
469
|
+
for (let k = 0; k < bounds.length; k += 1) if (off >= bounds[k][0] && off < bounds[k][1]) return k
|
|
470
|
+
return -1
|
|
471
|
+
}
|
|
472
|
+
const runs = []
|
|
473
|
+
for (const f of fences) {
|
|
474
|
+
const a = runOf(f.start)
|
|
475
|
+
const b = runOf(Math.max(f.start, f.end - 1))
|
|
476
|
+
if (a < 0 || a !== b) return { mode: 'bubble', segments, fences }
|
|
477
|
+
if (!runs.includes(a)) runs.push(a)
|
|
478
|
+
}
|
|
479
|
+
return { mode: 'single', segments, runs }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ===== Recovery mirror (draft re-seed after workspace switch / reload) ===
|
|
483
|
+
// DSH persists a session's composer draft as PLAIN TEXT
|
|
484
|
+
// (`dsh.conversation.<sid>` in localStorage) and re-seeds the editor from
|
|
485
|
+
// it — but Lexical chip nodes cannot round-trip through text (design.md
|
|
486
|
+
// §7), so switching workspaces mid-draft left every pasted block behind:
|
|
487
|
+
// the seeded draft keeps only one invisible U+200B per lost chip. The
|
|
488
|
+
// plugin therefore mirrors its block CONTENT to localStorage itself and
|
|
489
|
+
// re-attaches the chips when it recognizes that stray-ZWSP signature
|
|
490
|
+
// (BlockDock mount effect → BlockController.recover). Intentional chip
|
|
491
|
+
// deletion removes the ZWSPs along with the nodes, so it never looks
|
|
492
|
+
// like a re-seed; the mirror clears itself on send and once no blocks
|
|
493
|
+
// remain, and stale entries expire after two weeks.
|
|
494
|
+
const RECOVERY_PREFIX = 'dsh-pcb-recovery.'
|
|
495
|
+
const RECOVERY_TTL_MS = 14 * 24 * 60 * 60 * 1000
|
|
496
|
+
const RECOVERY_MAX_CHARS = 3_000_000
|
|
497
|
+
const RECOVERY_MAX_DRAFT = 200_000
|
|
498
|
+
|
|
499
|
+
function recoverySave(sid, blocks, draftText) {
|
|
500
|
+
const key = RECOVERY_PREFIX + sid
|
|
501
|
+
try {
|
|
502
|
+
if (!blocks || blocks.length === 0) { localStorage.removeItem(key); return }
|
|
503
|
+
const draft = typeof draftText === 'string' && draftText.length <= RECOVERY_MAX_DRAFT ? draftText : ''
|
|
504
|
+
const json = JSON.stringify({ at: Date.now(), draft, blocks })
|
|
505
|
+
if (json.length > RECOVERY_MAX_CHARS) { console.warn('[dsh-pcb] draft too large to mirror; chips will not survive a reload'); return }
|
|
506
|
+
localStorage.setItem(key, json)
|
|
507
|
+
} catch (e) { /* quota / private mode: best effort */ }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function recoveryLoad(sid) {
|
|
511
|
+
const key = RECOVERY_PREFIX + sid
|
|
512
|
+
try {
|
|
513
|
+
const raw = localStorage.getItem(key)
|
|
514
|
+
if (!raw) return null
|
|
515
|
+
let data = null
|
|
516
|
+
try { data = JSON.parse(raw) } catch (e) { data = null }
|
|
517
|
+
if (!data || typeof data.at !== 'number' || Date.now() - data.at > RECOVERY_TTL_MS) { localStorage.removeItem(key); return null }
|
|
518
|
+
return data
|
|
519
|
+
} catch (e) { return null }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function recoveryClear(sid) {
|
|
523
|
+
try { localStorage.removeItem(RECOVERY_PREFIX + sid) } catch (e) { /* noop */ }
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Mirror-side carry-over donor: a store whose mirrored draft matches the
|
|
528
|
+
* re-seeded one exactly and still holds blocks (after a page reload there
|
|
529
|
+
* is no live memory to match against). Returns { sid, blocks } on a
|
|
530
|
+
* UNIQUE match, null when absent or ambiguous.
|
|
531
|
+
*/
|
|
532
|
+
function recoveryScanDonor(targetSid, draft) {
|
|
533
|
+
try {
|
|
534
|
+
let hit = null
|
|
535
|
+
for (let i = 0; i < localStorage.length; i += 1) {
|
|
536
|
+
const key = localStorage.key(i)
|
|
537
|
+
if (!key || !key.startsWith(RECOVERY_PREFIX)) continue
|
|
538
|
+
const sid = key.slice(RECOVERY_PREFIX.length)
|
|
539
|
+
if (sid === targetSid) continue
|
|
540
|
+
const data = recoveryLoad(sid)
|
|
541
|
+
if (!data || data.draft !== draft || !Array.isArray(data.blocks)) continue
|
|
542
|
+
if (!data.blocks.some((b) => b && typeof b.content === 'string' && b.content !== '')) continue
|
|
543
|
+
if (hit) return null // ambiguous — refuse to guess
|
|
544
|
+
hit = { sid, blocks: data.blocks }
|
|
545
|
+
}
|
|
546
|
+
return hit
|
|
547
|
+
} catch (e) { return null }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function recoveryPruneExpired() {
|
|
551
|
+
try {
|
|
552
|
+
const doomed = []
|
|
553
|
+
for (let i = 0; i < localStorage.length; i += 1) {
|
|
554
|
+
const k = localStorage.key(i)
|
|
555
|
+
if (!k || !k.startsWith(RECOVERY_PREFIX)) continue
|
|
556
|
+
let at = 0
|
|
557
|
+
try { at = (JSON.parse(localStorage.getItem(k) || '{}').at) || 0 } catch (e) { /* fall through */ }
|
|
558
|
+
if (!at || Date.now() - at > RECOVERY_TTL_MS) doomed.push(k)
|
|
559
|
+
}
|
|
560
|
+
for (const k of doomed) localStorage.removeItem(k)
|
|
561
|
+
} catch (e) { /* noop */ }
|
|
270
562
|
}
|
|
271
563
|
|
|
272
564
|
// ===== Draft controller ==================================================
|
|
@@ -289,6 +581,8 @@ window.__ModuleLoader__.load({
|
|
|
289
581
|
this.expiry = new Map()
|
|
290
582
|
this.serializing = new Set()
|
|
291
583
|
this.listeners = new Map()
|
|
584
|
+
this.recoverTimer = new Map() // sid -> debounce handle for the recovery mirror
|
|
585
|
+
this.lastInput = new Map() // sid -> draft text seen by the last recover() pass
|
|
292
586
|
this.counter = 0
|
|
293
587
|
}
|
|
294
588
|
|
|
@@ -372,7 +666,11 @@ window.__ModuleLoader__.load({
|
|
|
372
666
|
insertReference(sessionId, block) {
|
|
373
667
|
const { actx, shell } = this.scope(sessionId)
|
|
374
668
|
const input = shell.snapshot
|
|
375
|
-
|
|
669
|
+
// DSH's SessionInputShell.insertReference accepts both `plain` and
|
|
670
|
+
// `claimed` phases; mirror that so a paste while a command draft is
|
|
671
|
+
// still pending (claimed) folds into a chip instead of falling back to
|
|
672
|
+
// raw text. Submitting/adjudicating (mid-submit) stay rejected.
|
|
673
|
+
if (!input || (input.phase !== 'plain' && input.phase !== 'claimed')) return false
|
|
376
674
|
// Each chip contributes exactly one char to the editor's detect text
|
|
377
675
|
// (the "" placeholder) and one char to the plain `draft` projection
|
|
378
676
|
// (clipboardText = zero-width space, chosen because String#trim() does
|
|
@@ -445,6 +743,16 @@ window.__ModuleLoader__.load({
|
|
|
445
743
|
this.refIndex.set(block.id, { sessionId: k, block })
|
|
446
744
|
this.labelIndex.set(block.label, block.id)
|
|
447
745
|
this.publish(sessionId)
|
|
746
|
+
// The shell just rewrote the draft (chip + placeholder char); until
|
|
747
|
+
// the next dock pass runs, this is the session's ground truth. Keep
|
|
748
|
+
// the typing-window fingerprint and the carry-over donor match fresh
|
|
749
|
+
// so the pass right after a paste cannot mistake the new draft for a
|
|
750
|
+
// rebuild.
|
|
751
|
+
try {
|
|
752
|
+
const after = this.scope(k).shell.snapshot
|
|
753
|
+
if (after && typeof after.draft === 'string') this.lastInput.set(k, after.draft)
|
|
754
|
+
} catch (e) { /* session vanished — the next pass re-observes */ }
|
|
755
|
+
this.mirrorNow(k) // attach is a key moment — persist synchronously
|
|
448
756
|
return true
|
|
449
757
|
}
|
|
450
758
|
|
|
@@ -570,6 +878,7 @@ window.__ModuleLoader__.load({
|
|
|
570
878
|
for (const [label, r] of this.labelIndex) if (r === ref) this.labelIndex.delete(label)
|
|
571
879
|
this.freeLabel(sessionId, entry.type, entry.labelNumber)
|
|
572
880
|
this.publish(sessionId)
|
|
881
|
+
this.mirror(k)
|
|
573
882
|
}
|
|
574
883
|
|
|
575
884
|
/** Update a block's content while editing it in the detail card. */
|
|
@@ -578,23 +887,31 @@ window.__ModuleLoader__.load({
|
|
|
578
887
|
if (!record) return
|
|
579
888
|
record.block.content = content
|
|
580
889
|
record.block.lines = content.split(/\r?\n/)
|
|
890
|
+
this.mirror(record.sessionId)
|
|
581
891
|
}
|
|
582
892
|
|
|
583
893
|
clearInFlight(sessionId) {
|
|
584
894
|
const k = String(sessionId)
|
|
585
895
|
const expiry = this.expiry.get(k)
|
|
586
896
|
if (expiry) { clearTimeout(expiry); this.expiry.delete(k) }
|
|
897
|
+
const had = this.inFlight.has(k)
|
|
587
898
|
for (const b of this.inFlight.get(k) || []) {
|
|
588
899
|
this.refIndex.delete(b.id)
|
|
589
900
|
this.freeLabel(sessionId, b.type, b.labelNumber)
|
|
590
901
|
for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
|
|
591
902
|
}
|
|
592
903
|
this.inFlight.delete(k)
|
|
904
|
+
if (had) this.mirrorNow(k)
|
|
593
905
|
}
|
|
594
906
|
|
|
595
907
|
markSerializing(ref) {
|
|
596
908
|
const record = this.refIndex.get(ref)
|
|
597
|
-
if (record && !this.serializing.has(record.sessionId))
|
|
909
|
+
if (record && !this.serializing.has(record.sessionId)) {
|
|
910
|
+
this.serializing.add(record.sessionId)
|
|
911
|
+
// A send is starting: the content leaves the draft, the mirror may go.
|
|
912
|
+
// (A failed send re-mirrors via restoreFailed.)
|
|
913
|
+
recoveryClear(record.sessionId)
|
|
914
|
+
}
|
|
598
915
|
}
|
|
599
916
|
|
|
600
917
|
async serialize(ref) {
|
|
@@ -640,6 +957,12 @@ window.__ModuleLoader__.load({
|
|
|
640
957
|
if (next.length > 0) this.list.set(k, next)
|
|
641
958
|
else this.list.delete(k)
|
|
642
959
|
this.publish(sessionId)
|
|
960
|
+
// Hand-deletion is final. Persist the pruned state synchronously so a
|
|
961
|
+
// later reload/carry-over cannot resurrect a deleted chip from a
|
|
962
|
+
// mirror the draft has already outlived — and so the recovery
|
|
963
|
+
// restore-path can never fire off invisible external residue once the
|
|
964
|
+
// list for this session is empty.
|
|
965
|
+
this.mirrorNow(k)
|
|
643
966
|
}
|
|
644
967
|
|
|
645
968
|
restoreFailed(sessionId) {
|
|
@@ -661,10 +984,168 @@ window.__ModuleLoader__.load({
|
|
|
661
984
|
}
|
|
662
985
|
if (survived.length) this.list.set(k, [...this.listFor(k), ...survived])
|
|
663
986
|
this.publish(sessionId)
|
|
987
|
+
if (survived.length) this.mirror(k)
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ----- recovery mirror -------------------------------------------------
|
|
991
|
+
/** Debounced persistence of the session's current block payloads. */
|
|
992
|
+
mirror(sessionId) {
|
|
993
|
+
const k = String(sessionId)
|
|
994
|
+
const prev = this.recoverTimer.get(k)
|
|
995
|
+
if (prev) clearTimeout(prev)
|
|
996
|
+
this.recoverTimer.set(k, setTimeout(() => {
|
|
997
|
+
this.recoverTimer.delete(k)
|
|
998
|
+
this.mirrorNow(k)
|
|
999
|
+
}, 250))
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
mirrorNow(sessionId) {
|
|
1003
|
+
const k = String(sessionId)
|
|
1004
|
+
const t = this.recoverTimer.get(k)
|
|
1005
|
+
if (t) { clearTimeout(t); this.recoverTimer.delete(k) }
|
|
1006
|
+
recoverySave(k, this.listFor(k).map((b) => ({ isCode: !!b.isCode, lang: b.lang || '', content: b.content, src: b.sourceText || '' })), this.lastInput.get(k))
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Restore the chips a text-only draft rebuild dropped. Two DSH paths
|
|
1011
|
+
* strand them: (a) the persisted per-session draft re-seeds the composer
|
|
1012
|
+
* after a page reload, and (b) a workspace switch on the new-session
|
|
1013
|
+
* page CARRIES the draft plain-text to the target workspace's blank
|
|
1014
|
+
* session (inputHub: next.setDraft(from.snapshot.draft)) — the chips,
|
|
1015
|
+
* which are Lexical nodes plus controller state, never move. Both leave
|
|
1016
|
+
* the same signature: stray U+200Bs (every chip contributes exactly one,
|
|
1017
|
+
* and removing a chip removes its ZWSP too) with no live occurrence of
|
|
1018
|
+
* ours. Deliberate deletion therefore never looks like a rebuild; and a
|
|
1019
|
+
* carry-over is matched to its donor by the exact draft the donor last
|
|
1020
|
+
* published — unique match only, no guessing. Blocks come from memory
|
|
1021
|
+
* first (same tab), then this session's mirror, then the donor's memory
|
|
1022
|
+
* or mirror. A stray U+200B that belongs to NO restorable block is
|
|
1023
|
+
* ordinary invisible copy from an external app (WeChat, web pages):
|
|
1024
|
+
* never restorable, cosmetic — it is left exactly where the user put
|
|
1025
|
+
* it. Sweeping it used to rewrite the whole draft mid-typing and is
|
|
1026
|
+
* retired in 0.2.2 (see the typing-window guard below).
|
|
1027
|
+
*
|
|
1028
|
+
* @param sessionId - session whose composer just (re)mounted.
|
|
1029
|
+
* @param snapshot - live input state ({draft, occurrences, phase}).
|
|
1030
|
+
* @returns whether the draft was rewritten by a real restore.
|
|
1031
|
+
*/
|
|
1032
|
+
recover(sessionId, snapshot) {
|
|
1033
|
+
const k = String(sessionId)
|
|
1034
|
+
if (!snapshot || typeof snapshot.draft !== 'string') return false
|
|
1035
|
+
const prior = this.lastInput.get(k)
|
|
1036
|
+
this.lastInput.set(k, snapshot.draft)
|
|
1037
|
+
if (this.serializing.has(k)) return false
|
|
1038
|
+
if (snapshot.phase && snapshot.phase !== 'plain') return false
|
|
1039
|
+
if ((snapshot.occurrences || []).some((o) => o.source === SOURCE)) return false
|
|
1040
|
+
const draft = snapshot.draft
|
|
1041
|
+
const strays = (draft.match(/\u200B/g) || []).length
|
|
1042
|
+
if (strays === 0) return false
|
|
1043
|
+
// Typing-window guard. Every committed keystroke re-runs this pass, so
|
|
1044
|
+
// a draft that MOVED since the pass before it is live typing, not a
|
|
1045
|
+
// text-only re-seed — and a real restore rewrites the whole document
|
|
1046
|
+
// via setDraft: caret to the end, chips re-appended, an in-flight IME
|
|
1047
|
+
// composition destroyed (0.2.2 bug: the first character typed was
|
|
1048
|
+
// eaten and the view bounced once, again and again). Only a draft
|
|
1049
|
+
// IDENTICAL to the last one observed for this session — or one this
|
|
1050
|
+
// page view has never seen, or only ever saw the composer hydrate
|
|
1051
|
+
// through while empty (fresh-boot re-seed) — can carry the rebuild
|
|
1052
|
+
// signature. The empty-prior escape is sealed from the other side by
|
|
1053
|
+
// reconcile pruning synchronously dropping the mirror: with nothing
|
|
1054
|
+
// restorable on record, a moved-from-empty pass restores nothing.
|
|
1055
|
+
if (prior && prior !== draft) return false
|
|
1056
|
+
let shell
|
|
1057
|
+
try {
|
|
1058
|
+
shell = this.scope(k).shell
|
|
1059
|
+
} catch (e) { return false } // session not ready yet — a later pass retries
|
|
1060
|
+
const usable = (arr) => Array.isArray(arr)
|
|
1061
|
+
? arr.filter((b) => b && typeof b.content === 'string' && b.content !== '')
|
|
1062
|
+
: []
|
|
1063
|
+
let sources = usable(this.listFor(k))
|
|
1064
|
+
let origin = sources.length ? 'memory' : null
|
|
1065
|
+
let donor = null
|
|
1066
|
+
if (!sources.length) {
|
|
1067
|
+
const saved = recoveryLoad(k)
|
|
1068
|
+
sources = usable(saved && saved.blocks)
|
|
1069
|
+
if (sources.length) origin = 'mirror'
|
|
1070
|
+
}
|
|
1071
|
+
if (!sources.length) {
|
|
1072
|
+
donor = this.findCarryoverSource(k, draft)
|
|
1073
|
+
if (donor) {
|
|
1074
|
+
sources = usable(this.listFor(donor))
|
|
1075
|
+
origin = 'carry-over (live)'
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
if (!sources.length) {
|
|
1079
|
+
const scanned = recoveryScanDonor(k, draft)
|
|
1080
|
+
if (scanned && scanned.blocks.length) {
|
|
1081
|
+
donor = scanned.sid
|
|
1082
|
+
sources = usable(scanned.blocks)
|
|
1083
|
+
origin = 'carry-over (mirror)'
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (sources.length === 0) {
|
|
1087
|
+
// Nothing to restore — invisible external residue (or the residue of
|
|
1088
|
+
// a deliberately deleted chip whose node already carries none of our
|
|
1089
|
+
// state). NEVER rewrite the draft for it: rewriting drops the caret,
|
|
1090
|
+
// destroys an in-flight IME composition and re-seeds stale text over
|
|
1091
|
+
// whatever the user typed in the meantime (0.2.2).
|
|
1092
|
+
return false
|
|
1093
|
+
}
|
|
1094
|
+
try {
|
|
1095
|
+
shell.setDraft(draft.replace(/\u200B/g, ''))
|
|
1096
|
+
} catch (e) {
|
|
1097
|
+
console.warn('[dsh-pcb] recovery: draft rewrite failed', e)
|
|
1098
|
+
return false
|
|
1099
|
+
}
|
|
1100
|
+
// The draft — and with it the blocks — now belongs to this session:
|
|
1101
|
+
// forget the (possibly moved) bookkeeping; attach() re-derives ids,
|
|
1102
|
+
// labels and numbers, and re-mirrors under this sid.
|
|
1103
|
+
this.forgetSession(k)
|
|
1104
|
+
if (donor) {
|
|
1105
|
+
this.forgetSession(donor)
|
|
1106
|
+
recoveryClear(donor)
|
|
1107
|
+
this.lastInput.delete(donor)
|
|
1108
|
+
}
|
|
1109
|
+
let restored = 0
|
|
1110
|
+
for (const b of sources) {
|
|
1111
|
+
const block = { lang: b.lang || '', content: b.content, lines: b.content.split(/\r?\n/), isCode: !!b.isCode, sourceText: b.sourceText || (typeof b.src === 'string' && b.src ? b.src : undefined) }
|
|
1112
|
+
if (this.attach(k, block)) restored += 1
|
|
1113
|
+
}
|
|
1114
|
+
console.log('[dsh-pcb] recovery: re-attached', restored, 'of', sources.length, 'block(s) via', origin, donor ? `(donor ${donor})` : '')
|
|
1115
|
+
return restored > 0
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/** Unmounted session that still lists blocks and last published EXACTLY this draft. */
|
|
1119
|
+
findCarryoverSource(targetSid, draft) {
|
|
1120
|
+
let found = null
|
|
1121
|
+
for (const [x, entries] of this.list) {
|
|
1122
|
+
if (x === targetSid || entries.length === 0) continue
|
|
1123
|
+
if (this.serializing.has(x)) continue
|
|
1124
|
+
const listeners = this.listeners.get(x)
|
|
1125
|
+
if (listeners && listeners.size > 0) continue // still mounted — draft did not move
|
|
1126
|
+
if (this.lastInput.get(x) !== draft) continue
|
|
1127
|
+
if (found) return null // ambiguous
|
|
1128
|
+
found = x
|
|
1129
|
+
}
|
|
1130
|
+
return found
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** Drop a session's block bookkeeping (its chips are gone or moving). */
|
|
1134
|
+
forgetSession(sessionId) {
|
|
1135
|
+
const k = String(sessionId)
|
|
1136
|
+
for (const b of this.listFor(k)) {
|
|
1137
|
+
this.freeLabel(k, b.type, b.labelNumber)
|
|
1138
|
+
this.refIndex.delete(b.id)
|
|
1139
|
+
for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
|
|
1140
|
+
}
|
|
1141
|
+
this.list.delete(k)
|
|
1142
|
+
this.selected.delete(k)
|
|
664
1143
|
}
|
|
665
1144
|
|
|
666
1145
|
dispose() {
|
|
667
1146
|
for (const t of this.expiry.values()) clearTimeout(t)
|
|
1147
|
+
for (const t of this.recoverTimer.values()) clearTimeout(t)
|
|
1148
|
+
this.recoverTimer.clear()
|
|
668
1149
|
this.expiry.clear(); this.list.clear(); this.selected.clear()
|
|
669
1150
|
this.used.clear(); this.refIndex.clear(); this.labelIndex.clear()
|
|
670
1151
|
this.inFlight.clear(); this.serializing.clear(); this.listeners.clear()
|
|
@@ -727,6 +1208,12 @@ window.__ModuleLoader__.load({
|
|
|
727
1208
|
useComposerMark(railRef, props.sessionId, input.draftRev, blocks.length > 0)
|
|
728
1209
|
|
|
729
1210
|
React.useEffect(() => {
|
|
1211
|
+
// Recognize a text-only draft re-seed (workspace switch / reload)
|
|
1212
|
+
// FIRST: re-attach lost chips before reconciliation prunes them. A
|
|
1213
|
+
// successful recovery mutates the draft, so this pass must NOT
|
|
1214
|
+
// reconcile against its now-stale snapshot — the next draftRev pass
|
|
1215
|
+
// does that with fresh occurrences.
|
|
1216
|
+
if (props.controller.recover(props.sessionId, input)) return
|
|
730
1217
|
props.controller.reconcile(props.sessionId, input.occurrences || [], input.phase)
|
|
731
1218
|
}, [props.controller, props.sessionId, input.draftRev, input.phase])
|
|
732
1219
|
|
|
@@ -834,6 +1321,163 @@ window.__ModuleLoader__.load({
|
|
|
834
1321
|
)
|
|
835
1322
|
}
|
|
836
1323
|
|
|
1324
|
+
// ===== Sent-message fold =================================================
|
|
1325
|
+
// A block that folded in the composer comes back *after send* as fenced
|
|
1326
|
+
// text inside the user bubble (DSH renders user messages as plain
|
|
1327
|
+
// pre-wrap runs), so the wall of text this plugin exists to prevent
|
|
1328
|
+
// reappears in the transcript. scanFolds() (see apply) walks sent
|
|
1329
|
+
// bubbles and folds every complete fenced block into a collapsed card:
|
|
1330
|
+
// [</>] python · 128 行 [⎘] ▾ (header click toggles)
|
|
1331
|
+
// React owns the bubble's own children, so nothing is removed or
|
|
1332
|
+
// reordered: the plainRun span being folded is hidden (display:none) and
|
|
1333
|
+
// a host element we fully own is inserted directly after it. A content
|
|
1334
|
+
// stamp on the run keeps the scan idempotent and self-refreshing — text
|
|
1335
|
+
// or locale revision changes rebuild the host, and a fold that becomes
|
|
1336
|
+
// unnecessary is undone. The copy button copies the RAW fenced text
|
|
1337
|
+
// (exactly what was sent); the bubble's own copy/edit/delete bar is
|
|
1338
|
+
// React-side and never sees this DOM.
|
|
1339
|
+
const FOLD_HOST_CLASS = 'dsh-pcb-fold-host'
|
|
1340
|
+
const FOLD_CARD_CLASS = 'dsh-pcb-fold'
|
|
1341
|
+
const FOLD_STAMP_ATTR = 'data-dsh-pcb-stamp' // on a host: the content stamp it was built from
|
|
1342
|
+
const FOLD_HIDE_ATTR = 'data-dsh-pcb-hide' // on every run the scan hid: the same stamp
|
|
1343
|
+
const FOLD_SIG_ATTR = 'data-dsh-pcb-sig' // on the bubble: '<mode>|<stamp>' it was folded for
|
|
1344
|
+
const FOLD_BUBBLE_SEL = [
|
|
1345
|
+
'[data-chat-flow-kind="user"]',
|
|
1346
|
+
'[data-chat-flow-kind="steering"]',
|
|
1347
|
+
'[data-submission-echo]',
|
|
1348
|
+
'[data-pending-steering]',
|
|
1349
|
+
].map((s) => `${s} [class*="bubble"]`).join(',')
|
|
1350
|
+
const foldOpenKeys = new Set() // user-toggled-open cards, stable across rebuilds
|
|
1351
|
+
let foldRev = 0 // bumped on locale switch so cards re-title
|
|
1352
|
+
|
|
1353
|
+
function hash32(str) {
|
|
1354
|
+
let h = 0x811c9dc5
|
|
1355
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
1356
|
+
h ^= str.charCodeAt(i)
|
|
1357
|
+
h = Math.imul(h, 0x01000193)
|
|
1358
|
+
}
|
|
1359
|
+
return (h >>> 0).toString(36)
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function foldStamp(text) {
|
|
1363
|
+
return `${foldRev}:${text.length}:${hash32(text)}`
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// Cheap pre-filter so the per-frame scan stays trivial on unfenced runs.
|
|
1367
|
+
const FENCE_HINT = /(^|\n) {0,3}(```|~~~)/
|
|
1368
|
+
|
|
1369
|
+
function isFoldRun(el) {
|
|
1370
|
+
return el.tagName === 'SPAN' && (el.getAttribute('class') || '').includes('plainRun')
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/** Segments when the text holds at least one non-empty complete fence, else null. */
|
|
1374
|
+
function foldPlanFor(text) {
|
|
1375
|
+
if (!FENCE_HINT.test(text)) return null
|
|
1376
|
+
const segments = splitFencedSegments(text)
|
|
1377
|
+
let folds = 0
|
|
1378
|
+
for (const seg of segments) if (seg.kind === 'fence' && seg.body.trim() !== '') folds += 1
|
|
1379
|
+
return folds > 0 ? segments : null
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function buildFoldCard(seg, openKey, t) {
|
|
1383
|
+
const infoLang = (seg.lang || '').toLowerCase()
|
|
1384
|
+
// Only an EXPLICIT ```text fence is a text block. The composer never
|
|
1385
|
+
// emits a bare fence for prose (plain blocks always serialize as
|
|
1386
|
+
// ```text), so a bare ``` fence is code whose language the composer
|
|
1387
|
+
// could not name — fold it as a code card and re-guess the language
|
|
1388
|
+
// for display. (0.2.0 bug: bare fences folded as 文本块, mismatching
|
|
1389
|
+
// the 代码块 chip the message was drafted from.)
|
|
1390
|
+
const isText = infoLang === 'text'
|
|
1391
|
+
const lang = isText ? '' : (infoLang || detectLang(seg.body) || '')
|
|
1392
|
+
const type = isText ? 'text' : 'code'
|
|
1393
|
+
const lines = seg.body.split('\n').length
|
|
1394
|
+
const open = foldOpenKeys.has(openKey)
|
|
1395
|
+
|
|
1396
|
+
const card = document.createElement('div')
|
|
1397
|
+
card.className = FOLD_CARD_CLASS
|
|
1398
|
+
card.setAttribute('data-pcb-type', type)
|
|
1399
|
+
card.setAttribute('data-open', open ? '1' : '0')
|
|
1400
|
+
|
|
1401
|
+
const head = document.createElement('div')
|
|
1402
|
+
head.className = 'dsh-pcb-fold-head'
|
|
1403
|
+
|
|
1404
|
+
const toggle = document.createElement('button')
|
|
1405
|
+
toggle.type = 'button'
|
|
1406
|
+
toggle.className = 'dsh-pcb-fold-toggle'
|
|
1407
|
+
toggle.setAttribute('aria-expanded', open ? 'true' : 'false')
|
|
1408
|
+
toggle.setAttribute('aria-label', t('fold.aria'))
|
|
1409
|
+
toggle.title = t(open ? 'collapse' : 'expand')
|
|
1410
|
+
const glyph = document.createElement('span')
|
|
1411
|
+
glyph.className = 'dsh-pcb-fold-glyph'
|
|
1412
|
+
glyph.setAttribute('aria-hidden', 'true')
|
|
1413
|
+
glyph.textContent = type === 'code' ? '</>' : 'Aa'
|
|
1414
|
+
const title = document.createElement('span')
|
|
1415
|
+
title.className = 'dsh-pcb-fold-title'
|
|
1416
|
+
title.textContent = foldTitle(t, type, lang, lines)
|
|
1417
|
+
const chevron = document.createElement('span')
|
|
1418
|
+
chevron.className = 'dsh-pcb-fold-chevron'
|
|
1419
|
+
chevron.setAttribute('aria-hidden', 'true')
|
|
1420
|
+
chevron.textContent = '▾'
|
|
1421
|
+
toggle.append(glyph, title, chevron)
|
|
1422
|
+
|
|
1423
|
+
const copy = document.createElement('button')
|
|
1424
|
+
copy.type = 'button'
|
|
1425
|
+
copy.className = 'dsh-pcb-btn dsh-pcb-fold-copy'
|
|
1426
|
+
copy.title = t('copy')
|
|
1427
|
+
copy.setAttribute('aria-label', t('copy'))
|
|
1428
|
+
copy.textContent = '⎘'
|
|
1429
|
+
let copyTimer = 0
|
|
1430
|
+
copy.addEventListener('click', (ev) => {
|
|
1431
|
+
ev.preventDefault()
|
|
1432
|
+
ev.stopPropagation()
|
|
1433
|
+
try {
|
|
1434
|
+
const done = navigator.clipboard?.writeText(seg.raw)
|
|
1435
|
+
if (done && typeof done.then === 'function') {
|
|
1436
|
+
done.then(() => {
|
|
1437
|
+
copy.textContent = '✓'
|
|
1438
|
+
clearTimeout(copyTimer)
|
|
1439
|
+
copyTimer = setTimeout(() => { copy.textContent = '⎘' }, 1200)
|
|
1440
|
+
}, () => {})
|
|
1441
|
+
}
|
|
1442
|
+
} catch (e) { /* noop */ }
|
|
1443
|
+
})
|
|
1444
|
+
|
|
1445
|
+
head.append(toggle, copy)
|
|
1446
|
+
const pre = document.createElement('pre')
|
|
1447
|
+
pre.className = 'dsh-pcb-fold-body'
|
|
1448
|
+
pre.textContent = seg.body
|
|
1449
|
+
card.append(head, pre)
|
|
1450
|
+
|
|
1451
|
+
toggle.addEventListener('click', (ev) => {
|
|
1452
|
+
ev.preventDefault()
|
|
1453
|
+
ev.stopPropagation()
|
|
1454
|
+
const nowOpen = card.getAttribute('data-open') !== '1'
|
|
1455
|
+
card.setAttribute('data-open', nowOpen ? '1' : '0')
|
|
1456
|
+
toggle.setAttribute('aria-expanded', nowOpen ? 'true' : 'false')
|
|
1457
|
+
toggle.title = t(nowOpen ? 'collapse' : 'expand')
|
|
1458
|
+
if (nowOpen) foldOpenKeys.add(openKey)
|
|
1459
|
+
else foldOpenKeys.delete(openKey)
|
|
1460
|
+
})
|
|
1461
|
+
return card
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function buildFoldHost(segments, keyBase, t) {
|
|
1465
|
+
const host = document.createElement('div')
|
|
1466
|
+
host.className = FOLD_HOST_CLASS
|
|
1467
|
+
segments.forEach((seg, i) => {
|
|
1468
|
+
if (seg.kind === 'fence' && seg.body.trim() !== '') {
|
|
1469
|
+
host.appendChild(buildFoldCard(seg, `${keyBase}:${i}`, t))
|
|
1470
|
+
} else {
|
|
1471
|
+
const prose = document.createElement('span')
|
|
1472
|
+
prose.className = 'dsh-pcb-fold-prose'
|
|
1473
|
+
// An un-foldable fence (empty body) keeps its raw text, fences and all.
|
|
1474
|
+
prose.textContent = seg.kind === 'fence' ? seg.raw : seg.text
|
|
1475
|
+
host.appendChild(prose)
|
|
1476
|
+
}
|
|
1477
|
+
})
|
|
1478
|
+
return host
|
|
1479
|
+
}
|
|
1480
|
+
|
|
837
1481
|
// ===== Styles ============================================================
|
|
838
1482
|
const CSS = `
|
|
839
1483
|
/* --- restyle DSH's inline reference chip into a boxed badge --- */
|
|
@@ -905,9 +1549,26 @@ window.__ModuleLoader__.load({
|
|
|
905
1549
|
/* --- hide the composer's own placeholder while chips/cards are active --- */
|
|
906
1550
|
[data-dsh-pcb-active="1"] [data-composer-placeholder]{visibility:hidden}
|
|
907
1551
|
|
|
1552
|
+
/* --- sent-message fold cards (conversation bubbles) --- */
|
|
1553
|
+
.dsh-pcb-fold-host{box-sizing:border-box;display:flex;flex-direction:column;gap:8px;width:100%;min-width:0}
|
|
1554
|
+
.dsh-pcb-fold-prose{white-space:pre-wrap;word-break:break-word}
|
|
1555
|
+
.dsh-pcb-fold{box-sizing:border-box;width:100%;min-width:0;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-input-major,var(--dsw-alias-bg-layer-1));box-shadow:var(--dsw-shadow-lv1);color:var(--dsw-alias-label-primary);overflow:hidden}
|
|
1556
|
+
.dsh-pcb-fold-head{box-sizing:border-box;min-height:34px;flex:none;display:flex;align-items:center;gap:4px;padding:0 6px 0 10px}
|
|
1557
|
+
.dsh-pcb-fold-toggle{flex:1;min-width:0;display:flex;align-items:center;gap:7px;padding:0;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
|
|
1558
|
+
.dsh-pcb-fold-glyph{flex:none;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:11px;font-weight:700;line-height:16px;opacity:.9}
|
|
1559
|
+
.dsh-pcb-fold[data-pcb-type="code"] .dsh-pcb-fold-glyph{color:var(--dsw-alias-state-business-primary,#4c6ef5)}
|
|
1560
|
+
.dsh-pcb-fold[data-pcb-type="text"] .dsh-pcb-fold-glyph{color:var(--dsw-alias-label-tertiary)}
|
|
1561
|
+
.dsh-pcb-fold-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
|
|
1562
|
+
.dsh-pcb-fold[data-pcb-type="text"] .dsh-pcb-fold-title{font-family:var(--dsw-font-family)}
|
|
1563
|
+
.dsh-pcb-fold-chevron{flex:none;width:14px;text-align:center;font-size:11px;line-height:1;color:var(--dsw-alias-label-tertiary);transition:transform .14s ease}
|
|
1564
|
+
.dsh-pcb-fold[data-open="1"] .dsh-pcb-fold-chevron{transform:rotate(180deg)}
|
|
1565
|
+
.dsh-pcb-fold[data-open="0"] .dsh-pcb-fold-body{display:none}
|
|
1566
|
+
.dsh-pcb-fold-body{box-sizing:border-box;margin:0;max-height:360px;overflow:auto;padding:8px 12px;border-top:1px solid var(--dsw-alias-border-l2);font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:12px;line-height:1.5;white-space:pre;word-break:break-all;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2)}
|
|
1567
|
+
|
|
908
1568
|
@media (max-width:640px){
|
|
909
1569
|
.dsh-pcb-head{height:34px}
|
|
910
1570
|
.dsh-pcb-lang{max-width:55%}
|
|
1571
|
+
.dsh-pcb-fold-body{max-height:240px}
|
|
911
1572
|
}
|
|
912
1573
|
@media (pointer:coarse){
|
|
913
1574
|
.${CHIP_X_CLASS}{width:22px;height:22px;font-size:15px}
|
|
@@ -918,7 +1579,10 @@ window.__ModuleLoader__.load({
|
|
|
918
1579
|
const inject = ['slots', 'sessions', 'conversation', 'inputTriggers', 'locale']
|
|
919
1580
|
|
|
920
1581
|
function apply(ctx) {
|
|
921
|
-
try {
|
|
1582
|
+
try { pcbDiag('LOADED', 'v0.2.5-diag') } catch (e) {}
|
|
1583
|
+
// Bump alongside package.json on every release — the load log is the
|
|
1584
|
+
// only proof of WHICH bundle generation the browser actually loaded.
|
|
1585
|
+
try { console.log('[dsh-paste-code-block] client loaded v0.2.5') } catch (e) { /* noop */ }
|
|
922
1586
|
|
|
923
1587
|
// Publish our dictionaries under the plugin namespace, then bind a
|
|
924
1588
|
// translator that always reflects the ACTIVE DSH language (Settings →
|
|
@@ -932,7 +1596,10 @@ window.__ModuleLoader__.load({
|
|
|
932
1596
|
: createT(detectBrowserLocale())
|
|
933
1597
|
|
|
934
1598
|
const controller = new BlockController(ctx, t)
|
|
1599
|
+
// Test seam (tests/fold-smoke.test.js drives recover() directly).
|
|
1600
|
+
exports.__controllerForTests = controller
|
|
935
1601
|
ctx.effect(() => () => controller.dispose(), 'paste-code-block: state')
|
|
1602
|
+
try { recoveryPruneExpired() } catch (e) { /* private mode: recovery simply off */ }
|
|
936
1603
|
|
|
937
1604
|
// ----- chip DOM sync: localized labels + per-chip ✕ delete button -----
|
|
938
1605
|
function scanChips() {
|
|
@@ -983,10 +1650,112 @@ window.__ModuleLoader__.load({
|
|
|
983
1650
|
}
|
|
984
1651
|
}
|
|
985
1652
|
|
|
1653
|
+
// ----- sent-message fold: the scan ------------------------------------
|
|
1654
|
+
// DSH renders a sent message as plain pre-wrap runs and SPLITS those runs
|
|
1655
|
+
// at every @mention chip, so one serialized block can arrive as several
|
|
1656
|
+
// sibling runs with the fence opener in one and its closer in another.
|
|
1657
|
+
// A per-run scan then sees only an unterminated half (nothing to fold) —
|
|
1658
|
+
// or, worse, a fragment that happens to look like a complete fence, which
|
|
1659
|
+
// is how a bogus card built from the tail of the message used to appear.
|
|
1660
|
+
// So fold at BUBBLE scope: rebuild the text from the runs, plan the
|
|
1661
|
+
// fences over that, and keep the per-run path only when every fence sits
|
|
1662
|
+
// inside a single run (which leaves DSH's mention chips clickable).
|
|
1663
|
+
const foldedBubbles = new WeakSet()
|
|
1664
|
+
|
|
1665
|
+
/** Hand a bubble back to DSH's own rendering. */
|
|
1666
|
+
function clearFolds(bubble) {
|
|
1667
|
+
for (const el of [...bubble.children]) {
|
|
1668
|
+
if (el.classList.contains(FOLD_HOST_CLASS)) { el.remove(); continue }
|
|
1669
|
+
if (el.getAttribute(FOLD_HIDE_ATTR) !== null) {
|
|
1670
|
+
el.style.display = ''
|
|
1671
|
+
el.removeAttribute(FOLD_HIDE_ATTR)
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
bubble.removeAttribute(FOLD_SIG_ATTR)
|
|
1675
|
+
foldedBubbles.delete(bubble)
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/** True when the DOM still matches what `sig` claims (React remounts runs). */
|
|
1679
|
+
function foldsIntact(bubble, mode, stamp, wanted) {
|
|
1680
|
+
const kids = [...bubble.children]
|
|
1681
|
+
const hosts = kids.filter((el) => el.classList.contains(FOLD_HOST_CLASS))
|
|
1682
|
+
if (hosts.length !== (mode === 'bubble' ? 1 : wanted)) return false
|
|
1683
|
+
for (const host of hosts) if (host.getAttribute(FOLD_STAMP_ATTR) !== stamp) return false
|
|
1684
|
+
const hidden = kids.filter((el) => el.getAttribute(FOLD_HIDE_ATTR) === stamp)
|
|
1685
|
+
if (hidden.length !== wanted) return false
|
|
1686
|
+
for (const el of hidden) if (el.style.display !== 'none') return false
|
|
1687
|
+
return true
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function foldBubble(bubble) {
|
|
1691
|
+
const holder = bubble.closest('[data-chat-flow-key]')
|
|
1692
|
+
const flowKey = holder ? holder.getAttribute('data-chat-flow-key') || '' : ''
|
|
1693
|
+
const runs = [...bubble.children].filter((el) => !el.classList.contains(FOLD_HOST_CLASS))
|
|
1694
|
+
const texts = runs.map((el) => el.textContent || '')
|
|
1695
|
+
const full = texts.join('')
|
|
1696
|
+
const stamp = foldStamp(full)
|
|
1697
|
+
// Cheap guard first: most messages carry no fence at all.
|
|
1698
|
+
const plan = runs.length && FENCE_HINT.test(full) ? planFoldRuns(full, texts) : null
|
|
1699
|
+
let mode = plan ? plan.mode : ''
|
|
1700
|
+
let subs = []
|
|
1701
|
+
if (plan && mode === 'single') {
|
|
1702
|
+
for (const k of plan.runs) {
|
|
1703
|
+
const sub = foldPlanFor(texts[k])
|
|
1704
|
+
if (sub) subs.push([k, sub])
|
|
1705
|
+
}
|
|
1706
|
+
// A run we cannot plan for (a mention chip, a wrapper element) is not
|
|
1707
|
+
// ours to hide — rebuild the bubble instead.
|
|
1708
|
+
if (!subs.length || plan.runs.some((k) => !isFoldRun(runs[k]))) mode = 'bubble'
|
|
1709
|
+
}
|
|
1710
|
+
const sig = plan ? `${mode}|${stamp}` : ''
|
|
1711
|
+
const wanted = plan ? (mode === 'bubble' ? runs.length : subs.length) : 0
|
|
1712
|
+
if (plan && bubble.getAttribute(FOLD_SIG_ATTR) === sig && foldsIntact(bubble, mode, stamp, wanted)) return
|
|
1713
|
+
if (!plan && !foldedBubbles.has(bubble)) return
|
|
1714
|
+
clearFolds(bubble)
|
|
1715
|
+
if (!plan) return
|
|
1716
|
+
const keyBase = flowKey || 'h' + hash32(full)
|
|
1717
|
+
const fenceCount = plan.fences ? plan.fences.length : (plan.runs || []).length
|
|
1718
|
+
pcbDiag('FOLD', 'mode=' + mode + ' runs=' + runs.length + ' fences=' + fenceCount +
|
|
1719
|
+
' hide=' + (mode === 'bubble' ? 'all' : subs.map((s) => s[0]).join(',')) + ' len=' + full.length)
|
|
1720
|
+
if (mode === 'bubble') {
|
|
1721
|
+
// Hide every run and render the whole message from the plan, so the
|
|
1722
|
+
// fence that straddled them is paired again. Mention chips inside the
|
|
1723
|
+
// folded text lose their click target — they are collapsed content.
|
|
1724
|
+
const host = buildFoldHost(plan.segments, `${keyBase}#b`, t)
|
|
1725
|
+
host.setAttribute(FOLD_STAMP_ATTR, stamp)
|
|
1726
|
+
for (const el of runs) {
|
|
1727
|
+
el.style.display = 'none'
|
|
1728
|
+
el.setAttribute(FOLD_HIDE_ATTR, stamp)
|
|
1729
|
+
}
|
|
1730
|
+
bubble.appendChild(host)
|
|
1731
|
+
} else {
|
|
1732
|
+
for (const [k, sub] of subs.slice().sort((a, b) => a[0] - b[0])) {
|
|
1733
|
+
const el = runs[k]
|
|
1734
|
+
el.style.display = 'none'
|
|
1735
|
+
el.setAttribute(FOLD_HIDE_ATTR, stamp)
|
|
1736
|
+
const host = buildFoldHost(sub, `${keyBase}#${k}`, t)
|
|
1737
|
+
host.setAttribute(FOLD_STAMP_ATTR, stamp)
|
|
1738
|
+
el.insertAdjacentElement('afterend', host)
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
bubble.setAttribute(FOLD_SIG_ATTR, sig)
|
|
1742
|
+
foldedBubbles.add(bubble)
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
function scanFolds() {
|
|
1746
|
+
if (typeof document === 'undefined') return
|
|
1747
|
+
let bubbles
|
|
1748
|
+
try { bubbles = document.querySelectorAll(FOLD_BUBBLE_SEL) } catch (e) { return }
|
|
1749
|
+
for (const bubble of bubbles) {
|
|
1750
|
+
if (!bubble.isConnected) continue
|
|
1751
|
+
try { foldBubble(bubble) } catch (e) { /* never break DSH's render loop */ }
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
986
1755
|
let scanScheduled = 0
|
|
987
1756
|
function scheduleScan() {
|
|
988
1757
|
if (scanScheduled) return
|
|
989
|
-
const run = () => { scanScheduled = 0; scanChips() }
|
|
1758
|
+
const run = () => { scanScheduled = 0; scanChips(); scanFolds() }
|
|
990
1759
|
scanScheduled = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : setTimeout(run, 16)
|
|
991
1760
|
}
|
|
992
1761
|
|
|
@@ -998,13 +1767,29 @@ window.__ModuleLoader__.load({
|
|
|
998
1767
|
}, 'paste-code-block: chip sync observer')
|
|
999
1768
|
|
|
1000
1769
|
// A locale switch (or a late dictionary registration bumps the revision
|
|
1001
|
-
// too) re-derives labels: controller state first, chip DOM second.
|
|
1770
|
+
// too) re-derives labels: controller state first, chip DOM second. Sent
|
|
1771
|
+
// fold cards are rebuilt the same way — bumping foldRev invalidates
|
|
1772
|
+
// every run stamp, so the next scan re-titles the cards in the new
|
|
1773
|
+
// language while foldOpenKeys keeps who was expanded.
|
|
1002
1774
|
ctx.effect(() => {
|
|
1003
1775
|
const face = ctx.locale
|
|
1004
1776
|
if (!face || typeof face.subscribe !== 'function') return undefined
|
|
1005
|
-
return face.subscribe(() => { controller.retitleAll(); scheduleScan() })
|
|
1777
|
+
return face.subscribe(() => { controller.retitleAll(); foldRev += 1; scheduleScan() })
|
|
1006
1778
|
}, 'paste-code-block: locale sync')
|
|
1007
|
-
scheduleScan() // late-mount safety for chips rendered before plugin boot
|
|
1779
|
+
scheduleScan() // late-mount safety for chips and sent bubbles rendered before plugin boot
|
|
1780
|
+
|
|
1781
|
+
// On dispose (plugin removed / HMR swap) hand the transcript back to DSH.
|
|
1782
|
+
ctx.effect(() => () => {
|
|
1783
|
+
try {
|
|
1784
|
+
for (const host of document.querySelectorAll(`.${FOLD_HOST_CLASS}`)) host.remove()
|
|
1785
|
+
for (const run of document.querySelectorAll(`[${FOLD_HIDE_ATTR}]`)) {
|
|
1786
|
+
run.style.display = ''
|
|
1787
|
+
run.removeAttribute(FOLD_HIDE_ATTR)
|
|
1788
|
+
}
|
|
1789
|
+
for (const bubble of document.querySelectorAll(`[${FOLD_SIG_ATTR}]`)) bubble.removeAttribute(FOLD_SIG_ATTR)
|
|
1790
|
+
} catch (e) { /* noop */ }
|
|
1791
|
+
foldOpenKeys.clear()
|
|
1792
|
+
}, 'paste-code-block: fold restore')
|
|
1008
1793
|
|
|
1009
1794
|
ctx.effect(() => ctx.inputTriggers.registerSource({
|
|
1010
1795
|
trigger: '@',
|
|
@@ -1026,6 +1811,34 @@ window.__ModuleLoader__.load({
|
|
|
1026
1811
|
return () => style.remove()
|
|
1027
1812
|
}, 'paste-code-block: styles')
|
|
1028
1813
|
|
|
1814
|
+
// Paste-storm guard. External automation — a Tampermonkey AutoClicker,
|
|
1815
|
+
// a clipboard bridge, IME glue, or a held-down Ctrl+V — can re-dispatch
|
|
1816
|
+
// the SAME clipboard text dozens of times a second. Each accepted paste
|
|
1817
|
+
// mints a new block, so a storm stuffs the composer with duplicate
|
|
1818
|
+
// chips. Collapse runs of an identical clipboard text that arrive faster
|
|
1819
|
+
// than a human can act into a single paste (a 1200 ms sliding window,
|
|
1820
|
+
// refreshed on every matching event, so a held repeat stays suppressed
|
|
1821
|
+
// while it continues). 0.2.4 added a content-liveness dedup that ALSO
|
|
1822
|
+
// suppressed a re-paste of the same text while its block was still
|
|
1823
|
+
// attached — that made "paste the same snippet twice" impossible, which
|
|
1824
|
+
// users hit immediately. 0.2.5 reverts that: identical text pasted after
|
|
1825
|
+
// the burst window is a fresh intent and gets its own block. Only
|
|
1826
|
+
// machine-paced repeats within the window are collapsed.
|
|
1827
|
+
const STORM_WINDOW_MS = 1200
|
|
1828
|
+
const pasteStorm = {
|
|
1829
|
+
lastText: '',
|
|
1830
|
+
lastAt: 0,
|
|
1831
|
+
// True when this text is the tail of a storm; every call refreshes
|
|
1832
|
+
// the window, so a held repeat stays suppressed while it continues.
|
|
1833
|
+
seen(text, now) {
|
|
1834
|
+
const dup = text === this.lastText && now - this.lastAt < STORM_WINDOW_MS
|
|
1835
|
+
this.lastText = text
|
|
1836
|
+
this.lastAt = now
|
|
1837
|
+
return dup
|
|
1838
|
+
},
|
|
1839
|
+
}
|
|
1840
|
+
exports.__pasteStormForTests = pasteStorm
|
|
1841
|
+
|
|
1029
1842
|
// Turn qualifying pastes into boxed inline chips.
|
|
1030
1843
|
const onPaste = (ev) => {
|
|
1031
1844
|
const clip = ev.clipboardData
|
|
@@ -1035,14 +1848,30 @@ window.__ModuleLoader__.load({
|
|
|
1035
1848
|
const target = document.activeElement
|
|
1036
1849
|
if (!target || !(target instanceof Element)) return
|
|
1037
1850
|
const sid = sessionFor(target)
|
|
1038
|
-
if (!sid) return
|
|
1851
|
+
if (!sid) { pcbDiag("EXIT-SID-NULL", "activeEl=" + target.tagName + "." + (target.className || "") + " mark=" + !!document.querySelector("[data-dsh-pcb]")); return }
|
|
1039
1852
|
const block = parseBlock(text)
|
|
1040
|
-
if (!block) { console.log('[dsh-paste-code-block] paste NOT block', text.length); return }
|
|
1041
|
-
|
|
1853
|
+
if (!block) { pcbDiag("EXIT-NOT-BLOCK", "len=" + text.length); console.log('[dsh-paste-code-block] paste NOT block', text.length); return }
|
|
1854
|
+
if (pasteStorm.seen(text, Date.now())) {
|
|
1855
|
+
// Consume the event (never let the raw text fall through to the
|
|
1856
|
+
// composer) but create nothing.
|
|
1857
|
+
ev.preventDefault()
|
|
1858
|
+
ev.stopPropagation()
|
|
1859
|
+
pcbDiag("EXIT-STORM", "len=" + text.length);
|
|
1860
|
+
console.log('[dsh-paste-code-block] storm paste ignored (<' + STORM_WINDOW_MS + 'ms repeat)')
|
|
1861
|
+
return
|
|
1862
|
+
}
|
|
1863
|
+
block.sourceText = text
|
|
1864
|
+
// trusted=false means a page script dispatched this paste (userscript,
|
|
1865
|
+
// clipboard bridge, replay glue) rather than a real keystroke — the
|
|
1866
|
+
// load-log line that names the source when a storm report comes in.
|
|
1867
|
+
pcbDiag("ENTER-INTERCEPT", "sid=" + sid + " lines=" + block.lines.length + " code=" + block.isCode + " trusted=" + ev.isTrusted);
|
|
1868
|
+
console.log('[dsh-paste-code-block] intercept', block.isCode ? 'code' : 'text', block.lines.length, 'lang=', block.lang, 'trusted=', ev.isTrusted)
|
|
1042
1869
|
ev.preventDefault()
|
|
1043
1870
|
ev.stopPropagation()
|
|
1044
1871
|
try {
|
|
1045
1872
|
if (!controller.attach(sid, block)) {
|
|
1873
|
+
const snapX = (() => { try { const s = controller.scope(sid).shell.snapshot; return "phase=" + (s && s.phase) + " draftLen=" + (s && s.draft ? s.draft.length : -1) + " rev=" + (s && s.draftRev) } catch (e) { return "scope-throw:" + e.message } })();
|
|
1874
|
+
pcbDiag("EXIT-ATTACH-FAIL", snapX);
|
|
1046
1875
|
console.log('[dsh-paste-code-block] attach failed; plain insert instead')
|
|
1047
1876
|
const { actx, shell } = controller.scope(sid)
|
|
1048
1877
|
const input = shell.snapshot
|
|
@@ -1057,6 +1886,17 @@ window.__ModuleLoader__.load({
|
|
|
1057
1886
|
console.warn('[dsh-paste-code-block] could not insert block text')
|
|
1058
1887
|
}
|
|
1059
1888
|
}
|
|
1889
|
+
pcbDiag("OK-ATTACHED", "id=" + block.id);
|
|
1890
|
+
setTimeout(() => { try {
|
|
1891
|
+
const sc = controller.scope(sid).shell.snapshot || {};
|
|
1892
|
+
const occ = (sc.occurrences || []).filter((o) => o.source === 'paste-code-block').length;
|
|
1893
|
+
var __chip = document.querySelector('[data-composer-chip="paste-code-block"]'); var __r = __chip && __chip.getBoundingClientRect(); var __cs = __chip && getComputedStyle(__chip); var __csi = __chip && __chip.firstElementChild && getComputedStyle(__chip.firstElementChild); pcbDiag("POST-ATTACH", "phase=" + sc.phase + " draftLen=" + (sc.draft ? sc.draft.length : -1) + " occ=" + occ + " chips=" + document.querySelectorAll('[data-composer-chip="paste-code-block"]').length);
|
|
1894
|
+
var __hosts = []; var __seen = new Set();
|
|
1895
|
+
document.querySelectorAll('[data-composer-chip]').forEach(function (c) { var k = c.getAttribute('data-composer-chip') + '|' + (c.closest('[data-composer-seat]') ? 'seat' : (c.closest('[data-message-id],[data-message-id] *,.dsh-msg,.message') ? 'msg' : '?')); if (!__seen.has(k)) { __seen.add(k); __hosts.push(k) } });
|
|
1896
|
+
pcbDiag("CHIP-HOSTS", __hosts.join(',') || 'none');
|
|
1897
|
+
var __seats = [].map.call(document.querySelectorAll('[data-composer-seat]'), function (s) { return s.getBoundingClientRect().width + 'x' + Math.round(s.getBoundingClientRect().height) }).join(',');
|
|
1898
|
+
pcbDiag("SEATS", "n=" + document.querySelectorAll('[data-composer-seat]').length + " sizes=" + (__seats || '-') + " ce=" + document.querySelectorAll('[contenteditable="true"]').length); pcbDiag("CHIP-DOM", __chip ? ("rect=" + Math.round(__r.width) + "x" + Math.round(__r.height) + "@" + Math.round(__r.x) + "," + Math.round(__r.y) + " disp=" + __cs.display + " vis=" + __cs.visibility + " op=" + __cs.opacity + " innerDisp=" + (__csi ? __csi.display : "?") + " txt=(" + __chip.textContent.slice(0, 24) + ") parent=" + (__chip.parentElement ? __chip.parentElement.tagName + "." + String(__chip.parentElement.className).slice(0, 40) : "?")) : "NO-CHIP");
|
|
1899
|
+
} catch (e) { pcbDiag("POST-ATTACH-THROW", e.message) } }, 350);
|
|
1060
1900
|
} catch (err) {
|
|
1061
1901
|
console.error('[paste-code-block] attach failed:', err)
|
|
1062
1902
|
}
|