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/README.md +59 -33
- package/README.zh-CN.md +57 -35
- package/docs/design.md +112 -9
- package/docs/images/hero.png +0 -0
- package/docs/images/hero.svg +4 -0
- package/docs/images/languages.png +0 -0
- package/docs/images/languages.svg +4 -0
- package/docs/images/workflow.png +0 -0
- package/docs/images/workflow.svg +4 -0
- package/package.json +11 -4
- package/src/chip-nodes.js +46 -0
- package/src/client.js +470 -53
- package/src/i18n.js +111 -0
- package/src/parse.js +3 -2
- package/src/projection.js +54 -0
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
|
-
//
|
|
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
|
|
51
|
-
* (
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
|
292
|
-
* transiently vanish, so prune is deferred
|
|
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' },
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|