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