dsh-paste-code-block 0.1.1 → 0.1.3

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/src/client.js CHANGED
@@ -15,8 +15,168 @@ window.__ModuleLoader__.load({
15
15
  const MARK = 'data-dsh-pcb'
16
16
  const MARK_SESSION = 'data-dsh-pcb-session'
17
17
  const DETAIL_NS = 'dsh-pcb-detail'
18
+ const CHIP_X_CLASS = 'dsh-pcb-chip-x'
19
+
20
+ // ----- shared chip-DOM helpers ------------------------------------------
21
+ // Module level on purpose: the chip sync scan and the controller's removal
22
+ // path both need to map a chip host element back to its label (and its
23
+ // session), and the ✕ is appended INSIDE the chip's inner span.
24
+ function chipInner(host) { return host.querySelector(':scope > span') }
25
+ function chipLabelText(host) {
26
+ const inner = chipInner(host)
27
+ if (!inner) return ''
28
+ const title = inner.getAttribute('title')
29
+ if (title) return title
30
+ const spans = [...inner.querySelectorAll(':scope > span')].filter((s) => !s.classList.contains(CHIP_X_CLASS))
31
+ const labelEl = spans[spans.length - 1]
32
+ return (labelEl && labelEl.textContent) || inner.textContent || ''
33
+ }
34
+ // Resolve the active session for an element inside a composer seat.
35
+ function sessionFor(target) {
36
+ const seat = target.closest(`[${MARK}]`)
37
+ let sid = seat && seat.getAttribute(MARK_SESSION)
38
+ if (!sid && target.parentElement) {
39
+ let el = target.parentElement
40
+ while (el) {
41
+ const rail = el.querySelector && el.querySelector('[data-dsh-pcb-rail]')
42
+ if (rail) { sid = rail.getAttribute('data-dsh-pcb-session') || sid; break }
43
+ el = el.parentElement
44
+ }
45
+ }
46
+ return sid
47
+ }
18
48
 
19
- // ===== Block parsing =====================================================
49
+ // ----- chip node-map helpers (embedded copy of src/chip-nodes.js) --------
50
+ // DSH renders each pasted block as a Lexical decorator chip and the input
51
+ // shell exposes no per-reference delete verb, so removal walks the editor's
52
+ // node map. Its VALUES ARE THE NODES THEMSELVES (Lexical >= 0.21): reading
53
+ // `value.node` matches nothing and silently deletes nothing (v0.1.2 bug).
54
+ function chipNodeOf(value) {
55
+ if (!value) return null
56
+ if (typeof value.getSource === 'function') return value
57
+ const wrapped = value.node
58
+ return wrapped && typeof wrapped.getSource === 'function' ? wrapped : null
59
+ }
60
+ function collectChipNodes(values, source, ref) {
61
+ const hits = []
62
+ if (!values) return hits
63
+ for (const value of values) {
64
+ const node = chipNodeOf(value)
65
+ if (!node || typeof node.getReference !== 'function') continue
66
+ if (node.getSource() === source && node.getReference() === ref) hits.push(node)
67
+ }
68
+ return hits
69
+ }
70
+
71
+ // ----- projection geometry (embedded copy of src/projection.js) ----------
72
+ // The shell publishes clipboard coordinates (`draft`, chips expanded to their
73
+ // clipboardText) while the scoped edit verbs address detect coordinates
74
+ // (each chip collapses to ONE placeholder char). Convert before editing.
75
+ function detectOffsetOf(occurrences, occ) {
76
+ let shift = 0
77
+ for (const other of occurrences || []) {
78
+ if (other === occ || other.offset >= occ.offset) continue
79
+ shift += Math.max(0, (other.length || 1) - 1)
80
+ }
81
+ return occ.offset - shift
82
+ }
83
+ // Detect span of the separator space the shell appends after a fresh chip.
84
+ function separatorSpaceSpan(projection, occ) {
85
+ if (!projection || !occ) return null
86
+ const draft = projection.draft || ''
87
+ const afterChip = occ.offset + (occ.length || 0)
88
+ if (draft.charAt(afterChip) !== ' ') return null
89
+ const start = detectOffsetOf(projection.occurrences, occ) + 1
90
+ return { start, end: start + 1 }
91
+ }
92
+
93
+ // ===== Locale (embedded copy of src/i18n.js — keep the two in sync) =====
94
+ const NS = 'paste-code-block'
95
+ const L10N = {
96
+ zh: {
97
+ 'block.code': '代码块 {n}',
98
+ 'block.text': '文本块 {n}',
99
+ 'detail.aria': '代码块详情',
100
+ 'slot.label': '粘贴代码块详情',
101
+ 'lines.one': '{count} 行',
102
+ 'lines.other': '{count} 行',
103
+ 'expand': '展开',
104
+ 'collapse': '折叠',
105
+ 'copy': '复制',
106
+ 'copied': '已复制',
107
+ 'hide': '收起',
108
+ 'remove': '移除',
109
+ 'edit.aria': '编辑代码块内容',
110
+ 'error.stale': '代码块已失效,请重新粘贴',
111
+ 'error.remove': '无法删除该块,请重试',
112
+ },
113
+ en: {
114
+ 'block.code': 'Code #{n}',
115
+ 'block.text': 'Text #{n}',
116
+ 'detail.aria': 'Block details',
117
+ 'slot.label': 'Pasted block details',
118
+ 'lines.one': '{count} line',
119
+ 'lines.other': '{count} lines',
120
+ 'expand': 'Expand',
121
+ 'collapse': 'Collapse',
122
+ 'copy': 'Copy',
123
+ 'copied': 'Copied',
124
+ 'hide': 'Hide',
125
+ 'remove': 'Remove',
126
+ 'edit.aria': 'Edit block content',
127
+ 'error.stale': 'This block is no longer valid — paste it again',
128
+ 'error.remove': 'Could not remove the block — please retry',
129
+ },
130
+ }
131
+
132
+ function createT(locale) {
133
+ const dict = L10N[locale] || L10N.en
134
+ return (key, params) => {
135
+ let text = dict[key] != null ? dict[key] : (L10N.en[key] != null ? L10N.en[key] : key)
136
+ if (params) text = text.replace(/\{(\w+)\}/g, (m, name) => (name in params ? String(params[name]) : m))
137
+ return text
138
+ }
139
+ }
140
+
141
+ function detectBrowserLocale() {
142
+ try {
143
+ const langs = [...(navigator.languages || []), navigator.language].filter(Boolean).map((l) => String(l).toLowerCase())
144
+ return langs.some((l) => l.startsWith('zh')) ? 'zh' : 'en'
145
+ } catch (e) {
146
+ return 'en'
147
+ }
148
+ }
149
+
150
+ // Pre-0.1.3 chip labels: no longer rendered, still recognized (see below).
151
+ const LEGACY_L10N = {
152
+ zh: { 'block.code': '复制代码块{n}', 'block.text': '复制文本块{n}' },
153
+ en: { 'block.code': 'Code block #{n}', 'block.text': 'Text block #{n}' },
154
+ }
155
+
156
+ /**
157
+ * Recognize a rendered block label in ANY shipped locale (or a pre-0.1.3
158
+ * legacy locale) and recover its `{ type, n }` identity — this keeps chip
159
+ * clicks, ✕ deletes, and the locale-switch retitling correct even for
160
+ * chips inserted under a different language than the one currently
161
+ * active. Null otherwise.
162
+ */
163
+ function parseBlockLabel(text) {
164
+ if (!text) return null
165
+ for (const dict of [L10N.zh, L10N.en, LEGACY_L10N.zh, LEGACY_L10N.en]) {
166
+ for (const key of ['block.code', 'block.text']) {
167
+ const pattern = new RegExp(
168
+ '^' +
169
+ dict[key].replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\{n\\\}/g, '(\\d+)') +
170
+ '$',
171
+ )
172
+ const m = pattern.exec(text)
173
+ if (m) return { type: key === 'block.code' ? 'code' : 'text', n: Number(m[1]) }
174
+ }
175
+ }
176
+ return null
177
+ }
178
+
179
+ // ===== Block parsing (embedded copy of src/parse.js — keep in sync) =====
20
180
  const SHEBANG_LANG = {
21
181
  python: 'python', python3: 'python', node: 'javascript', nodejs: 'javascript',
22
182
  ruby: 'ruby', bash: 'bash', sh: 'bash', zsh: 'bash', dash: 'bash',
@@ -47,8 +207,9 @@ window.__ModuleLoader__.load({
47
207
 
48
208
  /**
49
209
  * Parse pasted text into a block, or null when it is ordinary short prose
50
- * that should paste plainly. `isCode` drives both the chip wording
51
- * ("复制代码块N" vs "复制文本块N") and the fenced language on send.
210
+ * that should paste plainly. `isCode` drives both the chip label
211
+ * (i18n key `block.code` vs `block.text`, e.g. "代码块 N" / "Code #N")
212
+ * and the fenced language on send.
52
213
  */
53
214
  function parseBlock(raw) {
54
215
  const text = String(raw || '').replace(/^\uFEFF/, '')
@@ -110,13 +271,20 @@ window.__ModuleLoader__.load({
110
271
 
111
272
  // ===== Draft controller ==================================================
112
273
  class BlockController {
113
- constructor(ctx) {
274
+ /**
275
+ * @param ctx - cordis client context (sessions/conversation services).
276
+ * @param t - locale-bound translator, `(key, params) => string`, that
277
+ * reflects the ACTIVE DSH language at call time (from
278
+ * `ctx.locale.bind(NS)`), or the browser-guessed fallback.
279
+ */
280
+ constructor(ctx, t) {
114
281
  this.ctx = ctx
282
+ this.t = t
115
283
  this.list = new Map() // sessionId -> Block[]
116
284
  this.selected = new Map() // sessionId -> ref
117
285
  this.used = new Map() // sessionId -> {code:Set, text:Set}
118
286
  this.refIndex = new Map() // ref -> {sessionId, block}
119
- this.labelIndex = new Map()// label -> ref
287
+ this.labelIndex = new Map()// label (insert-time language) -> ref
120
288
  this.inFlight = new Map()
121
289
  this.expiry = new Map()
122
290
  this.serializing = new Set()
@@ -148,8 +316,9 @@ window.__ModuleLoader__.load({
148
316
  if (used) used[type]?.delete(n)
149
317
  }
150
318
 
319
+ /** Current-language label for a block number, e.g. 复制代码块2 / Code block #2. */
151
320
  labelFor(block, n) {
152
- return block.isCode ? `复制代码块${n}` : `复制文本块${n}`
321
+ return this.t(block.isCode ? 'block.code' : 'block.text', { n })
153
322
  }
154
323
 
155
324
  listFor(sessionId) { return this.list.get(String(sessionId)) || [] }
@@ -169,6 +338,31 @@ window.__ModuleLoader__.load({
169
338
  for (const fn of s) fn()
170
339
  }
171
340
 
341
+ /**
342
+ * Recompute every tracked block's label under the CURRENT language
343
+ * (called when the locale snapshot changes). Keeps `labelIndex`
344
+ * consistent and re-publishes the affected sessions so the detail card
345
+ * re-renders; the chip DOM is synced by the client's `scanChips` pass.
346
+ * @returns the array of `{ sessionId, block }` whose label changed.
347
+ */
348
+ retitleAll() {
349
+ const changed = []
350
+ const affected = new Set()
351
+ for (const [k, entries] of this.list) {
352
+ for (const block of entries) {
353
+ const want = this.labelFor(block, block.labelNumber)
354
+ if (want === block.label) continue
355
+ this.labelIndex.delete(block.label)
356
+ block.label = want
357
+ this.labelIndex.set(want, block.id)
358
+ changed.push({ sessionId: k, block })
359
+ affected.add(k)
360
+ }
361
+ }
362
+ for (const k of affected) this.publish(k)
363
+ return changed
364
+ }
365
+
172
366
  scope(sessionId) {
173
367
  const actx = this.ctx.sessions.scope(sessionId)
174
368
  if (!actx) throw new Error('paste-code-block: session not ready')
@@ -187,7 +381,7 @@ window.__ModuleLoader__.load({
187
381
  // only a block is pasted with no typed text). Hence draft.length already
188
382
  // equals the detect length; anchoring there keeps blocks strictly ordered.
189
383
  const end = input.draft ? input.draft.length : 0
190
- return actx.bail(actx, 'slash/input-insert-reference', {
384
+ const inserted = actx.bail(actx, 'slash/input-insert-reference', {
191
385
  reference: {
192
386
  source: SOURCE,
193
387
  ref: block.id,
@@ -196,6 +390,40 @@ window.__ModuleLoader__.load({
196
390
  },
197
391
  span: { start: end, end, draftRev: input.draftRev },
198
392
  }) === true
393
+ if (!inserted) return false
394
+ // The shell appends a separating space after the chip; creating a block
395
+ // must not prefix the following text (or pile spaces into the draft).
396
+ this.dropSeparatorSpace(actx, shell, block)
397
+ return true
398
+ }
399
+
400
+ /**
401
+ * Remove the separating space the shell appended after OUR freshly inserted
402
+ * chip, and nothing else.
403
+ *
404
+ * `SessionInputShell.insertReference` inserts `[chip, ' ']` whenever the
405
+ * character at the pick-time span is not itself a space. Blocks are always
406
+ * appended at the end of the draft, so the character now sitting directly
407
+ * after our chip can only have come from that insertion: deleting it can
408
+ * never disturb a space the draft already had (a pre-existing trailing
409
+ * space still sits where the user left it, before the chip).
410
+ *
411
+ * @param actx - session scope (scoped edit events).
412
+ * @param shell - the session's input shell.
413
+ * @param block - the block whose chip was just inserted.
414
+ * @returns whether the space was removed.
415
+ */
416
+ dropSeparatorSpace(actx, shell, block) {
417
+ const after = shell.snapshot
418
+ const occ = after && (after.occurrences || []).find((o) => o.source === SOURCE && o.ref === block.id)
419
+ const span = separatorSpaceSpan(after, occ)
420
+ if (!span) return false
421
+ const removed = actx.bail(actx, 'slash/input-insert-text', {
422
+ text: '',
423
+ span: { start: span.start, end: span.end, draftRev: after.draftRev },
424
+ }) === true
425
+ if (!removed) console.warn('[paste-code-block] could not drop the separator space')
426
+ return removed
199
427
  }
200
428
 
201
429
  attach(sessionId, block) {
@@ -220,9 +448,21 @@ window.__ModuleLoader__.load({
220
448
  return true
221
449
  }
222
450
 
223
- blockForLabel(label) {
451
+ /**
452
+ * Resolve a chip's rendered text (label in ANY locale — see
453
+ * `parseBlockLabel`) back to its block. Prefers the insert-time label
454
+ * index, then falls back to the (type, number) identity within the
455
+ * chip's own session, so clicks keep working after a language switch.
456
+ */
457
+ blockForLabel(sessionId, label) {
224
458
  const ref = this.labelIndex.get(label)
225
- return ref ? this.refIndex.get(ref)?.block : undefined
459
+ if (ref) {
460
+ const record = this.refIndex.get(ref)
461
+ if (record) return record.block
462
+ }
463
+ const parsed = parseBlockLabel(label)
464
+ if (!parsed) return undefined
465
+ return this.listFor(sessionId).find((b) => b.type === parsed.type && b.labelNumber === parsed.n)
226
466
  }
227
467
 
228
468
  select(sessionId, ref) {
@@ -233,19 +473,97 @@ window.__ModuleLoader__.load({
233
473
  this.publish(sessionId)
234
474
  }
235
475
 
476
+ /** Whether the editor's own node map still holds a chip for this ref. */
477
+ hasChipNode(shell, ref) {
478
+ const editor = shell && shell.editor
479
+ if (!editor || typeof editor.getEditorState !== 'function') return false
480
+ const state = editor.getEditorState()
481
+ if (!state || typeof state.read !== 'function') return false
482
+ let found = false
483
+ state.read(() => {
484
+ const map = state._nodeMap
485
+ if (map && typeof map.values === 'function') found = collectChipNodes(map.values(), SOURCE, ref).length > 0
486
+ })
487
+ return found
488
+ }
489
+
490
+ /** Whether the editor still projects an occurrence (chip) for this ref. */
491
+ hasChip(shell, ref) {
492
+ const input = shell && shell.snapshot
493
+ if (!input) return false
494
+ return (input.occurrences || []).some((o) => o.source === SOURCE && o.ref === ref)
495
+ }
496
+
497
+ /** Whether a chip for this ref is still present (editor first, then projection). */
498
+ chipPresent(shell, ref) {
499
+ return this.hasChipNode(shell, ref) || this.hasChip(shell, ref)
500
+ }
501
+
502
+ /**
503
+ * Remove the chip node(s) of one ref from the editor's own document.
504
+ *
505
+ * NEVER splice the draft via `setDraft()` to delete a chip: setDraft clears
506
+ * the root and re-creates plain-text paragraphs, and chips are Lexical
507
+ * decorator nodes that cannot round-trip through text — so one "spliced
508
+ * out" chip meant EVERY block vanished (0.1.1 bug).
509
+ *
510
+ * The editor exposes no per-reference delete verb, so we walk its node map
511
+ * and drop the ReferenceChipNode(s) carrying this source + ref — the same
512
+ * `node.remove()` the editor itself performs when a chip is deleted by hand.
513
+ *
514
+ * @param shell - the session's input shell (exposes `.editor`).
515
+ * @param ref - the block id to remove.
516
+ * @returns number of nodes removed, or -1 when the editor was unreachable.
517
+ */
518
+ removeChipNodes(shell, ref) {
519
+ const editor = shell && shell.editor
520
+ if (!editor || typeof editor.update !== 'function') return -1
521
+ let removed = 0
522
+ try {
523
+ editor.update(() => {
524
+ const state = editor.getEditorState()
525
+ const map = state ? state._nodeMap : null
526
+ if (!map || typeof map.values !== 'function') return
527
+ const hits = collectChipNodes(map.values(), SOURCE, ref)
528
+ for (const node of hits) node.remove()
529
+ removed = hits.length
530
+ }, { discrete: true })
531
+ } catch (err) {
532
+ console.warn('[paste-code-block] chip node removal failed:', err)
533
+ return -1
534
+ }
535
+ return removed
536
+ }
537
+
538
+ /**
539
+ * Delete one block: drop its chip from the composer, then its state.
540
+ *
541
+ * The chip removal is VERIFIED against the editor's own node map (and, as a
542
+ * cross-check, the published occurrence projection): if the chip is still
543
+ * there we keep the block and surface an error rather than silently doing
544
+ * nothing (or, worse, pruning state behind a chip that is still there).
545
+ *
546
+ * @param sessionId - owning session.
547
+ * @param ref - block id.
548
+ */
236
549
  remove(sessionId, ref) {
237
550
  const k = String(sessionId)
238
551
  const entries = this.listFor(k)
239
552
  const entry = entries.find((b) => b.id === ref)
240
553
  if (!entry) return
241
- if (this.selected.get(k) === ref) this.selected.delete(k)
242
554
  const { shell } = this.scope(sessionId)
243
- const input = shell.snapshot
244
- const occurrence = (input.occurrences || []).find((o) => o.source === SOURCE && o.ref === ref)
245
- if (occurrence) {
246
- shell.setDraft(input.draft.slice(0, occurrence.offset) + input.draft.slice(occurrence.offset + 1))
555
+ if (this.chipPresent(shell, ref)) {
556
+ const removed = this.removeChipNodes(shell, ref)
557
+ if (this.chipPresent(shell, ref)) {
558
+ console.warn('[paste-code-block] could not remove chip', { ref, removed })
559
+ shell.notify('error', this.t('error.remove'))
560
+ return
561
+ }
247
562
  }
248
- const next = entries.filter((b) => b.id !== ref)
563
+ if (this.selected.get(k) === ref) this.selected.delete(k)
564
+ const stillListed = this.listFor(k)
565
+ if (!stillListed.includes(entry)) return
566
+ const next = stillListed.filter((b) => b.id !== ref)
249
567
  if (next.length > 0) this.list.set(k, next)
250
568
  else this.list.delete(k)
251
569
  this.refIndex.delete(ref)
@@ -281,15 +599,16 @@ window.__ModuleLoader__.load({
281
599
 
282
600
  async serialize(ref) {
283
601
  const record = this.refIndex.get(ref)
284
- if (!record) throw new Error('代码块已失效,请重新粘贴')
602
+ if (!record) throw new Error(this.t('error.stale'))
285
603
  this.markSerializing(ref)
286
604
  return serializeBlock(record.block)
287
605
  }
288
606
 
289
607
  /**
290
608
  * Keep only blocks whose inline chip is still present in the draft (the
291
- * user may delete a chip with Backspace). During a submit the chips may
292
- * transiently vanish, so prune is deferred there.
609
+ * user may delete a chip with Backspace, or with the chip's ✕ button).
610
+ * During a submit the chips may transiently vanish, so prune is deferred
611
+ * there.
293
612
  */
294
613
  reconcile(sessionId, occurrences, phase) {
295
614
  const k = String(sessionId)
@@ -400,6 +719,10 @@ window.__ModuleLoader__.load({
400
719
  const blocks = useBlocks(props.controller, props.sessionId)
401
720
  const selected = useSelectedBlock(props.controller, props.sessionId)
402
721
  const railRef = React.useRef(null)
722
+ // `props.t` is the slot's locale seat (a fresh reference on every locale
723
+ // revision, and the outlet re-renders with it); the controller's
724
+ // translator is the defensive fallback for non-locale compositions.
725
+ const t = props.t || props.controller.t
403
726
 
404
727
  useComposerMark(railRef, props.sessionId, input.draftRev, blocks.length > 0)
405
728
 
@@ -413,6 +736,10 @@ window.__ModuleLoader__.load({
413
736
  }
414
737
  }, [props.controller, props.sessionId, promptError])
415
738
 
739
+ // Re-sync the chip DOM (localized titles, ✕ buttons) after every dock
740
+ // render — covers attach, removal, selection, and locale switches.
741
+ React.useEffect(() => { props.syncChips() })
742
+
416
743
  const empty = !selected
417
744
  return React.createElement(
418
745
  'div',
@@ -421,20 +748,21 @@ window.__ModuleLoader__.load({
421
748
  className: `dsh-pcb-dock${empty ? ' dsh-pcb-dock--empty' : ''}`,
422
749
  'data-dsh-pcb-rail': '',
423
750
  'data-dsh-pcb-session': String(props.sessionId),
424
- 'aria-label': '代码块详情',
751
+ 'aria-label': t('detail.aria'),
425
752
  },
426
753
  selected
427
754
  ? React.createElement(BlockDetail, {
428
755
  block: selected,
429
756
  controller: props.controller,
430
757
  sessionId: props.sessionId,
758
+ t,
431
759
  })
432
760
  : null,
433
761
  )
434
762
  }
435
763
 
436
764
  function BlockDetail(props) {
437
- const { block, controller, sessionId } = props
765
+ const { block, controller, sessionId, t } = props
438
766
  const [collapsed, setCollapsed] = React.useState(false)
439
767
  const [copied, setCopied] = React.useState(false)
440
768
  // Editable draft: lets the user edit the block's content in the detail
@@ -463,6 +791,7 @@ window.__ModuleLoader__.load({
463
791
  controller.select(sessionId, block.id) // toggles off (same id)
464
792
  }
465
793
 
794
+ const lineCount = block.lines.length
466
795
  return React.createElement(
467
796
  'div',
468
797
  { className: `dsh-pcb-detail${collapsed ? ' dsh-pcb-detail--collapsed' : ''}` },
@@ -470,25 +799,25 @@ window.__ModuleLoader__.load({
470
799
  'div',
471
800
  { className: 'dsh-pcb-head' },
472
801
  React.createElement('span', { className: 'dsh-pcb-lang' }, block.lang || 'text'),
473
- React.createElement('span', { className: 'dsh-pcb-lines' }, `${block.lines.length} 行`),
802
+ React.createElement('span', { className: 'dsh-pcb-lines' }, t(lineCount === 1 ? 'lines.one' : 'lines.other', { count: lineCount })),
474
803
  React.createElement('span', { className: 'dsh-pcb-spacer' }),
475
804
  collapsed ? React.createElement('button', {
476
- type: 'button', className: 'dsh-pcb-btn', title: '展开', 'aria-label': '展开',
805
+ type: 'button', className: 'dsh-pcb-btn', title: t('expand'), 'aria-label': t('expand'),
477
806
  onClick: () => setCollapsed(false),
478
807
  }, '▾') : React.createElement('button', {
479
- type: 'button', className: 'dsh-pcb-btn', title: '折叠', 'aria-label': '折叠',
808
+ type: 'button', className: 'dsh-pcb-btn', title: t('collapse'), 'aria-label': t('collapse'),
480
809
  onClick: () => setCollapsed(true),
481
810
  }, '▸'),
482
811
  React.createElement('button', {
483
- type: 'button', className: 'dsh-pcb-btn', title: '复制', 'aria-label': '复制',
812
+ type: 'button', className: 'dsh-pcb-btn', title: copied ? t('copied') : t('copy'), 'aria-label': t('copy'),
484
813
  onClick: copy,
485
814
  }, copied ? '✓' : '⎘'),
486
815
  React.createElement('button', {
487
- type: 'button', className: 'dsh-pcb-btn', title: '收起', 'aria-label': '收起',
816
+ type: 'button', className: 'dsh-pcb-btn', title: t('hide'), 'aria-label': t('hide'),
488
817
  onClick: hide,
489
818
  }, '⟨'),
490
819
  React.createElement('button', {
491
- type: 'button', className: 'dsh-pcb-btn dsh-pcb-btn--danger', title: '移除', 'aria-label': '移除',
820
+ type: 'button', className: 'dsh-pcb-btn dsh-pcb-btn--danger', title: t('remove'), 'aria-label': t('remove'),
492
821
  onClick: () => controller.remove(sessionId, block.id),
493
822
  }, '×'),
494
823
  ),
@@ -500,7 +829,7 @@ window.__ModuleLoader__.load({
500
829
  onChange: onEdit,
501
830
  spellCheck: false,
502
831
  rows: Math.min(Math.max(block.lines.length, 2), 12),
503
- 'aria-label': '编辑代码块内容',
832
+ 'aria-label': t('edit.aria'),
504
833
  }),
505
834
  )
506
835
  }
@@ -524,8 +853,38 @@ window.__ModuleLoader__.load({
524
853
  }
525
854
  /* hide the "@" marker that DSH renders when the chip has no appearance glyph */
526
855
  [data-composer-chip="${SOURCE}"] > span > [aria-hidden="true"]{display:none!important}
527
- /* the label text */
528
- [data-composer-chip="${SOURCE}"] > span > span:last-child{font-weight:500!important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
856
+ /* the label text (the trailing ✕ element is ours — never ellipsis it) */
857
+ [data-composer-chip="${SOURCE}"] > span > span:not(.${CHIP_X_CLASS}){font-weight:500!important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
858
+
859
+ /* --- type identity: leading glyph + tint (data-pcb-type set by scanChips) --- */
860
+ [data-composer-chip="${SOURCE}"][data-pcb-type="code"] > span{
861
+ border-color:var(--dsh-pcb-code-border,rgba(76,110,245,.38))!important;
862
+ background:var(--dsh-pcb-code-bg,rgba(76,110,245,.09))!important;
863
+ color:var(--dsw-alias-state-business-primary,#4c6ef5)!important
864
+ }
865
+ [data-composer-chip="${SOURCE}"][data-pcb-type="code"]:hover > span{
866
+ border-color:var(--dsw-alias-state-business-primary,#4c6ef5)!important;
867
+ background:var(--dsh-pcb-code-bg-hover,rgba(76,110,245,.16))!important
868
+ }
869
+ [data-composer-chip="${SOURCE}"][data-pcb-type="code"] > span::before{
870
+ content:'</>';flex:none;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);
871
+ font-size:10px;font-weight:700;line-height:16px;opacity:.9
872
+ }
873
+ [data-composer-chip="${SOURCE}"][data-pcb-type="text"] > span::before{
874
+ content:'Aa';flex:none;font-size:10px;font-weight:700;line-height:16px;
875
+ letter-spacing:.2px;color:var(--dsw-alias-label-tertiary)
876
+ }
877
+
878
+ /* --- per-chip ✕ delete button (element we own, appended inside the chip) --- */
879
+ .${CHIP_X_CLASS}{
880
+ box-sizing:border-box;flex:none;width:16px;height:16px;display:inline-grid;place-items:center;
881
+ margin-left:2px;border-radius:5px;font-size:13px;line-height:1;font-weight:600;
882
+ color:var(--dsw-alias-label-tertiary);cursor:pointer;user-select:none
883
+ }
884
+ .${CHIP_X_CLASS}:hover{
885
+ background:var(--dsw-alias-interactive-bg-hover-danger,rgba(224,49,49,.12));
886
+ color:var(--dsw-alias-state-error-primary,#e03131)
887
+ }
529
888
 
530
889
  /* --- dock / detail card above the input --- */
531
890
  .dsh-pcb-dock{box-sizing:border-box;width:100%;max-width:var(--dsh-composer-card-max-width);margin:0 auto 6px;padding:0 var(--dsh-composer-side-clearance,16px);display:flex;flex-direction:column;gap:8px}
@@ -550,16 +909,103 @@ window.__ModuleLoader__.load({
550
909
  .dsh-pcb-head{height:34px}
551
910
  .dsh-pcb-lang{max-width:55%}
552
911
  }
912
+ @media (pointer:coarse){
913
+ .${CHIP_X_CLASS}{width:22px;height:22px;font-size:15px}
914
+ }
553
915
  `
554
916
 
555
917
  // ===== Client plugin body ===============================================
556
- const inject = ['slots', 'sessions', 'conversation', 'inputTriggers']
918
+ const inject = ['slots', 'sessions', 'conversation', 'inputTriggers', 'locale']
557
919
 
558
920
  function apply(ctx) {
559
921
  try { console.log('[dsh-paste-code-block] client loaded') } catch (e) { /* noop */ }
560
- const controller = new BlockController(ctx)
922
+
923
+ // Publish our dictionaries under the plugin namespace, then bind a
924
+ // translator that always reflects the ACTIVE DSH language (Settings →
925
+ // Language, or its browser fallback). Outside a composition that carries
926
+ // the locale plugin we degrade to a browser-language guess.
927
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
928
+ ctx.effect(() => ctx.locale.register(NS, L10N), 'paste-code-block: dictionaries')
929
+ }
930
+ const t = (ctx.locale && typeof ctx.locale.bind === 'function')
931
+ ? ctx.locale.bind(NS)
932
+ : createT(detectBrowserLocale())
933
+
934
+ const controller = new BlockController(ctx, t)
561
935
  ctx.effect(() => () => controller.dispose(), 'paste-code-block: state')
562
936
 
937
+ // ----- chip DOM sync: localized labels + per-chip ✕ delete button -----
938
+ function scanChips() {
939
+ if (typeof document === 'undefined') return
940
+ if (controller.list.size === 0) return
941
+ const hosts = document.querySelectorAll(`[data-composer-chip="${SOURCE}"]`)
942
+ for (const host of hosts) {
943
+ const inner = chipInner(host)
944
+ if (!inner) continue
945
+ const parsed = parseBlockLabel(inner.getAttribute('title') || '') || parseBlockLabel(chipLabelText(host))
946
+ if (!parsed) continue
947
+ const seat = host.closest(`[${MARK_SESSION}]`)
948
+ const sid = seat && seat.getAttribute(MARK_SESSION)
949
+ if (!sid) continue
950
+ const block = controller.listFor(sid).find((b) => b.type === parsed.type && b.labelNumber === parsed.n)
951
+ if (!block) continue
952
+ // Tag the chip host with its type so CSS can render the leading
953
+ // glyph and tint (code = blue `</>`, text = neutral `Aa`).
954
+ if (host.getAttribute('data-pcb-type') !== parsed.type) {
955
+ host.setAttribute('data-pcb-type', parsed.type)
956
+ }
957
+ // Retitle the chip to the current language. Lexical caches the
958
+ // insert-time label inside the node, so on a Settings → Language
959
+ // switch (or a chip that remounted with the old label) the DOM is
960
+ // the sync surface; React's own diff never touches these nodes
961
+ // again while the node state is unchanged.
962
+ if (inner.getAttribute('title') !== block.label) {
963
+ inner.setAttribute('title', block.label)
964
+ const spans = [...inner.querySelectorAll(':scope > span')].filter((s) => !s.classList.contains(CHIP_X_CLASS))
965
+ const labelEl = spans[spans.length - 1]
966
+ if (labelEl && labelEl.textContent !== block.label) labelEl.textContent = block.label
967
+ }
968
+ // Ensure the ✕ delete affordance exists on this chip.
969
+ let x = inner.querySelector(`:scope > .${CHIP_X_CLASS}`)
970
+ if (!x) {
971
+ x = document.createElement('span')
972
+ x.className = CHIP_X_CLASS
973
+ x.textContent = '×'
974
+ x.setAttribute('role', 'button')
975
+ x.setAttribute('tabindex', '-1')
976
+ inner.appendChild(x)
977
+ }
978
+ const tip = t('remove')
979
+ if (x.getAttribute('aria-label') !== tip) {
980
+ x.setAttribute('aria-label', tip)
981
+ x.setAttribute('title', tip)
982
+ }
983
+ }
984
+ }
985
+
986
+ let scanScheduled = 0
987
+ function scheduleScan() {
988
+ if (scanScheduled) return
989
+ const run = () => { scanScheduled = 0; scanChips() }
990
+ scanScheduled = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : setTimeout(run, 16)
991
+ }
992
+
993
+ ctx.effect(() => {
994
+ if (typeof MutationObserver === 'undefined' || !document.body) return undefined
995
+ const mo = new MutationObserver(scheduleScan)
996
+ mo.observe(document.body, { childList: true, subtree: true })
997
+ return () => mo.disconnect()
998
+ }, 'paste-code-block: chip sync observer')
999
+
1000
+ // A locale switch (or a late dictionary registration bumps the revision
1001
+ // too) re-derives labels: controller state first, chip DOM second.
1002
+ ctx.effect(() => {
1003
+ const face = ctx.locale
1004
+ if (!face || typeof face.subscribe !== 'function') return undefined
1005
+ return face.subscribe(() => { controller.retitleAll(); scheduleScan() })
1006
+ }, 'paste-code-block: locale sync')
1007
+ scheduleScan() // late-mount safety for chips rendered before plugin boot
1008
+
563
1009
  ctx.effect(() => ctx.inputTriggers.registerSource({
564
1010
  trigger: '@',
565
1011
  name: SOURCE,
@@ -580,21 +1026,6 @@ window.__ModuleLoader__.load({
580
1026
  return () => style.remove()
581
1027
  }, 'paste-code-block: styles')
582
1028
 
583
- // Resolve the active session for an event inside the composer seat.
584
- function sessionFor(target) {
585
- const seat = target.closest(`[${MARK}]`)
586
- let sid = seat && seat.getAttribute(MARK_SESSION)
587
- if (!sid && target.parentElement) {
588
- let el = target.parentElement
589
- while (el) {
590
- const rail = el.querySelector && el.querySelector('[data-dsh-pcb-rail]')
591
- if (rail) { sid = rail.getAttribute('data-dsh-pcb-session') || sid; break }
592
- el = el.parentElement
593
- }
594
- }
595
- return sid
596
- }
597
-
598
1029
  // Turn qualifying pastes into boxed inline chips.
599
1030
  const onPaste = (ev) => {
600
1031
  const clip = ev.clipboardData
@@ -635,18 +1066,32 @@ window.__ModuleLoader__.load({
635
1066
  return () => document.removeEventListener('paste', onPaste, true)
636
1067
  }, 'paste-code-block: paste capture')
637
1068
 
638
- // Clicking an inline chip toggles its detail card.
1069
+ // Chip interactions: ✕ deletes the block, a body click toggles its card;
1070
+ // any other click dismisses the open card.
639
1071
  const onChipPointer = (ev) => {
640
1072
  const target = ev.target
641
1073
  if (!(target instanceof Element)) return
642
- // Clicking a chip toggles its detail card.
1074
+ // ✕ on a chip: remove that block outright.
1075
+ const chipX = target.closest(`.${CHIP_X_CLASS}`)
1076
+ if (chipX) {
1077
+ const xHost = chipX.closest(`[data-composer-chip="${SOURCE}"]`)
1078
+ const xSid = xHost && sessionFor(xHost)
1079
+ const label = xHost ? chipLabelText(xHost) : ''
1080
+ const block = xSid && label ? controller.blockForLabel(xSid, label) : null
1081
+ if (block) {
1082
+ ev.preventDefault()
1083
+ ev.stopPropagation()
1084
+ controller.remove(xSid, block.id)
1085
+ }
1086
+ return
1087
+ }
1088
+ // Clicking a chip body toggles its detail card.
643
1089
  const host = target.closest(`[data-composer-chip="${SOURCE}"]`)
644
1090
  if (host) {
645
- const labelEl = host.querySelector(':scope > span[title]') || host.querySelector('span[title]')
646
- const label = labelEl && labelEl.getAttribute('title')
1091
+ const label = chipLabelText(host)
647
1092
  const sid = sessionFor(target)
648
1093
  if (!sid || !label) return
649
- const block = controller.blockForLabel(label)
1094
+ const block = controller.blockForLabel(sid, label)
650
1095
  if (!block) return
651
1096
  ev.preventDefault()
652
1097
  ev.stopPropagation()
@@ -670,8 +1115,11 @@ window.__ModuleLoader__.load({
670
1115
  name: 'conversation.input.dock',
671
1116
  id: DETAIL_NS,
672
1117
  order: 90,
673
- label: '粘贴代码块详情',
674
- }, (props) => React.createElement(BlockDock, { ...props, controller })))
1118
+ label: t('slot.label'),
1119
+ // Declaring the locale namespace gives the dock a fresh `t` seat prop
1120
+ // and re-renders it automatically on every Settings → Language switch.
1121
+ locale: NS,
1122
+ }, (props) => React.createElement(BlockDock, { ...props, controller, syncChips: scheduleScan })))
675
1123
  }
676
1124
 
677
1125
  exports.apply = apply