dsh-paste-code-block 0.1.1 → 0.1.2

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,161 @@ 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 block #{n}',
115
+ 'block.text': 'Text block #{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
+ /**
151
+ * Recognize a rendered block label in ANY shipped locale and recover its
152
+ * `{ type, n }` identity — this keeps chip clicks, ✕ deletes, and the
153
+ * locale-switch retitling correct even for chips inserted under a
154
+ * different language than the one currently active. Null otherwise.
155
+ */
156
+ function parseBlockLabel(text) {
157
+ if (!text) return null
158
+ for (const dict of [L10N.zh, L10N.en]) {
159
+ for (const key of ['block.code', 'block.text']) {
160
+ const pattern = new RegExp(
161
+ '^' +
162
+ dict[key].replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\{n\\\}/g, '(\\d+)') +
163
+ '$',
164
+ )
165
+ const m = pattern.exec(text)
166
+ if (m) return { type: key === 'block.code' ? 'code' : 'text', n: Number(m[1]) }
167
+ }
168
+ }
169
+ return null
170
+ }
171
+
172
+ // ===== Block parsing (embedded copy of src/parse.js — keep in sync) =====
20
173
  const SHEBANG_LANG = {
21
174
  python: 'python', python3: 'python', node: 'javascript', nodejs: 'javascript',
22
175
  ruby: 'ruby', bash: 'bash', sh: 'bash', zsh: 'bash', dash: 'bash',
@@ -47,8 +200,9 @@ window.__ModuleLoader__.load({
47
200
 
48
201
  /**
49
202
  * 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.
203
+ * that should paste plainly. `isCode` drives both the chip label
204
+ * (i18n key `block.code` vs `block.text`, e.g. "复制代码块N" / "Code block
205
+ * #N") and the fenced language on send.
52
206
  */
53
207
  function parseBlock(raw) {
54
208
  const text = String(raw || '').replace(/^\uFEFF/, '')
@@ -110,13 +264,20 @@ window.__ModuleLoader__.load({
110
264
 
111
265
  // ===== Draft controller ==================================================
112
266
  class BlockController {
113
- constructor(ctx) {
267
+ /**
268
+ * @param ctx - cordis client context (sessions/conversation services).
269
+ * @param t - locale-bound translator, `(key, params) => string`, that
270
+ * reflects the ACTIVE DSH language at call time (from
271
+ * `ctx.locale.bind(NS)`), or the browser-guessed fallback.
272
+ */
273
+ constructor(ctx, t) {
114
274
  this.ctx = ctx
275
+ this.t = t
115
276
  this.list = new Map() // sessionId -> Block[]
116
277
  this.selected = new Map() // sessionId -> ref
117
278
  this.used = new Map() // sessionId -> {code:Set, text:Set}
118
279
  this.refIndex = new Map() // ref -> {sessionId, block}
119
- this.labelIndex = new Map()// label -> ref
280
+ this.labelIndex = new Map()// label (insert-time language) -> ref
120
281
  this.inFlight = new Map()
121
282
  this.expiry = new Map()
122
283
  this.serializing = new Set()
@@ -148,8 +309,9 @@ window.__ModuleLoader__.load({
148
309
  if (used) used[type]?.delete(n)
149
310
  }
150
311
 
312
+ /** Current-language label for a block number, e.g. 复制代码块2 / Code block #2. */
151
313
  labelFor(block, n) {
152
- return block.isCode ? `复制代码块${n}` : `复制文本块${n}`
314
+ return this.t(block.isCode ? 'block.code' : 'block.text', { n })
153
315
  }
154
316
 
155
317
  listFor(sessionId) { return this.list.get(String(sessionId)) || [] }
@@ -169,6 +331,31 @@ window.__ModuleLoader__.load({
169
331
  for (const fn of s) fn()
170
332
  }
171
333
 
334
+ /**
335
+ * Recompute every tracked block's label under the CURRENT language
336
+ * (called when the locale snapshot changes). Keeps `labelIndex`
337
+ * consistent and re-publishes the affected sessions so the detail card
338
+ * re-renders; the chip DOM is synced by the client's `scanChips` pass.
339
+ * @returns the array of `{ sessionId, block }` whose label changed.
340
+ */
341
+ retitleAll() {
342
+ const changed = []
343
+ const affected = new Set()
344
+ for (const [k, entries] of this.list) {
345
+ for (const block of entries) {
346
+ const want = this.labelFor(block, block.labelNumber)
347
+ if (want === block.label) continue
348
+ this.labelIndex.delete(block.label)
349
+ block.label = want
350
+ this.labelIndex.set(want, block.id)
351
+ changed.push({ sessionId: k, block })
352
+ affected.add(k)
353
+ }
354
+ }
355
+ for (const k of affected) this.publish(k)
356
+ return changed
357
+ }
358
+
172
359
  scope(sessionId) {
173
360
  const actx = this.ctx.sessions.scope(sessionId)
174
361
  if (!actx) throw new Error('paste-code-block: session not ready')
@@ -187,7 +374,7 @@ window.__ModuleLoader__.load({
187
374
  // only a block is pasted with no typed text). Hence draft.length already
188
375
  // equals the detect length; anchoring there keeps blocks strictly ordered.
189
376
  const end = input.draft ? input.draft.length : 0
190
- return actx.bail(actx, 'slash/input-insert-reference', {
377
+ const inserted = actx.bail(actx, 'slash/input-insert-reference', {
191
378
  reference: {
192
379
  source: SOURCE,
193
380
  ref: block.id,
@@ -196,6 +383,40 @@ window.__ModuleLoader__.load({
196
383
  },
197
384
  span: { start: end, end, draftRev: input.draftRev },
198
385
  }) === true
386
+ if (!inserted) return false
387
+ // The shell appends a separating space after the chip; creating a block
388
+ // must not prefix the following text (or pile spaces into the draft).
389
+ this.dropSeparatorSpace(actx, shell, block)
390
+ return true
391
+ }
392
+
393
+ /**
394
+ * Remove the separating space the shell appended after OUR freshly inserted
395
+ * chip, and nothing else.
396
+ *
397
+ * `SessionInputShell.insertReference` inserts `[chip, ' ']` whenever the
398
+ * character at the pick-time span is not itself a space. Blocks are always
399
+ * appended at the end of the draft, so the character now sitting directly
400
+ * after our chip can only have come from that insertion: deleting it can
401
+ * never disturb a space the draft already had (a pre-existing trailing
402
+ * space still sits where the user left it, before the chip).
403
+ *
404
+ * @param actx - session scope (scoped edit events).
405
+ * @param shell - the session's input shell.
406
+ * @param block - the block whose chip was just inserted.
407
+ * @returns whether the space was removed.
408
+ */
409
+ dropSeparatorSpace(actx, shell, block) {
410
+ const after = shell.snapshot
411
+ const occ = after && (after.occurrences || []).find((o) => o.source === SOURCE && o.ref === block.id)
412
+ const span = separatorSpaceSpan(after, occ)
413
+ if (!span) return false
414
+ const removed = actx.bail(actx, 'slash/input-insert-text', {
415
+ text: '',
416
+ span: { start: span.start, end: span.end, draftRev: after.draftRev },
417
+ }) === true
418
+ if (!removed) console.warn('[paste-code-block] could not drop the separator space')
419
+ return removed
199
420
  }
200
421
 
201
422
  attach(sessionId, block) {
@@ -220,9 +441,21 @@ window.__ModuleLoader__.load({
220
441
  return true
221
442
  }
222
443
 
223
- blockForLabel(label) {
444
+ /**
445
+ * Resolve a chip's rendered text (label in ANY locale — see
446
+ * `parseBlockLabel`) back to its block. Prefers the insert-time label
447
+ * index, then falls back to the (type, number) identity within the
448
+ * chip's own session, so clicks keep working after a language switch.
449
+ */
450
+ blockForLabel(sessionId, label) {
224
451
  const ref = this.labelIndex.get(label)
225
- return ref ? this.refIndex.get(ref)?.block : undefined
452
+ if (ref) {
453
+ const record = this.refIndex.get(ref)
454
+ if (record) return record.block
455
+ }
456
+ const parsed = parseBlockLabel(label)
457
+ if (!parsed) return undefined
458
+ return this.listFor(sessionId).find((b) => b.type === parsed.type && b.labelNumber === parsed.n)
226
459
  }
227
460
 
228
461
  select(sessionId, ref) {
@@ -233,19 +466,97 @@ window.__ModuleLoader__.load({
233
466
  this.publish(sessionId)
234
467
  }
235
468
 
469
+ /** Whether the editor's own node map still holds a chip for this ref. */
470
+ hasChipNode(shell, ref) {
471
+ const editor = shell && shell.editor
472
+ if (!editor || typeof editor.getEditorState !== 'function') return false
473
+ const state = editor.getEditorState()
474
+ if (!state || typeof state.read !== 'function') return false
475
+ let found = false
476
+ state.read(() => {
477
+ const map = state._nodeMap
478
+ if (map && typeof map.values === 'function') found = collectChipNodes(map.values(), SOURCE, ref).length > 0
479
+ })
480
+ return found
481
+ }
482
+
483
+ /** Whether the editor still projects an occurrence (chip) for this ref. */
484
+ hasChip(shell, ref) {
485
+ const input = shell && shell.snapshot
486
+ if (!input) return false
487
+ return (input.occurrences || []).some((o) => o.source === SOURCE && o.ref === ref)
488
+ }
489
+
490
+ /** Whether a chip for this ref is still present (editor first, then projection). */
491
+ chipPresent(shell, ref) {
492
+ return this.hasChipNode(shell, ref) || this.hasChip(shell, ref)
493
+ }
494
+
495
+ /**
496
+ * Remove the chip node(s) of one ref from the editor's own document.
497
+ *
498
+ * NEVER splice the draft via `setDraft()` to delete a chip: setDraft clears
499
+ * the root and re-creates plain-text paragraphs, and chips are Lexical
500
+ * decorator nodes that cannot round-trip through text — so one "spliced
501
+ * out" chip meant EVERY block vanished (0.1.1 bug).
502
+ *
503
+ * The editor exposes no per-reference delete verb, so we walk its node map
504
+ * and drop the ReferenceChipNode(s) carrying this source + ref — the same
505
+ * `node.remove()` the editor itself performs when a chip is deleted by hand.
506
+ *
507
+ * @param shell - the session's input shell (exposes `.editor`).
508
+ * @param ref - the block id to remove.
509
+ * @returns number of nodes removed, or -1 when the editor was unreachable.
510
+ */
511
+ removeChipNodes(shell, ref) {
512
+ const editor = shell && shell.editor
513
+ if (!editor || typeof editor.update !== 'function') return -1
514
+ let removed = 0
515
+ try {
516
+ editor.update(() => {
517
+ const state = editor.getEditorState()
518
+ const map = state ? state._nodeMap : null
519
+ if (!map || typeof map.values !== 'function') return
520
+ const hits = collectChipNodes(map.values(), SOURCE, ref)
521
+ for (const node of hits) node.remove()
522
+ removed = hits.length
523
+ }, { discrete: true })
524
+ } catch (err) {
525
+ console.warn('[paste-code-block] chip node removal failed:', err)
526
+ return -1
527
+ }
528
+ return removed
529
+ }
530
+
531
+ /**
532
+ * Delete one block: drop its chip from the composer, then its state.
533
+ *
534
+ * The chip removal is VERIFIED against the editor's own node map (and, as a
535
+ * cross-check, the published occurrence projection): if the chip is still
536
+ * there we keep the block and surface an error rather than silently doing
537
+ * nothing (or, worse, pruning state behind a chip that is still there).
538
+ *
539
+ * @param sessionId - owning session.
540
+ * @param ref - block id.
541
+ */
236
542
  remove(sessionId, ref) {
237
543
  const k = String(sessionId)
238
544
  const entries = this.listFor(k)
239
545
  const entry = entries.find((b) => b.id === ref)
240
546
  if (!entry) return
241
- if (this.selected.get(k) === ref) this.selected.delete(k)
242
547
  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))
548
+ if (this.chipPresent(shell, ref)) {
549
+ const removed = this.removeChipNodes(shell, ref)
550
+ if (this.chipPresent(shell, ref)) {
551
+ console.warn('[paste-code-block] could not remove chip', { ref, removed })
552
+ shell.notify('error', this.t('error.remove'))
553
+ return
554
+ }
247
555
  }
248
- const next = entries.filter((b) => b.id !== ref)
556
+ if (this.selected.get(k) === ref) this.selected.delete(k)
557
+ const stillListed = this.listFor(k)
558
+ if (!stillListed.includes(entry)) return
559
+ const next = stillListed.filter((b) => b.id !== ref)
249
560
  if (next.length > 0) this.list.set(k, next)
250
561
  else this.list.delete(k)
251
562
  this.refIndex.delete(ref)
@@ -281,15 +592,16 @@ window.__ModuleLoader__.load({
281
592
 
282
593
  async serialize(ref) {
283
594
  const record = this.refIndex.get(ref)
284
- if (!record) throw new Error('代码块已失效,请重新粘贴')
595
+ if (!record) throw new Error(this.t('error.stale'))
285
596
  this.markSerializing(ref)
286
597
  return serializeBlock(record.block)
287
598
  }
288
599
 
289
600
  /**
290
601
  * 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.
602
+ * user may delete a chip with Backspace, or with the chip's ✕ button).
603
+ * During a submit the chips may transiently vanish, so prune is deferred
604
+ * there.
293
605
  */
294
606
  reconcile(sessionId, occurrences, phase) {
295
607
  const k = String(sessionId)
@@ -400,6 +712,10 @@ window.__ModuleLoader__.load({
400
712
  const blocks = useBlocks(props.controller, props.sessionId)
401
713
  const selected = useSelectedBlock(props.controller, props.sessionId)
402
714
  const railRef = React.useRef(null)
715
+ // `props.t` is the slot's locale seat (a fresh reference on every locale
716
+ // revision, and the outlet re-renders with it); the controller's
717
+ // translator is the defensive fallback for non-locale compositions.
718
+ const t = props.t || props.controller.t
403
719
 
404
720
  useComposerMark(railRef, props.sessionId, input.draftRev, blocks.length > 0)
405
721
 
@@ -413,6 +729,10 @@ window.__ModuleLoader__.load({
413
729
  }
414
730
  }, [props.controller, props.sessionId, promptError])
415
731
 
732
+ // Re-sync the chip DOM (localized titles, ✕ buttons) after every dock
733
+ // render — covers attach, removal, selection, and locale switches.
734
+ React.useEffect(() => { props.syncChips() })
735
+
416
736
  const empty = !selected
417
737
  return React.createElement(
418
738
  'div',
@@ -421,20 +741,21 @@ window.__ModuleLoader__.load({
421
741
  className: `dsh-pcb-dock${empty ? ' dsh-pcb-dock--empty' : ''}`,
422
742
  'data-dsh-pcb-rail': '',
423
743
  'data-dsh-pcb-session': String(props.sessionId),
424
- 'aria-label': '代码块详情',
744
+ 'aria-label': t('detail.aria'),
425
745
  },
426
746
  selected
427
747
  ? React.createElement(BlockDetail, {
428
748
  block: selected,
429
749
  controller: props.controller,
430
750
  sessionId: props.sessionId,
751
+ t,
431
752
  })
432
753
  : null,
433
754
  )
434
755
  }
435
756
 
436
757
  function BlockDetail(props) {
437
- const { block, controller, sessionId } = props
758
+ const { block, controller, sessionId, t } = props
438
759
  const [collapsed, setCollapsed] = React.useState(false)
439
760
  const [copied, setCopied] = React.useState(false)
440
761
  // Editable draft: lets the user edit the block's content in the detail
@@ -463,6 +784,7 @@ window.__ModuleLoader__.load({
463
784
  controller.select(sessionId, block.id) // toggles off (same id)
464
785
  }
465
786
 
787
+ const lineCount = block.lines.length
466
788
  return React.createElement(
467
789
  'div',
468
790
  { className: `dsh-pcb-detail${collapsed ? ' dsh-pcb-detail--collapsed' : ''}` },
@@ -470,25 +792,25 @@ window.__ModuleLoader__.load({
470
792
  'div',
471
793
  { className: 'dsh-pcb-head' },
472
794
  React.createElement('span', { className: 'dsh-pcb-lang' }, block.lang || 'text'),
473
- React.createElement('span', { className: 'dsh-pcb-lines' }, `${block.lines.length} 行`),
795
+ React.createElement('span', { className: 'dsh-pcb-lines' }, t(lineCount === 1 ? 'lines.one' : 'lines.other', { count: lineCount })),
474
796
  React.createElement('span', { className: 'dsh-pcb-spacer' }),
475
797
  collapsed ? React.createElement('button', {
476
- type: 'button', className: 'dsh-pcb-btn', title: '展开', 'aria-label': '展开',
798
+ type: 'button', className: 'dsh-pcb-btn', title: t('expand'), 'aria-label': t('expand'),
477
799
  onClick: () => setCollapsed(false),
478
800
  }, '▾') : React.createElement('button', {
479
- type: 'button', className: 'dsh-pcb-btn', title: '折叠', 'aria-label': '折叠',
801
+ type: 'button', className: 'dsh-pcb-btn', title: t('collapse'), 'aria-label': t('collapse'),
480
802
  onClick: () => setCollapsed(true),
481
803
  }, '▸'),
482
804
  React.createElement('button', {
483
- type: 'button', className: 'dsh-pcb-btn', title: '复制', 'aria-label': '复制',
805
+ type: 'button', className: 'dsh-pcb-btn', title: copied ? t('copied') : t('copy'), 'aria-label': t('copy'),
484
806
  onClick: copy,
485
807
  }, copied ? '✓' : '⎘'),
486
808
  React.createElement('button', {
487
- type: 'button', className: 'dsh-pcb-btn', title: '收起', 'aria-label': '收起',
809
+ type: 'button', className: 'dsh-pcb-btn', title: t('hide'), 'aria-label': t('hide'),
488
810
  onClick: hide,
489
811
  }, '⟨'),
490
812
  React.createElement('button', {
491
- type: 'button', className: 'dsh-pcb-btn dsh-pcb-btn--danger', title: '移除', 'aria-label': '移除',
813
+ type: 'button', className: 'dsh-pcb-btn dsh-pcb-btn--danger', title: t('remove'), 'aria-label': t('remove'),
492
814
  onClick: () => controller.remove(sessionId, block.id),
493
815
  }, '×'),
494
816
  ),
@@ -500,7 +822,7 @@ window.__ModuleLoader__.load({
500
822
  onChange: onEdit,
501
823
  spellCheck: false,
502
824
  rows: Math.min(Math.max(block.lines.length, 2), 12),
503
- 'aria-label': '编辑代码块内容',
825
+ 'aria-label': t('edit.aria'),
504
826
  }),
505
827
  )
506
828
  }
@@ -524,8 +846,19 @@ window.__ModuleLoader__.load({
524
846
  }
525
847
  /* hide the "@" marker that DSH renders when the chip has no appearance glyph */
526
848
  [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}
849
+ /* the label text (the trailing ✕ element is ours — never ellipsis it) */
850
+ [data-composer-chip="${SOURCE}"] > span > span:not(.${CHIP_X_CLASS}){font-weight:500!important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
851
+
852
+ /* --- per-chip ✕ delete button (element we own, appended inside the chip) --- */
853
+ .${CHIP_X_CLASS}{
854
+ box-sizing:border-box;flex:none;width:16px;height:16px;display:inline-grid;place-items:center;
855
+ margin-left:2px;border-radius:5px;font-size:13px;line-height:1;font-weight:600;
856
+ color:var(--dsw-alias-label-tertiary);cursor:pointer;user-select:none
857
+ }
858
+ .${CHIP_X_CLASS}:hover{
859
+ background:var(--dsw-alias-interactive-bg-hover-danger,rgba(224,49,49,.12));
860
+ color:var(--dsw-alias-state-error-primary,#e03131)
861
+ }
529
862
 
530
863
  /* --- dock / detail card above the input --- */
531
864
  .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 +883,98 @@ window.__ModuleLoader__.load({
550
883
  .dsh-pcb-head{height:34px}
551
884
  .dsh-pcb-lang{max-width:55%}
552
885
  }
886
+ @media (pointer:coarse){
887
+ .${CHIP_X_CLASS}{width:22px;height:22px;font-size:15px}
888
+ }
553
889
  `
554
890
 
555
891
  // ===== Client plugin body ===============================================
556
- const inject = ['slots', 'sessions', 'conversation', 'inputTriggers']
892
+ const inject = ['slots', 'sessions', 'conversation', 'inputTriggers', 'locale']
557
893
 
558
894
  function apply(ctx) {
559
895
  try { console.log('[dsh-paste-code-block] client loaded') } catch (e) { /* noop */ }
560
- const controller = new BlockController(ctx)
896
+
897
+ // Publish our dictionaries under the plugin namespace, then bind a
898
+ // translator that always reflects the ACTIVE DSH language (Settings →
899
+ // Language, or its browser fallback). Outside a composition that carries
900
+ // the locale plugin we degrade to a browser-language guess.
901
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
902
+ ctx.effect(() => ctx.locale.register(NS, L10N), 'paste-code-block: dictionaries')
903
+ }
904
+ const t = (ctx.locale && typeof ctx.locale.bind === 'function')
905
+ ? ctx.locale.bind(NS)
906
+ : createT(detectBrowserLocale())
907
+
908
+ const controller = new BlockController(ctx, t)
561
909
  ctx.effect(() => () => controller.dispose(), 'paste-code-block: state')
562
910
 
911
+ // ----- chip DOM sync: localized labels + per-chip ✕ delete button -----
912
+ function scanChips() {
913
+ if (typeof document === 'undefined') return
914
+ if (controller.list.size === 0) return
915
+ const hosts = document.querySelectorAll(`[data-composer-chip="${SOURCE}"]`)
916
+ for (const host of hosts) {
917
+ const inner = chipInner(host)
918
+ if (!inner) continue
919
+ const parsed = parseBlockLabel(inner.getAttribute('title') || '') || parseBlockLabel(chipLabelText(host))
920
+ if (!parsed) continue
921
+ const seat = host.closest(`[${MARK_SESSION}]`)
922
+ const sid = seat && seat.getAttribute(MARK_SESSION)
923
+ if (!sid) continue
924
+ const block = controller.listFor(sid).find((b) => b.type === parsed.type && b.labelNumber === parsed.n)
925
+ if (!block) continue
926
+ // Retitle the chip to the current language. Lexical caches the
927
+ // insert-time label inside the node, so on a Settings → Language
928
+ // switch (or a chip that remounted with the old label) the DOM is
929
+ // the sync surface; React's own diff never touches these nodes
930
+ // again while the node state is unchanged.
931
+ if (inner.getAttribute('title') !== block.label) {
932
+ inner.setAttribute('title', block.label)
933
+ const spans = [...inner.querySelectorAll(':scope > span')].filter((s) => !s.classList.contains(CHIP_X_CLASS))
934
+ const labelEl = spans[spans.length - 1]
935
+ if (labelEl && labelEl.textContent !== block.label) labelEl.textContent = block.label
936
+ }
937
+ // Ensure the ✕ delete affordance exists on this chip.
938
+ let x = inner.querySelector(`:scope > .${CHIP_X_CLASS}`)
939
+ if (!x) {
940
+ x = document.createElement('span')
941
+ x.className = CHIP_X_CLASS
942
+ x.textContent = '×'
943
+ x.setAttribute('role', 'button')
944
+ x.setAttribute('tabindex', '-1')
945
+ inner.appendChild(x)
946
+ }
947
+ const tip = t('remove')
948
+ if (x.getAttribute('aria-label') !== tip) {
949
+ x.setAttribute('aria-label', tip)
950
+ x.setAttribute('title', tip)
951
+ }
952
+ }
953
+ }
954
+
955
+ let scanScheduled = 0
956
+ function scheduleScan() {
957
+ if (scanScheduled) return
958
+ const run = () => { scanScheduled = 0; scanChips() }
959
+ scanScheduled = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : setTimeout(run, 16)
960
+ }
961
+
962
+ ctx.effect(() => {
963
+ if (typeof MutationObserver === 'undefined' || !document.body) return undefined
964
+ const mo = new MutationObserver(scheduleScan)
965
+ mo.observe(document.body, { childList: true, subtree: true })
966
+ return () => mo.disconnect()
967
+ }, 'paste-code-block: chip sync observer')
968
+
969
+ // A locale switch (or a late dictionary registration bumps the revision
970
+ // too) re-derives labels: controller state first, chip DOM second.
971
+ ctx.effect(() => {
972
+ const face = ctx.locale
973
+ if (!face || typeof face.subscribe !== 'function') return undefined
974
+ return face.subscribe(() => { controller.retitleAll(); scheduleScan() })
975
+ }, 'paste-code-block: locale sync')
976
+ scheduleScan() // late-mount safety for chips rendered before plugin boot
977
+
563
978
  ctx.effect(() => ctx.inputTriggers.registerSource({
564
979
  trigger: '@',
565
980
  name: SOURCE,
@@ -580,21 +995,6 @@ window.__ModuleLoader__.load({
580
995
  return () => style.remove()
581
996
  }, 'paste-code-block: styles')
582
997
 
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
998
  // Turn qualifying pastes into boxed inline chips.
599
999
  const onPaste = (ev) => {
600
1000
  const clip = ev.clipboardData
@@ -635,18 +1035,32 @@ window.__ModuleLoader__.load({
635
1035
  return () => document.removeEventListener('paste', onPaste, true)
636
1036
  }, 'paste-code-block: paste capture')
637
1037
 
638
- // Clicking an inline chip toggles its detail card.
1038
+ // Chip interactions: ✕ deletes the block, a body click toggles its card;
1039
+ // any other click dismisses the open card.
639
1040
  const onChipPointer = (ev) => {
640
1041
  const target = ev.target
641
1042
  if (!(target instanceof Element)) return
642
- // Clicking a chip toggles its detail card.
1043
+ // ✕ on a chip: remove that block outright.
1044
+ const chipX = target.closest(`.${CHIP_X_CLASS}`)
1045
+ if (chipX) {
1046
+ const xHost = chipX.closest(`[data-composer-chip="${SOURCE}"]`)
1047
+ const xSid = xHost && sessionFor(xHost)
1048
+ const label = xHost ? chipLabelText(xHost) : ''
1049
+ const block = xSid && label ? controller.blockForLabel(xSid, label) : null
1050
+ if (block) {
1051
+ ev.preventDefault()
1052
+ ev.stopPropagation()
1053
+ controller.remove(xSid, block.id)
1054
+ }
1055
+ return
1056
+ }
1057
+ // Clicking a chip body toggles its detail card.
643
1058
  const host = target.closest(`[data-composer-chip="${SOURCE}"]`)
644
1059
  if (host) {
645
- const labelEl = host.querySelector(':scope > span[title]') || host.querySelector('span[title]')
646
- const label = labelEl && labelEl.getAttribute('title')
1060
+ const label = chipLabelText(host)
647
1061
  const sid = sessionFor(target)
648
1062
  if (!sid || !label) return
649
- const block = controller.blockForLabel(label)
1063
+ const block = controller.blockForLabel(sid, label)
650
1064
  if (!block) return
651
1065
  ev.preventDefault()
652
1066
  ev.stopPropagation()
@@ -670,8 +1084,11 @@ window.__ModuleLoader__.load({
670
1084
  name: 'conversation.input.dock',
671
1085
  id: DETAIL_NS,
672
1086
  order: 90,
673
- label: '粘贴代码块详情',
674
- }, (props) => React.createElement(BlockDock, { ...props, controller })))
1087
+ label: t('slot.label'),
1088
+ // Declaring the locale namespace gives the dock a fresh `t` seat prop
1089
+ // and re-renders it automatically on every Settings → Language switch.
1090
+ locale: NS,
1091
+ }, (props) => React.createElement(BlockDock, { ...props, controller, syncChips: scheduleScan })))
675
1092
  }
676
1093
 
677
1094
  exports.apply = apply