dsh-paste-code-block 0.1.2 → 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 +17 -11
- package/README.zh-CN.md +17 -11
- package/docs/design.md +144 -3
- package/docs/images/hero.png +0 -0
- package/docs/images/hero.svg +1 -1
- package/docs/images/languages.png +0 -0
- package/docs/images/languages.svg +1 -1
- package/docs/images/render.cjs +20 -0
- package/docs/images/workflow.png +0 -0
- package/package.json +20 -9
- package/src/client.js +677 -18
- package/src/i18n.js +42 -13
- package/src/parse.js +64 -0
package/src/client.js
CHANGED
|
@@ -94,8 +94,8 @@ window.__ModuleLoader__.load({
|
|
|
94
94
|
const NS = 'paste-code-block'
|
|
95
95
|
const L10N = {
|
|
96
96
|
zh: {
|
|
97
|
-
'block.code': '
|
|
98
|
-
'block.text': '
|
|
97
|
+
'block.code': '代码块 {n}',
|
|
98
|
+
'block.text': '文本块 {n}',
|
|
99
99
|
'detail.aria': '代码块详情',
|
|
100
100
|
'slot.label': '粘贴代码块详情',
|
|
101
101
|
'lines.one': '{count} 行',
|
|
@@ -109,10 +109,13 @@ window.__ModuleLoader__.load({
|
|
|
109
109
|
'edit.aria': '编辑代码块内容',
|
|
110
110
|
'error.stale': '代码块已失效,请重新粘贴',
|
|
111
111
|
'error.remove': '无法删除该块,请重试',
|
|
112
|
+
'fold.code': '代码块',
|
|
113
|
+
'fold.text': '文本块',
|
|
114
|
+
'fold.aria': '展开或折叠该块',
|
|
112
115
|
},
|
|
113
116
|
en: {
|
|
114
|
-
'block.code': 'Code
|
|
115
|
-
'block.text': 'Text
|
|
117
|
+
'block.code': 'Code #{n}',
|
|
118
|
+
'block.text': 'Text #{n}',
|
|
116
119
|
'detail.aria': 'Block details',
|
|
117
120
|
'slot.label': 'Pasted block details',
|
|
118
121
|
'lines.one': '{count} line',
|
|
@@ -126,6 +129,9 @@ window.__ModuleLoader__.load({
|
|
|
126
129
|
'edit.aria': 'Edit block content',
|
|
127
130
|
'error.stale': 'This block is no longer valid — paste it again',
|
|
128
131
|
'error.remove': 'Could not remove the block — please retry',
|
|
132
|
+
'fold.code': 'Code',
|
|
133
|
+
'fold.text': 'Text',
|
|
134
|
+
'fold.aria': 'Expand or collapse this block',
|
|
129
135
|
},
|
|
130
136
|
}
|
|
131
137
|
|
|
@@ -138,6 +144,13 @@ window.__ModuleLoader__.load({
|
|
|
138
144
|
}
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
// Sent-message fold card title (embedded copy of src/i18n.js#foldTitle).
|
|
148
|
+
function foldTitle(t, type, lang, lines) {
|
|
149
|
+
const meta = t(lines === 1 ? 'lines.one' : 'lines.other', { count: lines })
|
|
150
|
+
const name = type === 'code' ? (lang || t('fold.code')) : t('fold.text')
|
|
151
|
+
return `${name} · ${meta}`
|
|
152
|
+
}
|
|
153
|
+
|
|
141
154
|
function detectBrowserLocale() {
|
|
142
155
|
try {
|
|
143
156
|
const langs = [...(navigator.languages || []), navigator.language].filter(Boolean).map((l) => String(l).toLowerCase())
|
|
@@ -147,15 +160,22 @@ window.__ModuleLoader__.load({
|
|
|
147
160
|
}
|
|
148
161
|
}
|
|
149
162
|
|
|
163
|
+
// Pre-0.1.3 chip labels: no longer rendered, still recognized (see below).
|
|
164
|
+
const LEGACY_L10N = {
|
|
165
|
+
zh: { 'block.code': '复制代码块{n}', 'block.text': '复制文本块{n}' },
|
|
166
|
+
en: { 'block.code': 'Code block #{n}', 'block.text': 'Text block #{n}' },
|
|
167
|
+
}
|
|
168
|
+
|
|
150
169
|
/**
|
|
151
|
-
* Recognize a rendered block label in ANY shipped locale
|
|
152
|
-
* `{ type, n }` identity — this keeps chip
|
|
153
|
-
* locale-switch retitling correct even for
|
|
154
|
-
* different language than the one currently
|
|
170
|
+
* Recognize a rendered block label in ANY shipped locale (or a pre-0.1.3
|
|
171
|
+
* legacy locale) and recover its `{ type, n }` identity — this keeps chip
|
|
172
|
+
* clicks, ✕ deletes, and the locale-switch retitling correct even for
|
|
173
|
+
* chips inserted under a different language than the one currently
|
|
174
|
+
* active. Null otherwise.
|
|
155
175
|
*/
|
|
156
176
|
function parseBlockLabel(text) {
|
|
157
177
|
if (!text) return null
|
|
158
|
-
for (const dict of [L10N.zh, L10N.en]) {
|
|
178
|
+
for (const dict of [L10N.zh, L10N.en, LEGACY_L10N.zh, LEGACY_L10N.en]) {
|
|
159
179
|
for (const key of ['block.code', 'block.text']) {
|
|
160
180
|
const pattern = new RegExp(
|
|
161
181
|
'^' +
|
|
@@ -201,8 +221,8 @@ window.__ModuleLoader__.load({
|
|
|
201
221
|
/**
|
|
202
222
|
* Parse pasted text into a block, or null when it is ordinary short prose
|
|
203
223
|
* that should paste plainly. `isCode` drives both the chip label
|
|
204
|
-
* (i18n key `block.code` vs `block.text`, e.g. "
|
|
205
|
-
*
|
|
224
|
+
* (i18n key `block.code` vs `block.text`, e.g. "代码块 N" / "Code #N")
|
|
225
|
+
* and the fenced language on send.
|
|
206
226
|
*/
|
|
207
227
|
function parseBlock(raw) {
|
|
208
228
|
const text = String(raw || '').replace(/^\uFEFF/, '')
|
|
@@ -262,6 +282,134 @@ window.__ModuleLoader__.load({
|
|
|
262
282
|
return `\n\`\`\`${lang}\n${block.content}\n\`\`\`\n`
|
|
263
283
|
}
|
|
264
284
|
|
|
285
|
+
// ----- fence segmentation (embedded copy of src/parse.js) ----------------
|
|
286
|
+
// Split a message text into ordered prose/fence segments (GFM-style ``` or
|
|
287
|
+
// ~~~ fences, opener indented <=3 spaces, closer >= opener length alone on
|
|
288
|
+
// its line). Unterminated openers stay prose. Keep in sync with parse.js.
|
|
289
|
+
function splitFencedSegments(text) {
|
|
290
|
+
const src = String(text == null ? '' : text)
|
|
291
|
+
if (!src) return []
|
|
292
|
+
const lines = src.split(/\r?\n/)
|
|
293
|
+
const segments = []
|
|
294
|
+
const OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
|
|
295
|
+
let proseStart = 0
|
|
296
|
+
let i = 0
|
|
297
|
+
const flushProse = (end) => {
|
|
298
|
+
if (end > proseStart) segments.push({ kind: 'prose', text: lines.slice(proseStart, end).join('\n') })
|
|
299
|
+
}
|
|
300
|
+
while (i < lines.length) {
|
|
301
|
+
const m = OPEN.exec(lines[i])
|
|
302
|
+
if (!m) { i += 1; continue }
|
|
303
|
+
const marker = m[1]
|
|
304
|
+
const ch = marker[0]
|
|
305
|
+
const info = m[2].trim()
|
|
306
|
+
if (ch === '`' && info.includes('`')) { i += 1; continue }
|
|
307
|
+
const closeRe = new RegExp('^ {0,3}' + (ch === '`' ? '`' : '~') + '{' + marker.length + ',}[ \\t]*$')
|
|
308
|
+
let j = i + 1
|
|
309
|
+
let closed = false
|
|
310
|
+
while (j < lines.length) {
|
|
311
|
+
if (closeRe.test(lines[j])) { closed = true; break }
|
|
312
|
+
j += 1
|
|
313
|
+
}
|
|
314
|
+
if (!closed) { i += 1; continue }
|
|
315
|
+
flushProse(i)
|
|
316
|
+
const bodyLines = lines.slice(i + 1, j)
|
|
317
|
+
segments.push({
|
|
318
|
+
kind: 'fence',
|
|
319
|
+
lang: info ? info.split(/\s+/)[0] : '',
|
|
320
|
+
info,
|
|
321
|
+
body: bodyLines.join('\n'),
|
|
322
|
+
raw: lines.slice(i, j + 1).join('\n'),
|
|
323
|
+
})
|
|
324
|
+
i = j + 1
|
|
325
|
+
proseStart = i
|
|
326
|
+
}
|
|
327
|
+
flushProse(lines.length)
|
|
328
|
+
return segments
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ===== Recovery mirror (draft re-seed after workspace switch / reload) ===
|
|
332
|
+
// DSH persists a session's composer draft as PLAIN TEXT
|
|
333
|
+
// (`dsh.conversation.<sid>` in localStorage) and re-seeds the editor from
|
|
334
|
+
// it — but Lexical chip nodes cannot round-trip through text (design.md
|
|
335
|
+
// §7), so switching workspaces mid-draft left every pasted block behind:
|
|
336
|
+
// the seeded draft keeps only one invisible U+200B per lost chip. The
|
|
337
|
+
// plugin therefore mirrors its block CONTENT to localStorage itself and
|
|
338
|
+
// re-attaches the chips when it recognizes that stray-ZWSP signature
|
|
339
|
+
// (BlockDock mount effect → BlockController.recover). Intentional chip
|
|
340
|
+
// deletion removes the ZWSPs along with the nodes, so it never looks
|
|
341
|
+
// like a re-seed; the mirror clears itself on send and once no blocks
|
|
342
|
+
// remain, and stale entries expire after two weeks.
|
|
343
|
+
const RECOVERY_PREFIX = 'dsh-pcb-recovery.'
|
|
344
|
+
const RECOVERY_TTL_MS = 14 * 24 * 60 * 60 * 1000
|
|
345
|
+
const RECOVERY_MAX_CHARS = 3_000_000
|
|
346
|
+
const RECOVERY_MAX_DRAFT = 200_000
|
|
347
|
+
|
|
348
|
+
function recoverySave(sid, blocks, draftText) {
|
|
349
|
+
const key = RECOVERY_PREFIX + sid
|
|
350
|
+
try {
|
|
351
|
+
if (!blocks || blocks.length === 0) { localStorage.removeItem(key); return }
|
|
352
|
+
const draft = typeof draftText === 'string' && draftText.length <= RECOVERY_MAX_DRAFT ? draftText : ''
|
|
353
|
+
const json = JSON.stringify({ at: Date.now(), draft, blocks })
|
|
354
|
+
if (json.length > RECOVERY_MAX_CHARS) { console.warn('[dsh-pcb] draft too large to mirror; chips will not survive a reload'); return }
|
|
355
|
+
localStorage.setItem(key, json)
|
|
356
|
+
} catch (e) { /* quota / private mode: best effort */ }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function recoveryLoad(sid) {
|
|
360
|
+
const key = RECOVERY_PREFIX + sid
|
|
361
|
+
try {
|
|
362
|
+
const raw = localStorage.getItem(key)
|
|
363
|
+
if (!raw) return null
|
|
364
|
+
let data = null
|
|
365
|
+
try { data = JSON.parse(raw) } catch (e) { data = null }
|
|
366
|
+
if (!data || typeof data.at !== 'number' || Date.now() - data.at > RECOVERY_TTL_MS) { localStorage.removeItem(key); return null }
|
|
367
|
+
return data
|
|
368
|
+
} catch (e) { return null }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function recoveryClear(sid) {
|
|
372
|
+
try { localStorage.removeItem(RECOVERY_PREFIX + sid) } catch (e) { /* noop */ }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Mirror-side carry-over donor: a store whose mirrored draft matches the
|
|
377
|
+
* re-seeded one exactly and still holds blocks (after a page reload there
|
|
378
|
+
* is no live memory to match against). Returns { sid, blocks } on a
|
|
379
|
+
* UNIQUE match, null when absent or ambiguous.
|
|
380
|
+
*/
|
|
381
|
+
function recoveryScanDonor(targetSid, draft) {
|
|
382
|
+
try {
|
|
383
|
+
let hit = null
|
|
384
|
+
for (let i = 0; i < localStorage.length; i += 1) {
|
|
385
|
+
const key = localStorage.key(i)
|
|
386
|
+
if (!key || !key.startsWith(RECOVERY_PREFIX)) continue
|
|
387
|
+
const sid = key.slice(RECOVERY_PREFIX.length)
|
|
388
|
+
if (sid === targetSid) continue
|
|
389
|
+
const data = recoveryLoad(sid)
|
|
390
|
+
if (!data || data.draft !== draft || !Array.isArray(data.blocks)) continue
|
|
391
|
+
if (!data.blocks.some((b) => b && typeof b.content === 'string' && b.content !== '')) continue
|
|
392
|
+
if (hit) return null // ambiguous — refuse to guess
|
|
393
|
+
hit = { sid, blocks: data.blocks }
|
|
394
|
+
}
|
|
395
|
+
return hit
|
|
396
|
+
} catch (e) { return null }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function recoveryPruneExpired() {
|
|
400
|
+
try {
|
|
401
|
+
const doomed = []
|
|
402
|
+
for (let i = 0; i < localStorage.length; i += 1) {
|
|
403
|
+
const k = localStorage.key(i)
|
|
404
|
+
if (!k || !k.startsWith(RECOVERY_PREFIX)) continue
|
|
405
|
+
let at = 0
|
|
406
|
+
try { at = (JSON.parse(localStorage.getItem(k) || '{}').at) || 0 } catch (e) { /* fall through */ }
|
|
407
|
+
if (!at || Date.now() - at > RECOVERY_TTL_MS) doomed.push(k)
|
|
408
|
+
}
|
|
409
|
+
for (const k of doomed) localStorage.removeItem(k)
|
|
410
|
+
} catch (e) { /* noop */ }
|
|
411
|
+
}
|
|
412
|
+
|
|
265
413
|
// ===== Draft controller ==================================================
|
|
266
414
|
class BlockController {
|
|
267
415
|
/**
|
|
@@ -282,6 +430,8 @@ window.__ModuleLoader__.load({
|
|
|
282
430
|
this.expiry = new Map()
|
|
283
431
|
this.serializing = new Set()
|
|
284
432
|
this.listeners = new Map()
|
|
433
|
+
this.recoverTimer = new Map() // sid -> debounce handle for the recovery mirror
|
|
434
|
+
this.lastInput = new Map() // sid -> draft text seen by the last recover() pass
|
|
285
435
|
this.counter = 0
|
|
286
436
|
}
|
|
287
437
|
|
|
@@ -438,6 +588,16 @@ window.__ModuleLoader__.load({
|
|
|
438
588
|
this.refIndex.set(block.id, { sessionId: k, block })
|
|
439
589
|
this.labelIndex.set(block.label, block.id)
|
|
440
590
|
this.publish(sessionId)
|
|
591
|
+
// The shell just rewrote the draft (chip + placeholder char); until
|
|
592
|
+
// the next dock pass runs, this is the session's ground truth. Keep
|
|
593
|
+
// the typing-window fingerprint and the carry-over donor match fresh
|
|
594
|
+
// so the pass right after a paste cannot mistake the new draft for a
|
|
595
|
+
// rebuild.
|
|
596
|
+
try {
|
|
597
|
+
const after = this.scope(k).shell.snapshot
|
|
598
|
+
if (after && typeof after.draft === 'string') this.lastInput.set(k, after.draft)
|
|
599
|
+
} catch (e) { /* session vanished — the next pass re-observes */ }
|
|
600
|
+
this.mirrorNow(k) // attach is a key moment — persist synchronously
|
|
441
601
|
return true
|
|
442
602
|
}
|
|
443
603
|
|
|
@@ -563,6 +723,7 @@ window.__ModuleLoader__.load({
|
|
|
563
723
|
for (const [label, r] of this.labelIndex) if (r === ref) this.labelIndex.delete(label)
|
|
564
724
|
this.freeLabel(sessionId, entry.type, entry.labelNumber)
|
|
565
725
|
this.publish(sessionId)
|
|
726
|
+
this.mirror(k)
|
|
566
727
|
}
|
|
567
728
|
|
|
568
729
|
/** Update a block's content while editing it in the detail card. */
|
|
@@ -571,23 +732,31 @@ window.__ModuleLoader__.load({
|
|
|
571
732
|
if (!record) return
|
|
572
733
|
record.block.content = content
|
|
573
734
|
record.block.lines = content.split(/\r?\n/)
|
|
735
|
+
this.mirror(record.sessionId)
|
|
574
736
|
}
|
|
575
737
|
|
|
576
738
|
clearInFlight(sessionId) {
|
|
577
739
|
const k = String(sessionId)
|
|
578
740
|
const expiry = this.expiry.get(k)
|
|
579
741
|
if (expiry) { clearTimeout(expiry); this.expiry.delete(k) }
|
|
742
|
+
const had = this.inFlight.has(k)
|
|
580
743
|
for (const b of this.inFlight.get(k) || []) {
|
|
581
744
|
this.refIndex.delete(b.id)
|
|
582
745
|
this.freeLabel(sessionId, b.type, b.labelNumber)
|
|
583
746
|
for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
|
|
584
747
|
}
|
|
585
748
|
this.inFlight.delete(k)
|
|
749
|
+
if (had) this.mirrorNow(k)
|
|
586
750
|
}
|
|
587
751
|
|
|
588
752
|
markSerializing(ref) {
|
|
589
753
|
const record = this.refIndex.get(ref)
|
|
590
|
-
if (record && !this.serializing.has(record.sessionId))
|
|
754
|
+
if (record && !this.serializing.has(record.sessionId)) {
|
|
755
|
+
this.serializing.add(record.sessionId)
|
|
756
|
+
// A send is starting: the content leaves the draft, the mirror may go.
|
|
757
|
+
// (A failed send re-mirrors via restoreFailed.)
|
|
758
|
+
recoveryClear(record.sessionId)
|
|
759
|
+
}
|
|
591
760
|
}
|
|
592
761
|
|
|
593
762
|
async serialize(ref) {
|
|
@@ -633,6 +802,12 @@ window.__ModuleLoader__.load({
|
|
|
633
802
|
if (next.length > 0) this.list.set(k, next)
|
|
634
803
|
else this.list.delete(k)
|
|
635
804
|
this.publish(sessionId)
|
|
805
|
+
// Hand-deletion is final. Persist the pruned state synchronously so a
|
|
806
|
+
// later reload/carry-over cannot resurrect a deleted chip from a
|
|
807
|
+
// mirror the draft has already outlived — and so the recovery
|
|
808
|
+
// restore-path can never fire off invisible external residue once the
|
|
809
|
+
// list for this session is empty.
|
|
810
|
+
this.mirrorNow(k)
|
|
636
811
|
}
|
|
637
812
|
|
|
638
813
|
restoreFailed(sessionId) {
|
|
@@ -654,10 +829,168 @@ window.__ModuleLoader__.load({
|
|
|
654
829
|
}
|
|
655
830
|
if (survived.length) this.list.set(k, [...this.listFor(k), ...survived])
|
|
656
831
|
this.publish(sessionId)
|
|
832
|
+
if (survived.length) this.mirror(k)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ----- recovery mirror -------------------------------------------------
|
|
836
|
+
/** Debounced persistence of the session's current block payloads. */
|
|
837
|
+
mirror(sessionId) {
|
|
838
|
+
const k = String(sessionId)
|
|
839
|
+
const prev = this.recoverTimer.get(k)
|
|
840
|
+
if (prev) clearTimeout(prev)
|
|
841
|
+
this.recoverTimer.set(k, setTimeout(() => {
|
|
842
|
+
this.recoverTimer.delete(k)
|
|
843
|
+
this.mirrorNow(k)
|
|
844
|
+
}, 250))
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
mirrorNow(sessionId) {
|
|
848
|
+
const k = String(sessionId)
|
|
849
|
+
const t = this.recoverTimer.get(k)
|
|
850
|
+
if (t) { clearTimeout(t); this.recoverTimer.delete(k) }
|
|
851
|
+
recoverySave(k, this.listFor(k).map((b) => ({ isCode: !!b.isCode, lang: b.lang || '', content: b.content, src: b.sourceText || '' })), this.lastInput.get(k))
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Restore the chips a text-only draft rebuild dropped. Two DSH paths
|
|
856
|
+
* strand them: (a) the persisted per-session draft re-seeds the composer
|
|
857
|
+
* after a page reload, and (b) a workspace switch on the new-session
|
|
858
|
+
* page CARRIES the draft plain-text to the target workspace's blank
|
|
859
|
+
* session (inputHub: next.setDraft(from.snapshot.draft)) — the chips,
|
|
860
|
+
* which are Lexical nodes plus controller state, never move. Both leave
|
|
861
|
+
* the same signature: stray U+200Bs (every chip contributes exactly one,
|
|
862
|
+
* and removing a chip removes its ZWSP too) with no live occurrence of
|
|
863
|
+
* ours. Deliberate deletion therefore never looks like a rebuild; and a
|
|
864
|
+
* carry-over is matched to its donor by the exact draft the donor last
|
|
865
|
+
* published — unique match only, no guessing. Blocks come from memory
|
|
866
|
+
* first (same tab), then this session's mirror, then the donor's memory
|
|
867
|
+
* or mirror. A stray U+200B that belongs to NO restorable block is
|
|
868
|
+
* ordinary invisible copy from an external app (WeChat, web pages):
|
|
869
|
+
* never restorable, cosmetic — it is left exactly where the user put
|
|
870
|
+
* it. Sweeping it used to rewrite the whole draft mid-typing and is
|
|
871
|
+
* retired in 0.2.2 (see the typing-window guard below).
|
|
872
|
+
*
|
|
873
|
+
* @param sessionId - session whose composer just (re)mounted.
|
|
874
|
+
* @param snapshot - live input state ({draft, occurrences, phase}).
|
|
875
|
+
* @returns whether the draft was rewritten by a real restore.
|
|
876
|
+
*/
|
|
877
|
+
recover(sessionId, snapshot) {
|
|
878
|
+
const k = String(sessionId)
|
|
879
|
+
if (!snapshot || typeof snapshot.draft !== 'string') return false
|
|
880
|
+
const prior = this.lastInput.get(k)
|
|
881
|
+
this.lastInput.set(k, snapshot.draft)
|
|
882
|
+
if (this.serializing.has(k)) return false
|
|
883
|
+
if (snapshot.phase && snapshot.phase !== 'plain') return false
|
|
884
|
+
if ((snapshot.occurrences || []).some((o) => o.source === SOURCE)) return false
|
|
885
|
+
const draft = snapshot.draft
|
|
886
|
+
const strays = (draft.match(/\u200B/g) || []).length
|
|
887
|
+
if (strays === 0) return false
|
|
888
|
+
// Typing-window guard. Every committed keystroke re-runs this pass, so
|
|
889
|
+
// a draft that MOVED since the pass before it is live typing, not a
|
|
890
|
+
// text-only re-seed — and a real restore rewrites the whole document
|
|
891
|
+
// via setDraft: caret to the end, chips re-appended, an in-flight IME
|
|
892
|
+
// composition destroyed (0.2.2 bug: the first character typed was
|
|
893
|
+
// eaten and the view bounced once, again and again). Only a draft
|
|
894
|
+
// IDENTICAL to the last one observed for this session — or one this
|
|
895
|
+
// page view has never seen, or only ever saw the composer hydrate
|
|
896
|
+
// through while empty (fresh-boot re-seed) — can carry the rebuild
|
|
897
|
+
// signature. The empty-prior escape is sealed from the other side by
|
|
898
|
+
// reconcile pruning synchronously dropping the mirror: with nothing
|
|
899
|
+
// restorable on record, a moved-from-empty pass restores nothing.
|
|
900
|
+
if (prior && prior !== draft) return false
|
|
901
|
+
let shell
|
|
902
|
+
try {
|
|
903
|
+
shell = this.scope(k).shell
|
|
904
|
+
} catch (e) { return false } // session not ready yet — a later pass retries
|
|
905
|
+
const usable = (arr) => Array.isArray(arr)
|
|
906
|
+
? arr.filter((b) => b && typeof b.content === 'string' && b.content !== '')
|
|
907
|
+
: []
|
|
908
|
+
let sources = usable(this.listFor(k))
|
|
909
|
+
let origin = sources.length ? 'memory' : null
|
|
910
|
+
let donor = null
|
|
911
|
+
if (!sources.length) {
|
|
912
|
+
const saved = recoveryLoad(k)
|
|
913
|
+
sources = usable(saved && saved.blocks)
|
|
914
|
+
if (sources.length) origin = 'mirror'
|
|
915
|
+
}
|
|
916
|
+
if (!sources.length) {
|
|
917
|
+
donor = this.findCarryoverSource(k, draft)
|
|
918
|
+
if (donor) {
|
|
919
|
+
sources = usable(this.listFor(donor))
|
|
920
|
+
origin = 'carry-over (live)'
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (!sources.length) {
|
|
924
|
+
const scanned = recoveryScanDonor(k, draft)
|
|
925
|
+
if (scanned && scanned.blocks.length) {
|
|
926
|
+
donor = scanned.sid
|
|
927
|
+
sources = usable(scanned.blocks)
|
|
928
|
+
origin = 'carry-over (mirror)'
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (sources.length === 0) {
|
|
932
|
+
// Nothing to restore — invisible external residue (or the residue of
|
|
933
|
+
// a deliberately deleted chip whose node already carries none of our
|
|
934
|
+
// state). NEVER rewrite the draft for it: rewriting drops the caret,
|
|
935
|
+
// destroys an in-flight IME composition and re-seeds stale text over
|
|
936
|
+
// whatever the user typed in the meantime (0.2.2).
|
|
937
|
+
return false
|
|
938
|
+
}
|
|
939
|
+
try {
|
|
940
|
+
shell.setDraft(draft.replace(/\u200B/g, ''))
|
|
941
|
+
} catch (e) {
|
|
942
|
+
console.warn('[dsh-pcb] recovery: draft rewrite failed', e)
|
|
943
|
+
return false
|
|
944
|
+
}
|
|
945
|
+
// The draft — and with it the blocks — now belongs to this session:
|
|
946
|
+
// forget the (possibly moved) bookkeeping; attach() re-derives ids,
|
|
947
|
+
// labels and numbers, and re-mirrors under this sid.
|
|
948
|
+
this.forgetSession(k)
|
|
949
|
+
if (donor) {
|
|
950
|
+
this.forgetSession(donor)
|
|
951
|
+
recoveryClear(donor)
|
|
952
|
+
this.lastInput.delete(donor)
|
|
953
|
+
}
|
|
954
|
+
let restored = 0
|
|
955
|
+
for (const b of sources) {
|
|
956
|
+
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) }
|
|
957
|
+
if (this.attach(k, block)) restored += 1
|
|
958
|
+
}
|
|
959
|
+
console.log('[dsh-pcb] recovery: re-attached', restored, 'of', sources.length, 'block(s) via', origin, donor ? `(donor ${donor})` : '')
|
|
960
|
+
return restored > 0
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Unmounted session that still lists blocks and last published EXACTLY this draft. */
|
|
964
|
+
findCarryoverSource(targetSid, draft) {
|
|
965
|
+
let found = null
|
|
966
|
+
for (const [x, entries] of this.list) {
|
|
967
|
+
if (x === targetSid || entries.length === 0) continue
|
|
968
|
+
if (this.serializing.has(x)) continue
|
|
969
|
+
const listeners = this.listeners.get(x)
|
|
970
|
+
if (listeners && listeners.size > 0) continue // still mounted — draft did not move
|
|
971
|
+
if (this.lastInput.get(x) !== draft) continue
|
|
972
|
+
if (found) return null // ambiguous
|
|
973
|
+
found = x
|
|
974
|
+
}
|
|
975
|
+
return found
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Drop a session's block bookkeeping (its chips are gone or moving). */
|
|
979
|
+
forgetSession(sessionId) {
|
|
980
|
+
const k = String(sessionId)
|
|
981
|
+
for (const b of this.listFor(k)) {
|
|
982
|
+
this.freeLabel(k, b.type, b.labelNumber)
|
|
983
|
+
this.refIndex.delete(b.id)
|
|
984
|
+
for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
|
|
985
|
+
}
|
|
986
|
+
this.list.delete(k)
|
|
987
|
+
this.selected.delete(k)
|
|
657
988
|
}
|
|
658
989
|
|
|
659
990
|
dispose() {
|
|
660
991
|
for (const t of this.expiry.values()) clearTimeout(t)
|
|
992
|
+
for (const t of this.recoverTimer.values()) clearTimeout(t)
|
|
993
|
+
this.recoverTimer.clear()
|
|
661
994
|
this.expiry.clear(); this.list.clear(); this.selected.clear()
|
|
662
995
|
this.used.clear(); this.refIndex.clear(); this.labelIndex.clear()
|
|
663
996
|
this.inFlight.clear(); this.serializing.clear(); this.listeners.clear()
|
|
@@ -720,6 +1053,12 @@ window.__ModuleLoader__.load({
|
|
|
720
1053
|
useComposerMark(railRef, props.sessionId, input.draftRev, blocks.length > 0)
|
|
721
1054
|
|
|
722
1055
|
React.useEffect(() => {
|
|
1056
|
+
// Recognize a text-only draft re-seed (workspace switch / reload)
|
|
1057
|
+
// FIRST: re-attach lost chips before reconciliation prunes them. A
|
|
1058
|
+
// successful recovery mutates the draft, so this pass must NOT
|
|
1059
|
+
// reconcile against its now-stale snapshot — the next draftRev pass
|
|
1060
|
+
// does that with fresh occurrences.
|
|
1061
|
+
if (props.controller.recover(props.sessionId, input)) return
|
|
723
1062
|
props.controller.reconcile(props.sessionId, input.occurrences || [], input.phase)
|
|
724
1063
|
}, [props.controller, props.sessionId, input.draftRev, input.phase])
|
|
725
1064
|
|
|
@@ -827,6 +1166,162 @@ window.__ModuleLoader__.load({
|
|
|
827
1166
|
)
|
|
828
1167
|
}
|
|
829
1168
|
|
|
1169
|
+
// ===== Sent-message fold =================================================
|
|
1170
|
+
// A block that folded in the composer comes back *after send* as fenced
|
|
1171
|
+
// text inside the user bubble (DSH renders user messages as plain
|
|
1172
|
+
// pre-wrap runs), so the wall of text this plugin exists to prevent
|
|
1173
|
+
// reappears in the transcript. scanFolds() (see apply) walks sent
|
|
1174
|
+
// bubbles and folds every complete fenced block into a collapsed card:
|
|
1175
|
+
// [</>] python · 128 行 [⎘] ▾ (header click toggles)
|
|
1176
|
+
// React owns the bubble's own children, so nothing is removed or
|
|
1177
|
+
// reordered: the plainRun span being folded is hidden (display:none) and
|
|
1178
|
+
// a host element we fully own is inserted directly after it. A content
|
|
1179
|
+
// stamp on the run keeps the scan idempotent and self-refreshing — text
|
|
1180
|
+
// or locale revision changes rebuild the host, and a fold that becomes
|
|
1181
|
+
// unnecessary is undone. The copy button copies the RAW fenced text
|
|
1182
|
+
// (exactly what was sent); the bubble's own copy/edit/delete bar is
|
|
1183
|
+
// React-side and never sees this DOM.
|
|
1184
|
+
const FOLD_HOST_CLASS = 'dsh-pcb-fold-host'
|
|
1185
|
+
const FOLD_CARD_CLASS = 'dsh-pcb-fold'
|
|
1186
|
+
const FOLD_RUN_ATTR = 'data-dsh-pcb-run' // '1|<stamp>' folded · '0|<stamp>' checked, plain
|
|
1187
|
+
const FOLD_STAMP_ATTR = 'data-dsh-pcb-stamp' // on the host, matches the run's stamp
|
|
1188
|
+
const FOLD_BUBBLE_SEL = [
|
|
1189
|
+
'[data-chat-flow-kind="user"]',
|
|
1190
|
+
'[data-chat-flow-kind="steering"]',
|
|
1191
|
+
'[data-submission-echo]',
|
|
1192
|
+
'[data-pending-steering]',
|
|
1193
|
+
].map((s) => `${s} [class*="bubble"]`).join(',')
|
|
1194
|
+
const foldOpenKeys = new Set() // user-toggled-open cards, stable across rebuilds
|
|
1195
|
+
let foldRev = 0 // bumped on locale switch so cards re-title
|
|
1196
|
+
|
|
1197
|
+
function hash32(str) {
|
|
1198
|
+
let h = 0x811c9dc5
|
|
1199
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
1200
|
+
h ^= str.charCodeAt(i)
|
|
1201
|
+
h = Math.imul(h, 0x01000193)
|
|
1202
|
+
}
|
|
1203
|
+
return (h >>> 0).toString(36)
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function foldStamp(text) {
|
|
1207
|
+
return `${foldRev}:${text.length}:${hash32(text)}`
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Cheap pre-filter so the per-frame scan stays trivial on unfenced runs.
|
|
1211
|
+
const FENCE_HINT = /(^|\n) {0,3}(```|~~~)/
|
|
1212
|
+
|
|
1213
|
+
function isFoldRun(el) {
|
|
1214
|
+
return el.tagName === 'SPAN' && (el.getAttribute('class') || '').includes('plainRun')
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** Segments when the text holds at least one non-empty complete fence, else null. */
|
|
1218
|
+
function foldPlanFor(text) {
|
|
1219
|
+
if (!FENCE_HINT.test(text)) return null
|
|
1220
|
+
const segments = splitFencedSegments(text)
|
|
1221
|
+
let folds = 0
|
|
1222
|
+
for (const seg of segments) if (seg.kind === 'fence' && seg.body.trim() !== '') folds += 1
|
|
1223
|
+
return folds > 0 ? segments : null
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function buildFoldCard(seg, openKey, t) {
|
|
1227
|
+
const infoLang = (seg.lang || '').toLowerCase()
|
|
1228
|
+
// Only an EXPLICIT ```text fence is a text block. The composer never
|
|
1229
|
+
// emits a bare fence for prose (plain blocks always serialize as
|
|
1230
|
+
// ```text), so a bare ``` fence is code whose language the composer
|
|
1231
|
+
// could not name — fold it as a code card and re-guess the language
|
|
1232
|
+
// for display. (0.2.0 bug: bare fences folded as 文本块, mismatching
|
|
1233
|
+
// the 代码块 chip the message was drafted from.)
|
|
1234
|
+
const isText = infoLang === 'text'
|
|
1235
|
+
const lang = isText ? '' : (infoLang || detectLang(seg.body) || '')
|
|
1236
|
+
const type = isText ? 'text' : 'code'
|
|
1237
|
+
const lines = seg.body.split('\n').length
|
|
1238
|
+
const open = foldOpenKeys.has(openKey)
|
|
1239
|
+
|
|
1240
|
+
const card = document.createElement('div')
|
|
1241
|
+
card.className = FOLD_CARD_CLASS
|
|
1242
|
+
card.setAttribute('data-pcb-type', type)
|
|
1243
|
+
card.setAttribute('data-open', open ? '1' : '0')
|
|
1244
|
+
|
|
1245
|
+
const head = document.createElement('div')
|
|
1246
|
+
head.className = 'dsh-pcb-fold-head'
|
|
1247
|
+
|
|
1248
|
+
const toggle = document.createElement('button')
|
|
1249
|
+
toggle.type = 'button'
|
|
1250
|
+
toggle.className = 'dsh-pcb-fold-toggle'
|
|
1251
|
+
toggle.setAttribute('aria-expanded', open ? 'true' : 'false')
|
|
1252
|
+
toggle.setAttribute('aria-label', t('fold.aria'))
|
|
1253
|
+
toggle.title = t(open ? 'collapse' : 'expand')
|
|
1254
|
+
const glyph = document.createElement('span')
|
|
1255
|
+
glyph.className = 'dsh-pcb-fold-glyph'
|
|
1256
|
+
glyph.setAttribute('aria-hidden', 'true')
|
|
1257
|
+
glyph.textContent = type === 'code' ? '</>' : 'Aa'
|
|
1258
|
+
const title = document.createElement('span')
|
|
1259
|
+
title.className = 'dsh-pcb-fold-title'
|
|
1260
|
+
title.textContent = foldTitle(t, type, lang, lines)
|
|
1261
|
+
const chevron = document.createElement('span')
|
|
1262
|
+
chevron.className = 'dsh-pcb-fold-chevron'
|
|
1263
|
+
chevron.setAttribute('aria-hidden', 'true')
|
|
1264
|
+
chevron.textContent = '▾'
|
|
1265
|
+
toggle.append(glyph, title, chevron)
|
|
1266
|
+
|
|
1267
|
+
const copy = document.createElement('button')
|
|
1268
|
+
copy.type = 'button'
|
|
1269
|
+
copy.className = 'dsh-pcb-btn dsh-pcb-fold-copy'
|
|
1270
|
+
copy.title = t('copy')
|
|
1271
|
+
copy.setAttribute('aria-label', t('copy'))
|
|
1272
|
+
copy.textContent = '⎘'
|
|
1273
|
+
let copyTimer = 0
|
|
1274
|
+
copy.addEventListener('click', (ev) => {
|
|
1275
|
+
ev.preventDefault()
|
|
1276
|
+
ev.stopPropagation()
|
|
1277
|
+
try {
|
|
1278
|
+
const done = navigator.clipboard?.writeText(seg.raw)
|
|
1279
|
+
if (done && typeof done.then === 'function') {
|
|
1280
|
+
done.then(() => {
|
|
1281
|
+
copy.textContent = '✓'
|
|
1282
|
+
clearTimeout(copyTimer)
|
|
1283
|
+
copyTimer = setTimeout(() => { copy.textContent = '⎘' }, 1200)
|
|
1284
|
+
}, () => {})
|
|
1285
|
+
}
|
|
1286
|
+
} catch (e) { /* noop */ }
|
|
1287
|
+
})
|
|
1288
|
+
|
|
1289
|
+
head.append(toggle, copy)
|
|
1290
|
+
const pre = document.createElement('pre')
|
|
1291
|
+
pre.className = 'dsh-pcb-fold-body'
|
|
1292
|
+
pre.textContent = seg.body
|
|
1293
|
+
card.append(head, pre)
|
|
1294
|
+
|
|
1295
|
+
toggle.addEventListener('click', (ev) => {
|
|
1296
|
+
ev.preventDefault()
|
|
1297
|
+
ev.stopPropagation()
|
|
1298
|
+
const nowOpen = card.getAttribute('data-open') !== '1'
|
|
1299
|
+
card.setAttribute('data-open', nowOpen ? '1' : '0')
|
|
1300
|
+
toggle.setAttribute('aria-expanded', nowOpen ? 'true' : 'false')
|
|
1301
|
+
toggle.title = t(nowOpen ? 'collapse' : 'expand')
|
|
1302
|
+
if (nowOpen) foldOpenKeys.add(openKey)
|
|
1303
|
+
else foldOpenKeys.delete(openKey)
|
|
1304
|
+
})
|
|
1305
|
+
return card
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function buildFoldHost(segments, keyBase, t) {
|
|
1309
|
+
const host = document.createElement('div')
|
|
1310
|
+
host.className = FOLD_HOST_CLASS
|
|
1311
|
+
segments.forEach((seg, i) => {
|
|
1312
|
+
if (seg.kind === 'fence' && seg.body.trim() !== '') {
|
|
1313
|
+
host.appendChild(buildFoldCard(seg, `${keyBase}:${i}`, t))
|
|
1314
|
+
} else {
|
|
1315
|
+
const prose = document.createElement('span')
|
|
1316
|
+
prose.className = 'dsh-pcb-fold-prose'
|
|
1317
|
+
// An un-foldable fence (empty body) keeps its raw text, fences and all.
|
|
1318
|
+
prose.textContent = seg.kind === 'fence' ? seg.raw : seg.text
|
|
1319
|
+
host.appendChild(prose)
|
|
1320
|
+
}
|
|
1321
|
+
})
|
|
1322
|
+
return host
|
|
1323
|
+
}
|
|
1324
|
+
|
|
830
1325
|
// ===== Styles ============================================================
|
|
831
1326
|
const CSS = `
|
|
832
1327
|
/* --- restyle DSH's inline reference chip into a boxed badge --- */
|
|
@@ -849,6 +1344,25 @@ window.__ModuleLoader__.load({
|
|
|
849
1344
|
/* the label text (the trailing ✕ element is ours — never ellipsis it) */
|
|
850
1345
|
[data-composer-chip="${SOURCE}"] > span > span:not(.${CHIP_X_CLASS}){font-weight:500!important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
851
1346
|
|
|
1347
|
+
/* --- type identity: leading glyph + tint (data-pcb-type set by scanChips) --- */
|
|
1348
|
+
[data-composer-chip="${SOURCE}"][data-pcb-type="code"] > span{
|
|
1349
|
+
border-color:var(--dsh-pcb-code-border,rgba(76,110,245,.38))!important;
|
|
1350
|
+
background:var(--dsh-pcb-code-bg,rgba(76,110,245,.09))!important;
|
|
1351
|
+
color:var(--dsw-alias-state-business-primary,#4c6ef5)!important
|
|
1352
|
+
}
|
|
1353
|
+
[data-composer-chip="${SOURCE}"][data-pcb-type="code"]:hover > span{
|
|
1354
|
+
border-color:var(--dsw-alias-state-business-primary,#4c6ef5)!important;
|
|
1355
|
+
background:var(--dsh-pcb-code-bg-hover,rgba(76,110,245,.16))!important
|
|
1356
|
+
}
|
|
1357
|
+
[data-composer-chip="${SOURCE}"][data-pcb-type="code"] > span::before{
|
|
1358
|
+
content:'</>';flex:none;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);
|
|
1359
|
+
font-size:10px;font-weight:700;line-height:16px;opacity:.9
|
|
1360
|
+
}
|
|
1361
|
+
[data-composer-chip="${SOURCE}"][data-pcb-type="text"] > span::before{
|
|
1362
|
+
content:'Aa';flex:none;font-size:10px;font-weight:700;line-height:16px;
|
|
1363
|
+
letter-spacing:.2px;color:var(--dsw-alias-label-tertiary)
|
|
1364
|
+
}
|
|
1365
|
+
|
|
852
1366
|
/* --- per-chip ✕ delete button (element we own, appended inside the chip) --- */
|
|
853
1367
|
.${CHIP_X_CLASS}{
|
|
854
1368
|
box-sizing:border-box;flex:none;width:16px;height:16px;display:inline-grid;place-items:center;
|
|
@@ -879,9 +1393,26 @@ window.__ModuleLoader__.load({
|
|
|
879
1393
|
/* --- hide the composer's own placeholder while chips/cards are active --- */
|
|
880
1394
|
[data-dsh-pcb-active="1"] [data-composer-placeholder]{visibility:hidden}
|
|
881
1395
|
|
|
1396
|
+
/* --- sent-message fold cards (conversation bubbles) --- */
|
|
1397
|
+
.dsh-pcb-fold-host{box-sizing:border-box;display:flex;flex-direction:column;gap:8px;width:100%;min-width:0}
|
|
1398
|
+
.dsh-pcb-fold-prose{white-space:pre-wrap;word-break:break-word}
|
|
1399
|
+
.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}
|
|
1400
|
+
.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}
|
|
1401
|
+
.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}
|
|
1402
|
+
.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}
|
|
1403
|
+
.dsh-pcb-fold[data-pcb-type="code"] .dsh-pcb-fold-glyph{color:var(--dsw-alias-state-business-primary,#4c6ef5)}
|
|
1404
|
+
.dsh-pcb-fold[data-pcb-type="text"] .dsh-pcb-fold-glyph{color:var(--dsw-alias-label-tertiary)}
|
|
1405
|
+
.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)}
|
|
1406
|
+
.dsh-pcb-fold[data-pcb-type="text"] .dsh-pcb-fold-title{font-family:var(--dsw-font-family)}
|
|
1407
|
+
.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}
|
|
1408
|
+
.dsh-pcb-fold[data-open="1"] .dsh-pcb-fold-chevron{transform:rotate(180deg)}
|
|
1409
|
+
.dsh-pcb-fold[data-open="0"] .dsh-pcb-fold-body{display:none}
|
|
1410
|
+
.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)}
|
|
1411
|
+
|
|
882
1412
|
@media (max-width:640px){
|
|
883
1413
|
.dsh-pcb-head{height:34px}
|
|
884
1414
|
.dsh-pcb-lang{max-width:55%}
|
|
1415
|
+
.dsh-pcb-fold-body{max-height:240px}
|
|
885
1416
|
}
|
|
886
1417
|
@media (pointer:coarse){
|
|
887
1418
|
.${CHIP_X_CLASS}{width:22px;height:22px;font-size:15px}
|
|
@@ -892,7 +1423,9 @@ window.__ModuleLoader__.load({
|
|
|
892
1423
|
const inject = ['slots', 'sessions', 'conversation', 'inputTriggers', 'locale']
|
|
893
1424
|
|
|
894
1425
|
function apply(ctx) {
|
|
895
|
-
|
|
1426
|
+
// Bump alongside package.json on every release — the load log is the
|
|
1427
|
+
// only proof of WHICH bundle generation the browser actually loaded.
|
|
1428
|
+
try { console.log('[dsh-paste-code-block] client loaded v0.2.5') } catch (e) { /* noop */ }
|
|
896
1429
|
|
|
897
1430
|
// Publish our dictionaries under the plugin namespace, then bind a
|
|
898
1431
|
// translator that always reflects the ACTIVE DSH language (Settings →
|
|
@@ -906,7 +1439,10 @@ window.__ModuleLoader__.load({
|
|
|
906
1439
|
: createT(detectBrowserLocale())
|
|
907
1440
|
|
|
908
1441
|
const controller = new BlockController(ctx, t)
|
|
1442
|
+
// Test seam (tests/fold-smoke.test.js drives recover() directly).
|
|
1443
|
+
exports.__controllerForTests = controller
|
|
909
1444
|
ctx.effect(() => () => controller.dispose(), 'paste-code-block: state')
|
|
1445
|
+
try { recoveryPruneExpired() } catch (e) { /* private mode: recovery simply off */ }
|
|
910
1446
|
|
|
911
1447
|
// ----- chip DOM sync: localized labels + per-chip ✕ delete button -----
|
|
912
1448
|
function scanChips() {
|
|
@@ -923,6 +1459,11 @@ window.__ModuleLoader__.load({
|
|
|
923
1459
|
if (!sid) continue
|
|
924
1460
|
const block = controller.listFor(sid).find((b) => b.type === parsed.type && b.labelNumber === parsed.n)
|
|
925
1461
|
if (!block) continue
|
|
1462
|
+
// Tag the chip host with its type so CSS can render the leading
|
|
1463
|
+
// glyph and tint (code = blue `</>`, text = neutral `Aa`).
|
|
1464
|
+
if (host.getAttribute('data-pcb-type') !== parsed.type) {
|
|
1465
|
+
host.setAttribute('data-pcb-type', parsed.type)
|
|
1466
|
+
}
|
|
926
1467
|
// Retitle the chip to the current language. Lexical caches the
|
|
927
1468
|
// insert-time label inside the node, so on a Settings → Language
|
|
928
1469
|
// switch (or a chip that remounted with the old label) the DOM is
|
|
@@ -952,10 +1493,73 @@ window.__ModuleLoader__.load({
|
|
|
952
1493
|
}
|
|
953
1494
|
}
|
|
954
1495
|
|
|
1496
|
+
// ----- sent-message fold: the scan ------------------------------------
|
|
1497
|
+
function foldRun(run, plan, keyBase, stamp) {
|
|
1498
|
+
run.setAttribute(FOLD_RUN_ATTR, `1|${stamp}`)
|
|
1499
|
+
run.style.display = 'none'
|
|
1500
|
+
const next = run.nextElementSibling
|
|
1501
|
+
if (next && next.classList.contains(FOLD_HOST_CLASS)) {
|
|
1502
|
+
if (next.getAttribute(FOLD_STAMP_ATTR) === stamp) return // already current
|
|
1503
|
+
next.remove()
|
|
1504
|
+
}
|
|
1505
|
+
const host = buildFoldHost(plan, keyBase, t)
|
|
1506
|
+
host.setAttribute(FOLD_STAMP_ATTR, stamp)
|
|
1507
|
+
run.insertAdjacentElement('afterend', host)
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/** Restore a previously folded run to DSH's own rendering. */
|
|
1511
|
+
function unfoldRun(run, stamp) {
|
|
1512
|
+
const next = run.nextElementSibling
|
|
1513
|
+
if (next && next.classList.contains(FOLD_HOST_CLASS)) next.remove()
|
|
1514
|
+
run.style.display = ''
|
|
1515
|
+
run.setAttribute(FOLD_RUN_ATTR, `0|${stamp}`)
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function scanFolds() {
|
|
1519
|
+
if (typeof document === 'undefined') return
|
|
1520
|
+
let bubbles
|
|
1521
|
+
try { bubbles = document.querySelectorAll(FOLD_BUBBLE_SEL) } catch (e) { return }
|
|
1522
|
+
for (const bubble of bubbles) {
|
|
1523
|
+
if (!bubble.isConnected) continue
|
|
1524
|
+
const holder = bubble.closest('[data-chat-flow-key]')
|
|
1525
|
+
const flowKey = holder ? holder.getAttribute('data-chat-flow-key') || '' : ''
|
|
1526
|
+
const kids = [...bubble.children]
|
|
1527
|
+
for (let i = 0; i < kids.length; i += 1) {
|
|
1528
|
+
const el = kids[i]
|
|
1529
|
+
if (el.classList.contains(FOLD_HOST_CLASS)) continue
|
|
1530
|
+
if (!isFoldRun(el)) continue
|
|
1531
|
+
const text = el.textContent || ''
|
|
1532
|
+
const stamp = foldStamp(text)
|
|
1533
|
+
const marked = el.getAttribute(FOLD_RUN_ATTR) || ''
|
|
1534
|
+
if (marked === `1|${stamp}` || marked === `0|${stamp}`) continue // current
|
|
1535
|
+
const plan = foldPlanFor(text)
|
|
1536
|
+
if (!plan) {
|
|
1537
|
+
if (marked.startsWith('1|')) unfoldRun(el, stamp)
|
|
1538
|
+
else el.setAttribute(FOLD_RUN_ATTR, `0|${stamp}`)
|
|
1539
|
+
continue
|
|
1540
|
+
}
|
|
1541
|
+
// Echo/pending rows have no flow key — fall back to a content hash so
|
|
1542
|
+
// two simultaneous unkeyed rows never share one open-state slot.
|
|
1543
|
+
const keyBase = `${flowKey || 'h' + hash32(text)}#${i}`
|
|
1544
|
+
foldRun(el, plan, keyBase, stamp)
|
|
1545
|
+
}
|
|
1546
|
+
// Drop orphan hosts (e.g. React remounted the run span underneath us).
|
|
1547
|
+
for (const el of [...bubble.children]) {
|
|
1548
|
+
if (!el.classList.contains(FOLD_HOST_CLASS)) continue
|
|
1549
|
+
const prev = el.previousElementSibling
|
|
1550
|
+
const mark = prev && prev.getAttribute ? prev.getAttribute(FOLD_RUN_ATTR) : null
|
|
1551
|
+
const keep = !!mark && mark.startsWith('1|') &&
|
|
1552
|
+
prev.style.display === 'none' &&
|
|
1553
|
+
el.getAttribute(FOLD_STAMP_ATTR) === mark.slice(2)
|
|
1554
|
+
if (!keep) el.remove()
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
955
1559
|
let scanScheduled = 0
|
|
956
1560
|
function scheduleScan() {
|
|
957
1561
|
if (scanScheduled) return
|
|
958
|
-
const run = () => { scanScheduled = 0; scanChips() }
|
|
1562
|
+
const run = () => { scanScheduled = 0; scanChips(); scanFolds() }
|
|
959
1563
|
scanScheduled = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : setTimeout(run, 16)
|
|
960
1564
|
}
|
|
961
1565
|
|
|
@@ -967,13 +1571,28 @@ window.__ModuleLoader__.load({
|
|
|
967
1571
|
}, 'paste-code-block: chip sync observer')
|
|
968
1572
|
|
|
969
1573
|
// A locale switch (or a late dictionary registration bumps the revision
|
|
970
|
-
// too) re-derives labels: controller state first, chip DOM second.
|
|
1574
|
+
// too) re-derives labels: controller state first, chip DOM second. Sent
|
|
1575
|
+
// fold cards are rebuilt the same way — bumping foldRev invalidates
|
|
1576
|
+
// every run stamp, so the next scan re-titles the cards in the new
|
|
1577
|
+
// language while foldOpenKeys keeps who was expanded.
|
|
971
1578
|
ctx.effect(() => {
|
|
972
1579
|
const face = ctx.locale
|
|
973
1580
|
if (!face || typeof face.subscribe !== 'function') return undefined
|
|
974
|
-
return face.subscribe(() => { controller.retitleAll(); scheduleScan() })
|
|
1581
|
+
return face.subscribe(() => { controller.retitleAll(); foldRev += 1; scheduleScan() })
|
|
975
1582
|
}, 'paste-code-block: locale sync')
|
|
976
|
-
scheduleScan() // late-mount safety for chips rendered before plugin boot
|
|
1583
|
+
scheduleScan() // late-mount safety for chips and sent bubbles rendered before plugin boot
|
|
1584
|
+
|
|
1585
|
+
// On dispose (plugin removed / HMR swap) hand the transcript back to DSH.
|
|
1586
|
+
ctx.effect(() => () => {
|
|
1587
|
+
try {
|
|
1588
|
+
for (const host of document.querySelectorAll(`.${FOLD_HOST_CLASS}`)) host.remove()
|
|
1589
|
+
for (const run of document.querySelectorAll(`[${FOLD_RUN_ATTR}]`)) {
|
|
1590
|
+
run.style.display = ''
|
|
1591
|
+
run.removeAttribute(FOLD_RUN_ATTR)
|
|
1592
|
+
}
|
|
1593
|
+
} catch (e) { /* noop */ }
|
|
1594
|
+
foldOpenKeys.clear()
|
|
1595
|
+
}, 'paste-code-block: fold restore')
|
|
977
1596
|
|
|
978
1597
|
ctx.effect(() => ctx.inputTriggers.registerSource({
|
|
979
1598
|
trigger: '@',
|
|
@@ -995,6 +1614,34 @@ window.__ModuleLoader__.load({
|
|
|
995
1614
|
return () => style.remove()
|
|
996
1615
|
}, 'paste-code-block: styles')
|
|
997
1616
|
|
|
1617
|
+
// Paste-storm guard. External automation — a Tampermonkey AutoClicker,
|
|
1618
|
+
// a clipboard bridge, IME glue, or a held-down Ctrl+V — can re-dispatch
|
|
1619
|
+
// the SAME clipboard text dozens of times a second. Each accepted paste
|
|
1620
|
+
// mints a new block, so a storm stuffs the composer with duplicate
|
|
1621
|
+
// chips. Collapse runs of an identical clipboard text that arrive faster
|
|
1622
|
+
// than a human can act into a single paste (a 1200 ms sliding window,
|
|
1623
|
+
// refreshed on every matching event, so a held repeat stays suppressed
|
|
1624
|
+
// while it continues). 0.2.4 added a content-liveness dedup that ALSO
|
|
1625
|
+
// suppressed a re-paste of the same text while its block was still
|
|
1626
|
+
// attached — that made "paste the same snippet twice" impossible, which
|
|
1627
|
+
// users hit immediately. 0.2.5 reverts that: identical text pasted after
|
|
1628
|
+
// the burst window is a fresh intent and gets its own block. Only
|
|
1629
|
+
// machine-paced repeats within the window are collapsed.
|
|
1630
|
+
const STORM_WINDOW_MS = 1200
|
|
1631
|
+
const pasteStorm = {
|
|
1632
|
+
lastText: '',
|
|
1633
|
+
lastAt: 0,
|
|
1634
|
+
// True when this text is the tail of a storm; every call refreshes
|
|
1635
|
+
// the window, so a held repeat stays suppressed while it continues.
|
|
1636
|
+
seen(text, now) {
|
|
1637
|
+
const dup = text === this.lastText && now - this.lastAt < STORM_WINDOW_MS
|
|
1638
|
+
this.lastText = text
|
|
1639
|
+
this.lastAt = now
|
|
1640
|
+
return dup
|
|
1641
|
+
},
|
|
1642
|
+
}
|
|
1643
|
+
exports.__pasteStormForTests = pasteStorm
|
|
1644
|
+
|
|
998
1645
|
// Turn qualifying pastes into boxed inline chips.
|
|
999
1646
|
const onPaste = (ev) => {
|
|
1000
1647
|
const clip = ev.clipboardData
|
|
@@ -1007,7 +1654,19 @@ window.__ModuleLoader__.load({
|
|
|
1007
1654
|
if (!sid) return
|
|
1008
1655
|
const block = parseBlock(text)
|
|
1009
1656
|
if (!block) { console.log('[dsh-paste-code-block] paste NOT block', text.length); return }
|
|
1010
|
-
|
|
1657
|
+
if (pasteStorm.seen(text, Date.now())) {
|
|
1658
|
+
// Consume the event (never let the raw text fall through to the
|
|
1659
|
+
// composer) but create nothing.
|
|
1660
|
+
ev.preventDefault()
|
|
1661
|
+
ev.stopPropagation()
|
|
1662
|
+
console.log('[dsh-paste-code-block] storm paste ignored (<' + STORM_WINDOW_MS + 'ms repeat)')
|
|
1663
|
+
return
|
|
1664
|
+
}
|
|
1665
|
+
block.sourceText = text
|
|
1666
|
+
// trusted=false means a page script dispatched this paste (userscript,
|
|
1667
|
+
// clipboard bridge, replay glue) rather than a real keystroke — the
|
|
1668
|
+
// load-log line that names the source when a storm report comes in.
|
|
1669
|
+
console.log('[dsh-paste-code-block] intercept', block.isCode ? 'code' : 'text', block.lines.length, 'lang=', block.lang, 'trusted=', ev.isTrusted)
|
|
1011
1670
|
ev.preventDefault()
|
|
1012
1671
|
ev.stopPropagation()
|
|
1013
1672
|
try {
|