dsh-paste-code-block 0.2.5 → 0.2.6
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/LICENSE +21 -21
- package/README.md +118 -118
- package/README.zh-CN.md +118 -118
- package/cordis.patch.yml +4 -4
- package/docs/design.md +373 -337
- package/docs/images/hero.svg +3 -3
- package/docs/images/languages.svg +3 -3
- package/docs/images/render.cjs +20 -20
- package/docs/images/workflow.svg +3 -3
- package/package.json +3 -2
- package/src/chip-nodes.js +46 -46
- package/src/client.js +273 -61
- package/src/i18n.js +140 -140
- package/src/index.js +11 -11
- package/src/parse.js +159 -6
- package/src/projection.js +54 -54
- package/tests/chip-nodes.test.js +71 -0
- package/tests/fixtures.js +9 -0
- package/tests/fold-smoke.test.js +642 -0
- package/tests/fold.test.js +147 -0
- package/tests/i18n.test.js +112 -0
- package/tests/lexical-chip-probe.mjs +109 -0
- package/tests/parse.test.js +183 -0
- package/tests/projection.test.js +64 -0
package/docs/design.md
CHANGED
|
@@ -1,337 +1,373 @@
|
|
|
1
|
-
# Design notes — dsh-paste-code-block
|
|
2
|
-
|
|
3
|
-
This document captures the non-obvious design decisions behind the plugin, so future
|
|
4
|
-
contributors understand *why* the code is the way it is. It is not a usage guide — see
|
|
5
|
-
[README.md](../README.md) for that.
|
|
6
|
-
|
|
7
|
-
## 1. One plugin, two halves
|
|
8
|
-
|
|
9
|
-
A DSH plugin can ship a **host** (node) half and a **client** (browser) half. This plugin is a
|
|
10
|
-
*pure UI* plugin: all behavior lives on the browser side.
|
|
11
|
-
|
|
12
|
-
- `src/index.js` — host half. **Intentionally empty.** It exists only so the package resolves
|
|
13
|
-
as a DSH plugin and so the `dsh.client` declaration in `package.json` is discovered.
|
|
14
|
-
- `src/client.js` — browser half. A self-contained UMD bundle loaded via
|
|
15
|
-
`window.__ModuleLoader__.load({ id, factory })`. It captures pastes, restyles DSH's inline
|
|
16
|
-
reference chips into boxed badges, and renders an editing detail card above the composer.
|
|
17
|
-
|
|
18
|
-
Because the client is loaded by DSH's module loader — which resolves *package names*, not
|
|
19
|
-
relative ESM files — `client.js` **cannot** `import` a sibling module. Its runtime logic is
|
|
20
|
-
therefore **embedded** in the bundle. To keep that logic unit-testable, the same functions are
|
|
21
|
-
extracted into plain, dependency-free ESM modules which the test suite exercises:
|
|
22
|
-
|
|
23
|
-
- `src/parse.js` — block detection / language detection / serialization.
|
|
24
|
-
- `src/i18n.js` — locale dictionaries, label building, and label parsing.
|
|
25
|
-
|
|
26
|
-
**Keep `src/parse.js` / `src/i18n.js` and their embedded copies in `src/client.js` in sync.**
|
|
27
|
-
|
|
28
|
-
## 2. How a paste becomes a card (the pipeline)
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
paste event
|
|
32
|
-
└─ parseBlock(text) → Block | null (pure logic, in src/parse.js)
|
|
33
|
-
└─ is short plain prose? → null → paste as-is (don't intercept)
|
|
34
|
-
└─ else → { lang, content, lines, isCode }
|
|
35
|
-
└─ controller.attach(sid, block)
|
|
36
|
-
├─ nextLabel(sid, type) → smallest free number (code/text counted apart)
|
|
37
|
-
├─ insertReference(...) → DSH 'slash/input-insert-reference' with clipboardText '\u200B'
|
|
38
|
-
└─ publish(sid) → re-render the dock
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
## 3. The hidden-reference codec
|
|
42
|
-
|
|
43
|
-
The card is rendered by DSH's existing **hidden-reference (chip)** machinery — the same
|
|
44
|
-
mechanism that renders file/upload references inline. The plugin registers a source under the
|
|
45
|
-
`@` input trigger:
|
|
46
|
-
|
|
47
|
-
- `trigger: '@'`, `name: 'paste-code-block'`
|
|
48
|
-
- `codec.clipboardText` → `''` (the chip leaves no visible text on copy)
|
|
49
|
-
- `codec.serialize(ref)` → the block's fenced ```` ```lang … ``` ```` text
|
|
50
|
-
|
|
51
|
-
The chip label is the localized human-readable badge (`代码块 N` / `Code #N` — see §9);
|
|
52
|
-
clicking it opens the detail card, and each chip carries a `×` button that deletes the block
|
|
53
|
-
(see §10).
|
|
54
|
-
|
|
55
|
-
## 4. Why `clipboardText` is U+200B (zero-width space)
|
|
56
|
-
|
|
57
|
-
`insertReference` passes `clipboardText: '\u200B'`. This is deliberate:
|
|
58
|
-
|
|
59
|
-
- Each chip contributes exactly **one character** to the editor's detect text.
|
|
60
|
-
- `String.prototype.trim()` does **not** strip U+200B, so the composer's empty check
|
|
61
|
-
(`draft.trim() === ""`) sees a draft with only chips as *non-empty*, keeping the send button
|
|
62
|
-
enabled even when the user pasted a block with no surrounding text.
|
|
63
|
-
- Because `draft.length` then equals the detect length, anchoring insertions at `draft.length`
|
|
64
|
-
keeps chips strictly ordered — fixing the earlier "every 3rd block shifts forward by 1" bug.
|
|
65
|
-
|
|
66
|
-
## 5. Numbering and reuse
|
|
67
|
-
|
|
68
|
-
Numbers are tracked **per session, split by type** (`code` vs `text`):
|
|
69
|
-
|
|
70
|
-
- `nextLabel` always allocates the **smallest free integer** (`while (used.has(n)) n++`).
|
|
71
|
-
- Deleting a chip frees its number; the next paste reuses it.
|
|
72
|
-
|
|
73
|
-
So `代码块 1` … `代码块 N` and `文本块 1` … `文本块 M` each count independently and
|
|
74
|
-
stay dense after removals.
|
|
75
|
-
|
|
76
|
-
## 6. Markdown vs. code detection
|
|
77
|
-
|
|
78
|
-
The trickiest heuristic is deciding whether pasted text is *code* or *markdown prose*. The
|
|
79
|
-
signal counter `countMarkdownSignals` scores structural markdown (headings, blockquotes, lists,
|
|
80
|
-
links, bold, tables, hr). A lone `#` comment in code scores 1 and stays code; a real markdown
|
|
81
|
-
document scores ≥2 and is treated as text. Fenced snippets always win as code.
|
|
82
|
-
|
|
83
|
-
## 7. Anchor stability and reconcile
|
|
84
|
-
|
|
85
|
-
Blocks are **appended at the end of the draft**, reusing DSH's hidden-reference semantics so
|
|
86
|
-
cursor/undo stay stable. During a submit the chips may transiently vanish; `reconcile` defers
|
|
87
|
-
pruning while `serializing` is setatch, and `restoreFailed` re-surfaces in-flight blocks if the
|
|
88
|
-
send fails — so a failed send auto-refills the input.
|
|
89
|
-
|
|
90
|
-
## 8. Testing strategy
|
|
91
|
-
|
|
92
|
-
`tests/parse.test.js` and `tests/i18n.test.js` exercise the two pure modules (`src/parse.js`,
|
|
93
|
-
`src/i18n.js`) with the built-in `node:test` runner (no test framework dependency). The
|
|
94
|
-
DOM/controller glue is not unit-tested because it depends on the live DSH client runtime; it is
|
|
95
|
-
verified manually in the browser.
|
|
96
|
-
|
|
97
|
-
```sh
|
|
98
|
-
npm test
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
## 9. Localization (i18n)
|
|
102
|
-
|
|
103
|
-
Block names are produced through **DSH's own locale service**, the same one first-party plugins
|
|
104
|
-
(schedule, session-log-export, …) use:
|
|
105
|
-
|
|
106
|
-
- `apply()` registers the `paste-code-block` namespace via `ctx.locale.register(NS, {zh, en})`
|
|
107
|
-
with *identical key sets* in both dictionaries (the built-in locale runtime assumes that).
|
|
108
|
-
- All user-visible strings — chip labels (`block.code` / `block.text` with `{n}`), button
|
|
109
|
-
tooltips, the `{count}` line counter (with a `lines.one` / `lines.other` plural split),
|
|
110
|
-
aria labels, slot label, and the stale-block send error — go through the bound translator
|
|
111
|
-
`t(key, params)`.
|
|
112
|
-
- The dock slot entry declares `locale: NS`, so the renderer hands it a fresh `t` seat **and
|
|
113
|
-
re-renders it on every locale revision**.
|
|
114
|
-
|
|
115
|
-
**Naming and visual identity (0.1.3).** A chip is a *reference badge*, not an action button, so
|
|
116
|
-
the pre-0.1.3 names carried a verb nobody invokes on it — `复制代码块N` / `Code block #N` read
|
|
117
|
-
like a command and burned width inside the composer. Current names are noun + number
|
|
118
|
-
(`代码块 1` / `Code #1`). The code/text distinction moved from wording to visuals: `scanChips`
|
|
119
|
-
tags every chip host with `data-pcb-type`, and CSS renders a leading glyph — `</>` for code
|
|
120
|
-
(mono, business-primary) and `Aa` for text (neutral gray) — plus a soft blue tint on code
|
|
121
|
-
chips. Pre-0.1.3 labels stay parseable through the `legacyLabels` table, so chips inserted by
|
|
122
|
-
an older bundle keep their click/✕/retitle identity until the next locale-synced retitle.
|
|
123
|
-
|
|
124
|
-
**Chip retitling across switches.** The chip label is *insert-time* content: Lexical caches the
|
|
125
|
-
label inside the decorator node, and React never re-renders those node bodies while the node
|
|
126
|
-
state is unchanged. So on a locale switch the plugin: (1) recomputes `block.label` for every
|
|
127
|
-
tracked block and rebuilds `labelIndex` (`retitleAll`), then (2) patches the visible DOM —
|
|
128
|
-
`title` attribute and label span `.textContent` — inside `scanChips`. React's virtual DOM
|
|
129
|
-
record still says the old label, so it never fights the patch; a full chip remount (the only
|
|
130
|
-
revert vector) is caught by the same MutationObserver that manages the ✕ button.
|
|
131
|
-
|
|
132
|
-
**Cross-language identity.** `parseBlockLabel` recognizes a rendered label in **any** shipped
|
|
133
|
-
locale and recovers `{ type, n }`; lookups (chip click, ✕ delete, retitle matching) resolve the
|
|
134
|
-
block by that identity within the chip's own session. A chip inserted under one language keeps
|
|
135
|
-
working after switching to the other.
|
|
136
|
-
|
|
137
|
-
## 10. The per-chip ✕ delete button
|
|
138
|
-
|
|
139
|
-
DSH renders chips from Lexical decorator nodes; third parties cannot add React children to
|
|
140
|
-
them. Instead the plugin **appends a `<span class="dsh-pcb-chip-x">×</span>` into each chip's
|
|
141
|
-
inner element** and keeps them present with a `MutationObserver` on `document.body`
|
|
142
|
-
(rAF-coalesced; the scan exits immediately when no block is tracked). A capture-phase
|
|
143
|
-
`pointerdown` handler checks `.dsh-pcb-chip-x` **before** the toggle logic and calls
|
|
144
|
-
`controller.remove`, which removes **just that chip's Lexical node** and frees its number. If
|
|
145
|
-
the chip is not backed by a tracked block (e.g. a restored draft after redraw), no ✕ is added —
|
|
146
|
-
no dead affordances. Touch targets grow on coarse pointers (`@media (pointer:coarse)`).
|
|
147
|
-
|
|
148
|
-
**Why node-level removal matters — the 0.1.1 incident.** The first `remove()` implementation
|
|
149
|
-
spliced the chip's placeholder char out of the draft string and wrote the result through
|
|
150
|
-
`shell.setDraft()`. But `setDraft` **clears the editor root and rebuilds plain-text
|
|
151
|
-
paragraphs** — chips are decorator nodes that cannot round-trip through text, so deleting one
|
|
152
|
-
block destroyed *every* chip in the draft. `removeChipNodes()` instead scans the editor's node
|
|
153
|
-
map for `ReferenceChipNode`s whose public `getSource()`/`getReference()` match this plugin and
|
|
154
|
-
the block's ref, and calls the node's own `remove()` inside one discrete update — a surgical,
|
|
155
|
-
undoable (`Ctrl+Z`) deletion that leaves sibling chips untouched.
|
|
156
|
-
|
|
157
|
-
**The 0.1.2 silent no-op.** The node map (`editor.getEditorState()._nodeMap`) is keyed by
|
|
158
|
-
NodeKey and **its values are the nodes themselves**; the older `NodeState` wrapper with a
|
|
159
|
-
`.node` field no longer exists. Reading `value.node` therefore matched *nothing* for every
|
|
160
|
-
entry, `removeChipNodes()` removed zero chips, yet the call looked successful — the block's
|
|
161
|
-
state was pruned behind a chip that was still in the draft, so clicking ✕ appeared to do
|
|
162
|
-
nothing at all. `src/chip-nodes.js` now normalizes both shapes (`chipNodeOf`) and is unit
|
|
163
|
-
tested for exactly this rule; `tests/lexical-chip-probe.mjs` reproduces the original defect
|
|
164
|
-
against real `lexical@0.49.0`:
|
|
165
|
-
|
|
166
|
-
```
|
|
167
|
-
_nodeMap value shape: valueIsNode=true, hasNodeProp=false
|
|
168
|
-
OLD code (value.node): removed = 0 | chips after = r1,r2,r3 <- silent no-op
|
|
169
|
-
NEW code (value is node): removed = 1 | chips after = r1,r3 <- only the target
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
**Failure is never silent and never destructive.** `remove()` gates every state change on
|
|
173
|
-
`chipPresent()` — the editor's node map first, the published occurrence projection as a
|
|
174
|
-
cross-check — so a block is pruned only once its chip is verifiably gone. If the chip survives
|
|
175
|
-
the attempt the block stays tracked and the composer surfaces `error.remove`; the old
|
|
176
|
-
`setDraft` fallback is gone for good. State that diverges from the editor still self-heals via
|
|
177
|
-
`reconcile`.
|
|
178
|
-
|
|
179
|
-
## 11. Insertion leaves no stray space
|
|
180
|
-
|
|
181
|
-
`SessionInputShell.insertReference` inserts `[chip, ' ']` — a separating space — whenever the
|
|
182
|
-
character at the pick-time span is not itself a space. Because a block is always appended at
|
|
183
|
-
the **end** of the draft, that condition is always true, so every paste used to leave a space
|
|
184
|
-
behind: text typed next started with a space (the visible "prefix space"), and pasting several
|
|
185
|
-
blocks piled stray spaces into the message body.
|
|
186
|
-
|
|
187
|
-
Two rules keep the draft clean without ever touching user whitespace:
|
|
188
|
-
|
|
189
|
-
1. **Insert, then drop exactly the appended character.** `dropSeparatorSpace()` asks
|
|
190
|
-
`separatorSpaceSpan()` (src/projection.js) for the detect span of the character directly
|
|
191
|
-
after *our* chip and deletes it with the same public `slash/input-insert-text` verb DSH uses
|
|
192
|
-
for its own span edits. Since the chip is inserted at the end, that character can only have
|
|
193
|
-
come from the insertion itself — a trailing space the user had already typed still sits
|
|
194
|
-
before the chip, untouched. The span is only used when the projection shows a space right
|
|
195
|
-
after this block's chip, and a failure to apply just logs (the draft keeps DSH's behaviour).
|
|
196
|
-
2. **Delete removes the chip node only.** `remove()` drops the chip's Lexical node
|
|
197
|
-
(§10) and never rewrites text, so spaces around a deleted block survive exactly as they were.
|
|
198
|
-
|
|
199
|
-
`src/projection.js` owns the coordinate conversion this needs: the published `draft` is the
|
|
200
|
-
clipboard projection (a chip occupies `occurrence.length` characters, i.e. one zero-width space
|
|
201
|
-
for us) while the scoped edit verbs address detect coordinates (a chip collapses to exactly one
|
|
202
|
-
placeholder character). `detectOffsetOf()` converts with
|
|
203
|
-
`detect = clipboard - Σ(len_i - 1)` over preceding occurrences, so blocks stay correctly
|
|
204
|
-
addressed even when other plugins' chips (file references and the like) sit in the same draft.
|
|
205
|
-
Both helpers are pure and unit-tested (8 cases in tests/projection.test.js).
|
|
206
|
-
|
|
207
|
-
## 12. Sent-message folding (0.2.0)
|
|
208
|
-
|
|
209
|
-
The composer fold was only half the story: on send the chips expand back into fenced text, and
|
|
210
|
-
DSH renders user bubbles as **plain pre-wrap text** (`projectUserText` → `span.plainRun` runs
|
|
211
|
-
plus `data-ref-chip` badges), so the sent message reappeared in the transcript as the very wall
|
|
212
|
-
of text the plugin exists to prevent. §12 folds *sent* blocks back down.
|
|
213
|
-
|
|
214
|
-
**Where the DOM hooks are.** Every conversation row carries `data-chat-flow-kind`
|
|
215
|
-
(`user` / `steering` / `agent` / …) and `data-chat-flow-key` on its wrapper; the right-aligned
|
|
216
|
-
user bubble lives under the same row and contains the `plainRun` spans. The send echo
|
|
217
|
-
(`data-submission-echo`) and pending steering (`data-pending-steering`) render the same bubble
|
|
218
|
-
markup one frame before the durable node exists, so scanning all four gives an instant
|
|
219
|
-
collapsed view from the moment of send. Assistant output is deliberately not touched: its
|
|
220
|
-
markdown renderer already styles code blocks its own way.
|
|
221
|
-
|
|
222
|
-
**Never fight React.** The bubble's children belong to React; removing them would make the
|
|
223
|
-
next reconciliation throw. So the fold *hides*
|
|
224
|
-
the node stays a child, React keeps patching it harmlessly) and inserts a
|
|
225
|
-
`div.dsh-pcb-fold-host` **after**
|
|
226
|
-
|
|
227
|
-
`
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
`
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
fold
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
on
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
1
|
+
# Design notes — dsh-paste-code-block
|
|
2
|
+
|
|
3
|
+
This document captures the non-obvious design decisions behind the plugin, so future
|
|
4
|
+
contributors understand *why* the code is the way it is. It is not a usage guide — see
|
|
5
|
+
[README.md](../README.md) for that.
|
|
6
|
+
|
|
7
|
+
## 1. One plugin, two halves
|
|
8
|
+
|
|
9
|
+
A DSH plugin can ship a **host** (node) half and a **client** (browser) half. This plugin is a
|
|
10
|
+
*pure UI* plugin: all behavior lives on the browser side.
|
|
11
|
+
|
|
12
|
+
- `src/index.js` — host half. **Intentionally empty.** It exists only so the package resolves
|
|
13
|
+
as a DSH plugin and so the `dsh.client` declaration in `package.json` is discovered.
|
|
14
|
+
- `src/client.js` — browser half. A self-contained UMD bundle loaded via
|
|
15
|
+
`window.__ModuleLoader__.load({ id, factory })`. It captures pastes, restyles DSH's inline
|
|
16
|
+
reference chips into boxed badges, and renders an editing detail card above the composer.
|
|
17
|
+
|
|
18
|
+
Because the client is loaded by DSH's module loader — which resolves *package names*, not
|
|
19
|
+
relative ESM files — `client.js` **cannot** `import` a sibling module. Its runtime logic is
|
|
20
|
+
therefore **embedded** in the bundle. To keep that logic unit-testable, the same functions are
|
|
21
|
+
extracted into plain, dependency-free ESM modules which the test suite exercises:
|
|
22
|
+
|
|
23
|
+
- `src/parse.js` — block detection / language detection / serialization.
|
|
24
|
+
- `src/i18n.js` — locale dictionaries, label building, and label parsing.
|
|
25
|
+
|
|
26
|
+
**Keep `src/parse.js` / `src/i18n.js` and their embedded copies in `src/client.js` in sync.**
|
|
27
|
+
|
|
28
|
+
## 2. How a paste becomes a card (the pipeline)
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
paste event
|
|
32
|
+
└─ parseBlock(text) → Block | null (pure logic, in src/parse.js)
|
|
33
|
+
└─ is short plain prose? → null → paste as-is (don't intercept)
|
|
34
|
+
└─ else → { lang, content, lines, isCode }
|
|
35
|
+
└─ controller.attach(sid, block)
|
|
36
|
+
├─ nextLabel(sid, type) → smallest free number (code/text counted apart)
|
|
37
|
+
├─ insertReference(...) → DSH 'slash/input-insert-reference' with clipboardText '\u200B'
|
|
38
|
+
└─ publish(sid) → re-render the dock
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 3. The hidden-reference codec
|
|
42
|
+
|
|
43
|
+
The card is rendered by DSH's existing **hidden-reference (chip)** machinery — the same
|
|
44
|
+
mechanism that renders file/upload references inline. The plugin registers a source under the
|
|
45
|
+
`@` input trigger:
|
|
46
|
+
|
|
47
|
+
- `trigger: '@'`, `name: 'paste-code-block'`
|
|
48
|
+
- `codec.clipboardText` → `''` (the chip leaves no visible text on copy)
|
|
49
|
+
- `codec.serialize(ref)` → the block's fenced ```` ```lang … ``` ```` text
|
|
50
|
+
|
|
51
|
+
The chip label is the localized human-readable badge (`代码块 N` / `Code #N` — see §9);
|
|
52
|
+
clicking it opens the detail card, and each chip carries a `×` button that deletes the block
|
|
53
|
+
(see §10).
|
|
54
|
+
|
|
55
|
+
## 4. Why `clipboardText` is U+200B (zero-width space)
|
|
56
|
+
|
|
57
|
+
`insertReference` passes `clipboardText: '\u200B'`. This is deliberate:
|
|
58
|
+
|
|
59
|
+
- Each chip contributes exactly **one character** to the editor's detect text.
|
|
60
|
+
- `String.prototype.trim()` does **not** strip U+200B, so the composer's empty check
|
|
61
|
+
(`draft.trim() === ""`) sees a draft with only chips as *non-empty*, keeping the send button
|
|
62
|
+
enabled even when the user pasted a block with no surrounding text.
|
|
63
|
+
- Because `draft.length` then equals the detect length, anchoring insertions at `draft.length`
|
|
64
|
+
keeps chips strictly ordered — fixing the earlier "every 3rd block shifts forward by 1" bug.
|
|
65
|
+
|
|
66
|
+
## 5. Numbering and reuse
|
|
67
|
+
|
|
68
|
+
Numbers are tracked **per session, split by type** (`code` vs `text`):
|
|
69
|
+
|
|
70
|
+
- `nextLabel` always allocates the **smallest free integer** (`while (used.has(n)) n++`).
|
|
71
|
+
- Deleting a chip frees its number; the next paste reuses it.
|
|
72
|
+
|
|
73
|
+
So `代码块 1` … `代码块 N` and `文本块 1` … `文本块 M` each count independently and
|
|
74
|
+
stay dense after removals.
|
|
75
|
+
|
|
76
|
+
## 6. Markdown vs. code detection
|
|
77
|
+
|
|
78
|
+
The trickiest heuristic is deciding whether pasted text is *code* or *markdown prose*. The
|
|
79
|
+
signal counter `countMarkdownSignals` scores structural markdown (headings, blockquotes, lists,
|
|
80
|
+
links, bold, tables, hr). A lone `#` comment in code scores 1 and stays code; a real markdown
|
|
81
|
+
document scores ≥2 and is treated as text. Fenced snippets always win as code.
|
|
82
|
+
|
|
83
|
+
## 7. Anchor stability and reconcile
|
|
84
|
+
|
|
85
|
+
Blocks are **appended at the end of the draft**, reusing DSH's hidden-reference semantics so
|
|
86
|
+
cursor/undo stay stable. During a submit the chips may transiently vanish; `reconcile` defers
|
|
87
|
+
pruning while `serializing` is setatch, and `restoreFailed` re-surfaces in-flight blocks if the
|
|
88
|
+
send fails — so a failed send auto-refills the input.
|
|
89
|
+
|
|
90
|
+
## 8. Testing strategy
|
|
91
|
+
|
|
92
|
+
`tests/parse.test.js` and `tests/i18n.test.js` exercise the two pure modules (`src/parse.js`,
|
|
93
|
+
`src/i18n.js`) with the built-in `node:test` runner (no test framework dependency). The
|
|
94
|
+
DOM/controller glue is not unit-tested because it depends on the live DSH client runtime; it is
|
|
95
|
+
verified manually in the browser.
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
npm test
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## 9. Localization (i18n)
|
|
102
|
+
|
|
103
|
+
Block names are produced through **DSH's own locale service**, the same one first-party plugins
|
|
104
|
+
(schedule, session-log-export, …) use:
|
|
105
|
+
|
|
106
|
+
- `apply()` registers the `paste-code-block` namespace via `ctx.locale.register(NS, {zh, en})`
|
|
107
|
+
with *identical key sets* in both dictionaries (the built-in locale runtime assumes that).
|
|
108
|
+
- All user-visible strings — chip labels (`block.code` / `block.text` with `{n}`), button
|
|
109
|
+
tooltips, the `{count}` line counter (with a `lines.one` / `lines.other` plural split),
|
|
110
|
+
aria labels, slot label, and the stale-block send error — go through the bound translator
|
|
111
|
+
`t(key, params)`.
|
|
112
|
+
- The dock slot entry declares `locale: NS`, so the renderer hands it a fresh `t` seat **and
|
|
113
|
+
re-renders it on every locale revision**.
|
|
114
|
+
|
|
115
|
+
**Naming and visual identity (0.1.3).** A chip is a *reference badge*, not an action button, so
|
|
116
|
+
the pre-0.1.3 names carried a verb nobody invokes on it — `复制代码块N` / `Code block #N` read
|
|
117
|
+
like a command and burned width inside the composer. Current names are noun + number
|
|
118
|
+
(`代码块 1` / `Code #1`). The code/text distinction moved from wording to visuals: `scanChips`
|
|
119
|
+
tags every chip host with `data-pcb-type`, and CSS renders a leading glyph — `</>` for code
|
|
120
|
+
(mono, business-primary) and `Aa` for text (neutral gray) — plus a soft blue tint on code
|
|
121
|
+
chips. Pre-0.1.3 labels stay parseable through the `legacyLabels` table, so chips inserted by
|
|
122
|
+
an older bundle keep their click/✕/retitle identity until the next locale-synced retitle.
|
|
123
|
+
|
|
124
|
+
**Chip retitling across switches.** The chip label is *insert-time* content: Lexical caches the
|
|
125
|
+
label inside the decorator node, and React never re-renders those node bodies while the node
|
|
126
|
+
state is unchanged. So on a locale switch the plugin: (1) recomputes `block.label` for every
|
|
127
|
+
tracked block and rebuilds `labelIndex` (`retitleAll`), then (2) patches the visible DOM —
|
|
128
|
+
`title` attribute and label span `.textContent` — inside `scanChips`. React's virtual DOM
|
|
129
|
+
record still says the old label, so it never fights the patch; a full chip remount (the only
|
|
130
|
+
revert vector) is caught by the same MutationObserver that manages the ✕ button.
|
|
131
|
+
|
|
132
|
+
**Cross-language identity.** `parseBlockLabel` recognizes a rendered label in **any** shipped
|
|
133
|
+
locale and recovers `{ type, n }`; lookups (chip click, ✕ delete, retitle matching) resolve the
|
|
134
|
+
block by that identity within the chip's own session. A chip inserted under one language keeps
|
|
135
|
+
working after switching to the other.
|
|
136
|
+
|
|
137
|
+
## 10. The per-chip ✕ delete button
|
|
138
|
+
|
|
139
|
+
DSH renders chips from Lexical decorator nodes; third parties cannot add React children to
|
|
140
|
+
them. Instead the plugin **appends a `<span class="dsh-pcb-chip-x">×</span>` into each chip's
|
|
141
|
+
inner element** and keeps them present with a `MutationObserver` on `document.body`
|
|
142
|
+
(rAF-coalesced; the scan exits immediately when no block is tracked). A capture-phase
|
|
143
|
+
`pointerdown` handler checks `.dsh-pcb-chip-x` **before** the toggle logic and calls
|
|
144
|
+
`controller.remove`, which removes **just that chip's Lexical node** and frees its number. If
|
|
145
|
+
the chip is not backed by a tracked block (e.g. a restored draft after redraw), no ✕ is added —
|
|
146
|
+
no dead affordances. Touch targets grow on coarse pointers (`@media (pointer:coarse)`).
|
|
147
|
+
|
|
148
|
+
**Why node-level removal matters — the 0.1.1 incident.** The first `remove()` implementation
|
|
149
|
+
spliced the chip's placeholder char out of the draft string and wrote the result through
|
|
150
|
+
`shell.setDraft()`. But `setDraft` **clears the editor root and rebuilds plain-text
|
|
151
|
+
paragraphs** — chips are decorator nodes that cannot round-trip through text, so deleting one
|
|
152
|
+
block destroyed *every* chip in the draft. `removeChipNodes()` instead scans the editor's node
|
|
153
|
+
map for `ReferenceChipNode`s whose public `getSource()`/`getReference()` match this plugin and
|
|
154
|
+
the block's ref, and calls the node's own `remove()` inside one discrete update — a surgical,
|
|
155
|
+
undoable (`Ctrl+Z`) deletion that leaves sibling chips untouched.
|
|
156
|
+
|
|
157
|
+
**The 0.1.2 silent no-op.** The node map (`editor.getEditorState()._nodeMap`) is keyed by
|
|
158
|
+
NodeKey and **its values are the nodes themselves**; the older `NodeState` wrapper with a
|
|
159
|
+
`.node` field no longer exists. Reading `value.node` therefore matched *nothing* for every
|
|
160
|
+
entry, `removeChipNodes()` removed zero chips, yet the call looked successful — the block's
|
|
161
|
+
state was pruned behind a chip that was still in the draft, so clicking ✕ appeared to do
|
|
162
|
+
nothing at all. `src/chip-nodes.js` now normalizes both shapes (`chipNodeOf`) and is unit
|
|
163
|
+
tested for exactly this rule; `tests/lexical-chip-probe.mjs` reproduces the original defect
|
|
164
|
+
against real `lexical@0.49.0`:
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
_nodeMap value shape: valueIsNode=true, hasNodeProp=false
|
|
168
|
+
OLD code (value.node): removed = 0 | chips after = r1,r2,r3 <- silent no-op
|
|
169
|
+
NEW code (value is node): removed = 1 | chips after = r1,r3 <- only the target
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Failure is never silent and never destructive.** `remove()` gates every state change on
|
|
173
|
+
`chipPresent()` — the editor's node map first, the published occurrence projection as a
|
|
174
|
+
cross-check — so a block is pruned only once its chip is verifiably gone. If the chip survives
|
|
175
|
+
the attempt the block stays tracked and the composer surfaces `error.remove`; the old
|
|
176
|
+
`setDraft` fallback is gone for good. State that diverges from the editor still self-heals via
|
|
177
|
+
`reconcile`.
|
|
178
|
+
|
|
179
|
+
## 11. Insertion leaves no stray space
|
|
180
|
+
|
|
181
|
+
`SessionInputShell.insertReference` inserts `[chip, ' ']` — a separating space — whenever the
|
|
182
|
+
character at the pick-time span is not itself a space. Because a block is always appended at
|
|
183
|
+
the **end** of the draft, that condition is always true, so every paste used to leave a space
|
|
184
|
+
behind: text typed next started with a space (the visible "prefix space"), and pasting several
|
|
185
|
+
blocks piled stray spaces into the message body.
|
|
186
|
+
|
|
187
|
+
Two rules keep the draft clean without ever touching user whitespace:
|
|
188
|
+
|
|
189
|
+
1. **Insert, then drop exactly the appended character.** `dropSeparatorSpace()` asks
|
|
190
|
+
`separatorSpaceSpan()` (src/projection.js) for the detect span of the character directly
|
|
191
|
+
after *our* chip and deletes it with the same public `slash/input-insert-text` verb DSH uses
|
|
192
|
+
for its own span edits. Since the chip is inserted at the end, that character can only have
|
|
193
|
+
come from the insertion itself — a trailing space the user had already typed still sits
|
|
194
|
+
before the chip, untouched. The span is only used when the projection shows a space right
|
|
195
|
+
after this block's chip, and a failure to apply just logs (the draft keeps DSH's behaviour).
|
|
196
|
+
2. **Delete removes the chip node only.** `remove()` drops the chip's Lexical node
|
|
197
|
+
(§10) and never rewrites text, so spaces around a deleted block survive exactly as they were.
|
|
198
|
+
|
|
199
|
+
`src/projection.js` owns the coordinate conversion this needs: the published `draft` is the
|
|
200
|
+
clipboard projection (a chip occupies `occurrence.length` characters, i.e. one zero-width space
|
|
201
|
+
for us) while the scoped edit verbs address detect coordinates (a chip collapses to exactly one
|
|
202
|
+
placeholder character). `detectOffsetOf()` converts with
|
|
203
|
+
`detect = clipboard - Σ(len_i - 1)` over preceding occurrences, so blocks stay correctly
|
|
204
|
+
addressed even when other plugins' chips (file references and the like) sit in the same draft.
|
|
205
|
+
Both helpers are pure and unit-tested (8 cases in tests/projection.test.js).
|
|
206
|
+
|
|
207
|
+
## 12. Sent-message folding (0.2.0)
|
|
208
|
+
|
|
209
|
+
The composer fold was only half the story: on send the chips expand back into fenced text, and
|
|
210
|
+
DSH renders user bubbles as **plain pre-wrap text** (`projectUserText` → `span.plainRun` runs
|
|
211
|
+
plus `data-ref-chip` badges), so the sent message reappeared in the transcript as the very wall
|
|
212
|
+
of text the plugin exists to prevent. §12 folds *sent* blocks back down.
|
|
213
|
+
|
|
214
|
+
**Where the DOM hooks are.** Every conversation row carries `data-chat-flow-kind`
|
|
215
|
+
(`user` / `steering` / `agent` / …) and `data-chat-flow-key` on its wrapper; the right-aligned
|
|
216
|
+
user bubble lives under the same row and contains the `plainRun` spans. The send echo
|
|
217
|
+
(`data-submission-echo`) and pending steering (`data-pending-steering`) render the same bubble
|
|
218
|
+
markup one frame before the durable node exists, so scanning all four gives an instant
|
|
219
|
+
collapsed view from the moment of send. Assistant output is deliberately not touched: its
|
|
220
|
+
markdown renderer already styles code blocks its own way.
|
|
221
|
+
|
|
222
|
+
**Never fight React.** The bubble's children belong to React; removing them would make the
|
|
223
|
+
next reconciliation throw. So the fold only ever *hides* the runs it consumes (`display:none` —
|
|
224
|
+
the node stays a child, React keeps patching it harmlessly) and inserts a
|
|
225
|
+
`div.dsh-pcb-fold-host` **after** them: after the folded run on the per-run path, appended at
|
|
226
|
+
the end of the bubble on the bubble-wide path. The host is ours: prose segments re-render as
|
|
227
|
+
`span.dsh-pcb-fold-prose`, each fence as a collapsed card (glyph `</>`/`Aa` + localized title
|
|
228
|
+
`python · 128 行` / `Text · 128 lines`, copy button copying the **raw fenced text** exactly as
|
|
229
|
+
sent, click-to-expand `<pre>`). Since hiding a child does not affect `textContent`, the
|
|
230
|
+
message action bar (React-side copy/edit/delete) is entirely unaffected.
|
|
231
|
+
|
|
232
|
+
**What folds.** `splitFencedSegments()` (canonical in `src/parse.js`, embedded copy in
|
|
233
|
+
`client.js`) walks the run line by line with GFM fence rules (``` or ~~~, opener indented ≤ 3
|
|
234
|
+
spaces, closer ≥ opener length alone on its line; backticks in a ``` info string kill that
|
|
235
|
+
opener; an unterminated opener stays prose). Any fence segment with a non-empty body folds;
|
|
236
|
+
everything else — prose, whitespace between blocks, empty fences — renders verbatim around
|
|
237
|
+
the cards. Fence-only (not "any long text") is deliberate: composer-created blocks always
|
|
238
|
+
travel as fences, while unfenced long messages may have been *typed*, and silently folding
|
|
239
|
+
typed prose would be a nasty surprise.
|
|
240
|
+
|
|
241
|
+
**Idempotence and refresh.** The bubble carries
|
|
242
|
+
`data-dsh-pcb-sig = "<mode>|<stamp>"` (`single` = per-run fold, `bubble` = whole-message fold),
|
|
243
|
+
each hidden run carries `data-dsh-pcb-hide = "<stamp>"`, and each host carries
|
|
244
|
+
`data-dsh-pcb-stamp = "<stamp>"`, where the stamp folds `foldRev`, text length, and an FNV-1a
|
|
245
|
+
hash. A bubble whose signature matches and whose DOM still shows that many hidden runs and
|
|
246
|
+
hosts is skipped, so steady state costs attribute reads. A locale switch bumps `foldRev`, which
|
|
247
|
+
invalidates every stamp — the next scan rebuilds each host and re-titles cards from the live
|
|
248
|
+
translator. Rebuilds preserve who was expanded via a stable `flowKey#scope:segIndex` open set,
|
|
249
|
+
and a payload change (or a React remount of a run underneath us) clears the stale fold first —
|
|
250
|
+
hosts are removed and every stamped run unhidden — before the new plan is built. Disposal
|
|
251
|
+
(HMR / plugin removal) removes all hosts and unhides every run — the transcript hands back
|
|
252
|
+
clean. A bubble with no plan and no fold is never touched at all (no marker, no attribute).
|
|
253
|
+
|
|
254
|
+
`tests/fold-smoke.test.js` exercises the real bundle against a dependency-free DOM shim:
|
|
255
|
+
fold → marker/card/prose structure, steering rows, non-fenced messages untouched, a fence that
|
|
256
|
+
straddles the runs DSH split at its @mention chips folding at bubble scope (0.2.6), retitle
|
|
257
|
+
across locale switches with open-state kept, text edits re-fold and unfold, orphan sweep,
|
|
258
|
+
and dispose restoration.
|
|
259
|
+
|
|
260
|
+
**Bare fences are code (0.2.1).** Card type follows the composer round-trip convention: a
|
|
261
|
+
plain-text block always serializes as an explicit ` ```text ` fence while a code block whose
|
|
262
|
+
language was not detected serializes as a **bare** ` ``` ` fence — so only an explicit `text`
|
|
263
|
+
info string folds as a 文本块, and a bare fence folds as 代码块, re-guessing a language from
|
|
264
|
+
the body for the title (`python · 2 行`, else `代码块 · N 行`). Manually typed bare fences
|
|
265
|
+
follow the same rule; that is also the CommonMark reading. (0.2.0 shipped the inverse and a
|
|
266
|
+
sent 代码块 chip could reappear in the transcript as 文本块.)
|
|
267
|
+
|
|
268
|
+
## 13. Draft rebuild recovery (0.2.1)
|
|
269
|
+
|
|
270
|
+
**Typing-window guard and retired hygiene (0.2.2).** The recovery rewrite is a whole-
|
|
271
|
+
document `setDraft`: the caret drops at the end and an in-flight IME composition is
|
|
272
|
+
destroyed. Run in the middle of typing, that ate the user's first character and bounced
|
|
273
|
+
the view on every pass (reported). Two keystroke-time paths existed: sweeping a stray
|
|
274
|
+
U+200B that belonged to no restorable block (external invisible copy from WeChat / web
|
|
275
|
+
pages — the signature is NOT exclusive to rebuilds after all), and restoring blocks while
|
|
276
|
+
the draft had already moved past the re-seeded text. The hygiene path is retired outright:
|
|
277
|
+
external residue is left exactly where the user put it. Both paths now share one gate —
|
|
278
|
+
`recover()` only acts when the draft is IDENTICAL to the last draft observed for that
|
|
279
|
+
session in this page view (or was never observed: fresh boot, or the composer only ever
|
|
280
|
+
seen hydrating empty). A draft that MOVED between passes is live typing, never a rebuild.
|
|
281
|
+
`attach()` records the shell's post-insertion draft as that fingerprint immediately, and
|
|
282
|
+
the reconcile prune drops the mirror synchronously, so a hand-deleted block cannot be
|
|
283
|
+
resurrected from stale mirror data through the empty-prior window.
|
|
284
|
+
|
|
285
|
+
**Paste-storm guard (0.2.3).** Even with the typing-window guard deployed (load log
|
|
286
|
+
`client loaded v0.2.2` verified live), the console showed the SAME clipboard text (571
|
|
287
|
+
chars) intercepted dozens of times in seconds — an external automation (a Tampermonkey
|
|
288
|
+
AutoClicker was running on the page) re-dispatching paste. Each accepted paste inserts a
|
|
289
|
+
chip and re-renders the dock, so a storm reproduces the exact bounce-and-eat-the-first-
|
|
290
|
+
character experience through a different door. Identical clipboard text arriving
|
|
291
|
+
faster than a human can act collapses into one paste (`pasteStorm.seen`, 1200 ms
|
|
292
|
+
sliding window refreshed on every matching event, so a held repeat stays
|
|
293
|
+
suppressed while it continues); suppressed events are still `preventDefault`ed so
|
|
294
|
+
raw text never falls through to the composer. The plugin cannot suppress the
|
|
295
|
+
automation itself — users hitting this should disable AutoClicker-style userscripts
|
|
296
|
+
on the DSH page.
|
|
297
|
+
|
|
298
|
+
**Burst-only collapse (0.2.5).** 0.2.4 tried a content-liveness rule: while a block
|
|
299
|
+
with the same text was still attached, re-pasting identical text was consumed as
|
|
300
|
+
"not a new intent." That over-suppressed — pasting the same snippet twice (a
|
|
301
|
+
completely normal action) yielded a single block and confused users, and in the
|
|
302
|
+
end the reported bounces were traced to a *different* plugin entirely, not to
|
|
303
|
+
paste storms. So 0.2.5 reverts to a pure burst window: identical text pasted after
|
|
304
|
+
the 1200 ms window is a fresh intent and gets its own block (`sourceText` is
|
|
305
|
+
recorded per block and carried through the recovery mirror); only machine-paced
|
|
306
|
+
repeats *within* the window collapse. The `intercept` load log still carries
|
|
307
|
+
`trusted=` — `false` names a page-script dispatch (userscript glue, or the
|
|
308
|
+
`dsh-vision-router` image-paste replay, which carries the original text and lands
|
|
309
|
+
seconds after the trusted paste) rather than a real Ctrl+V, so any future storm
|
|
310
|
+
report identifies its own source.
|
|
311
|
+
|
|
312
|
+
Two DSH paths strand pasted blocks mid-draft, and both share one signature. (a) A page
|
|
313
|
+
reload re-seeds the composer from DSH's persisted per-session draft
|
|
314
|
+
(`dsh.conversation.<sid>`), which is **plain text** — Lexical chips cannot round-trip
|
|
315
|
+
through it (§7). (b) A workspace switch on the new-session page *carries the draft text
|
|
316
|
+
over* to the target workspace's blank session (`inputHub`: `next.setDraft(from.snapshot
|
|
317
|
+
.draft)`, then `from.setDraft("")`) — prose follows the composer, chips and controller
|
|
318
|
+
state do not, so blocks were silently orphaned under the old session id (user-reported).
|
|
319
|
+
Typed prose comes back either way; the blocks did not.
|
|
320
|
+
|
|
321
|
+
The signature — as 0.2.1 read it: every chip contributes exactly one U+200B to the clipboard
|
|
322
|
+
projection and nothing scrubs it from persisted/carried text (DSH's placeholder-stripping
|
|
323
|
+
regex covers private-use chars and U+FFFC, not ZWSPs). Stray U+200Bs with **no** live
|
|
324
|
+
occurrence of ours can therefore only mean the draft was rebuilt from text; conversely
|
|
325
|
+
deleting a chip — ✕, Backspace, or prune — removes its ZWSP together with the node, so
|
|
326
|
+
deliberate deletion never looks like a rebuild and cannot be "resurrected".
|
|
327
|
+
|
|
328
|
+
`BlockController.recover()` runs in the dock's reconcile effect, ahead of the reconcile,
|
|
329
|
+
and resolves blocks in this order: own-session memory → the session's own localStorage
|
|
330
|
+
mirror → **carry-over donor**: the unmounted session whose last published draft equals the
|
|
331
|
+
re-seeded text character for character (the mirror stores that same fingerprint, so the
|
|
332
|
+
donor is even found after a reload wiped memory) → nothing. An ambiguous fingerprint aborts
|
|
333
|
+
the donor step — no guessing. On a hit the mirror moves to the new sid. (0.2.1 also stripped
|
|
334
|
+
strays when nothing could be restored; the typing-window guard above retired that — see the
|
|
335
|
+
0.2.2 note.) The mirror holds just the block payloads plus that draft fingerprint, is written
|
|
336
|
+
synchronously on attach (and debounced on detail-card edits), drops on a send attempt, and
|
|
337
|
+
expires after two weeks. The recovery pass then *skips* its reconcile — the snapshot it
|
|
338
|
+
would reconcile against is stale; the draft mutation bumps `draftRev` and the next pass
|
|
339
|
+
reconciles real occurrences. Restored chips get re-derived labels and numbers, so a
|
|
340
|
+
restored draft is indistinguishable from one that never left. Mirror writes are best
|
|
341
|
+
effort: private mode / quota only lose reload durability, never the feature itself.
|
|
342
|
+
|
|
343
|
+
## 14. Re-pasting your own message (0.2.6)
|
|
344
|
+
|
|
345
|
+
The 0.2.5 report: the user copied their *previous* message out of the transcript and pasted it
|
|
346
|
+
back to continue the conversation. The plugin got the round trip wrong twice over, and the
|
|
347
|
+
captured payload is kept as `tests/fixtures.js#B2` / `W` / `B` so the shapes cannot drift.
|
|
348
|
+
|
|
349
|
+
**A fence plus a tail is not one code block.** `parseBlock()` used to call anything that
|
|
350
|
+
*starts* with a fence a code block, so a message that was one ` ```text ` fence **plus** words
|
|
351
|
+
after it was typed as code; re-pasting then re-serialized the tail *inside* the fence. The fix
|
|
352
|
+
records the whole-fence span during detection (`wholeFence()` — the paste is a block only when
|
|
353
|
+
the fence is the entire paste, optionally surrounded by whitespace) and, when a paste starts
|
|
354
|
+
with a fence but carries a tail, takes the block type from the opening fence's own info string:
|
|
355
|
+
` ```text ` is a 文本块, a bare/other fence is 代码块, and the tail stays prose outside it.
|
|
356
|
+
|
|
357
|
+
**Chosen fence markers must outgrow their body.** A block whose *content* already contains a
|
|
358
|
+
fence (re-pasting folded output does exactly this) was re-serialized inside a bare ` ``` `, so
|
|
359
|
+
the inner fence closed the outer one and the rest of the block spilled out as prose.
|
|
360
|
+
`serializeBlock()` now picks its outer marker with `fenceMarkerFor()`: the longest consecutive
|
|
361
|
+
backtick run in the content plus one (minimum three), so the wrapper always outgrows anything
|
|
362
|
+
inside it. Round-tripping fixed output is therefore stable.
|
|
363
|
+
|
|
364
|
+
**Fold scope is the bubble, not the run.** DSH splits a sent user message into several
|
|
365
|
+
`plainRun` spans at every `@mention` chip, and a fence can begin in one run and end in another.
|
|
366
|
+
Per-run scanning paired such an outer fence with an *inner* opener and folded a bogus one-line
|
|
367
|
+
card while the rest of the message stayed as raw text. `planFoldRuns(text, runTexts)` (embedded
|
|
368
|
+
copy of the same name in `src/parse.js`; `fenceRanges()` is the shared span finder) plans the
|
|
369
|
+
fences over the runs' text joined back together and reports either `single` (every fence sits
|
|
370
|
+
inside one run — keep the per-run path, which leaves mention chips clickable) or `bubble` (a
|
|
371
|
+
fence straddles runs — hide every run and render the whole message from the bubble-wide plan).
|
|
372
|
+
An isolated/debug black corner badge printed on every scan is gone unless
|
|
373
|
+
`window.__PCB_DEBUG__ === true`.
|