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/src/i18n.js CHANGED
@@ -1,140 +1,140 @@
1
- /**
2
- * dsh-paste-code-block — locale dictionaries and label logic (pure, no DOM).
3
- *
4
- * Canonical source for the user-visible names of pasted blocks ("代码块 1"
5
- * vs "Code #1", etc.). The browser half (`client.js`) embeds its own
6
- * copy of this logic because DSH's client module loader resolves *package
7
- * names*, not relative ESM files; this module is the single place where that
8
- * logic is unit-tested. **Keep `src/i18n.js` and the embedded copy in
9
- * `src/client.js` in sync.**
10
- *
11
- * Labels render through the DSH locale service (`ctx.locale`), whose
12
- * `t(key, params)` lookup walks the active language's fallback chain, so the
13
- * dictionaries below are the whole story: identical key sets in zh and en,
14
- * `{name}` placeholders for interpolation.
15
- */
16
-
17
- /** Dictionary namespace owned by this plugin. */
18
- export const NS = 'paste-code-block'
19
-
20
- /** Simplified Chinese dictionary (the key-set source of truth). */
21
- export const zh = {
22
- 'block.code': '代码块 {n}',
23
- 'block.text': '文本块 {n}',
24
- 'detail.aria': '代码块详情',
25
- 'slot.label': '粘贴代码块详情',
26
- 'lines.one': '{count} 行',
27
- 'lines.other': '{count} 行',
28
- 'expand': '展开',
29
- 'collapse': '折叠',
30
- 'copy': '复制',
31
- 'copied': '已复制',
32
- 'hide': '收起',
33
- 'remove': '移除',
34
- 'edit.aria': '编辑代码块内容',
35
- 'error.stale': '代码块已失效,请重新粘贴',
36
- 'error.remove': '无法删除该块,请重试',
37
- 'fold.code': '代码块',
38
- 'fold.text': '文本块',
39
- 'fold.aria': '展开或折叠该块',
40
- }
41
-
42
- /** English dictionary, key-identical to the Chinese source of truth. */
43
- export const en = {
44
- 'block.code': 'Code #{n}',
45
- 'block.text': 'Text #{n}',
46
- 'detail.aria': 'Block details',
47
- 'slot.label': 'Pasted block details',
48
- 'lines.one': '{count} line',
49
- 'lines.other': '{count} lines',
50
- 'expand': 'Expand',
51
- 'collapse': 'Collapse',
52
- 'copy': 'Copy',
53
- 'copied': 'Copied',
54
- 'hide': 'Hide',
55
- 'remove': 'Remove',
56
- 'edit.aria': 'Edit block content',
57
- 'error.stale': 'This block is no longer valid — paste it again',
58
- 'error.remove': 'Could not remove the block — please retry',
59
- 'fold.code': 'Code',
60
- 'fold.text': 'Text',
61
- 'fold.aria': 'Expand or collapse this block',
62
- }
63
-
64
- /** Shipped dictionaries keyed by locale id (mirrors DSH's built-in zh/en set). */
65
- export const dictionaries = { zh, en }
66
-
67
- /**
68
- * Pre-0.1.3 chip labels ("复制代码块N" / "Code block #N" …). No longer
69
- * rendered, but still recognized by `parseBlockLabel` so chips inserted by an
70
- * older bundle keep resolving to their block after an in-place page refresh.
71
- */
72
- export const legacyLabels = {
73
- zh: { 'block.code': '复制代码块{n}', 'block.text': '复制文本块{n}' },
74
- en: { 'block.code': 'Code block #{n}', 'block.text': 'Text block #{n}' },
75
- }
76
-
77
- /**
78
- * Standalone translator used only when no DSH locale service is present
79
- * (defensive fallback outside the standard web composition). English is the
80
- * fallback language, matching DSH's own `FALLBACK_LOCALE`.
81
- */
82
- export function createT(locale) {
83
- const dict = dictionaries[locale] || dictionaries.en
84
- return (key, params) => {
85
- let text = dict[key] != null ? dict[key] : (dictionaries.en[key] != null ? dictionaries.en[key] : key)
86
- if (params) text = text.replace(/\{(\w+)\}/g, (m, name) => (name in params ? String(params[name]) : m))
87
- return text
88
- }
89
- }
90
-
91
- /** Guess a locale id from the browser's language list (fallback path only). */
92
- export function detectBrowserLocale() {
93
- try {
94
- const langs = [...(navigator.languages || []), navigator.language].filter(Boolean).map((l) => String(l).toLowerCase())
95
- return langs.some((l) => l.startsWith('zh')) ? 'zh' : 'en'
96
- } catch (e) {
97
- return 'en'
98
- }
99
- }
100
-
101
- /** Build a block chip label from a translator: 代码块 2 / Code #2. */
102
- export function blockLabel(t, type, n) {
103
- return t(type === 'code' ? 'block.code' : 'block.text', { n })
104
- }
105
-
106
- /**
107
- * Build the title of a SENT-message fold card: `python · 128 行` /
108
- * `Code · 128 lines`. `lang` (the fence info string) names code cards when
109
- * present; text cards and untagged code cards fall back to the localized
110
- * type name. Line count always renders, so users can gauge size while the
111
- * card is collapsed.
112
- */
113
- export function foldTitle(t, type, lang, lines) {
114
- const meta = t(lines === 1 ? 'lines.one' : 'lines.other', { count: lines })
115
- const name = type === 'code' ? (lang || t('fold.code')) : t('fold.text')
116
- return `${name} · ${meta}`
117
- }
118
-
119
- /**
120
- * Recognize a rendered block label in ANY shipped locale (or a pre-0.1.3
121
- * legacy locale) and recover its `{ type, n }` identity. This lets a chip
122
- * click, an ✕ delete, and the locale-switch retitling find the right block
123
- * even when the chip was inserted under a different language than the one
124
- * currently active. Returns null for anything that is not a block label.
125
- */
126
- export function parseBlockLabel(text) {
127
- if (!text) return null
128
- for (const dict of [dictionaries.zh, dictionaries.en, legacyLabels.zh, legacyLabels.en]) {
129
- for (const key of ['block.code', 'block.text']) {
130
- const pattern = new RegExp(
131
- '^' +
132
- dict[key].replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\{n\\\}/g, '(\\d+)') +
133
- '$',
134
- )
135
- const m = pattern.exec(text)
136
- if (m) return { type: key === 'block.code' ? 'code' : 'text', n: Number(m[1]) }
137
- }
138
- }
139
- return null
140
- }
1
+ /**
2
+ * dsh-paste-code-block — locale dictionaries and label logic (pure, no DOM).
3
+ *
4
+ * Canonical source for the user-visible names of pasted blocks ("代码块 1"
5
+ * vs "Code #1", etc.). The browser half (`client.js`) embeds its own
6
+ * copy of this logic because DSH's client module loader resolves *package
7
+ * names*, not relative ESM files; this module is the single place where that
8
+ * logic is unit-tested. **Keep `src/i18n.js` and the embedded copy in
9
+ * `src/client.js` in sync.**
10
+ *
11
+ * Labels render through the DSH locale service (`ctx.locale`), whose
12
+ * `t(key, params)` lookup walks the active language's fallback chain, so the
13
+ * dictionaries below are the whole story: identical key sets in zh and en,
14
+ * `{name}` placeholders for interpolation.
15
+ */
16
+
17
+ /** Dictionary namespace owned by this plugin. */
18
+ export const NS = 'paste-code-block'
19
+
20
+ /** Simplified Chinese dictionary (the key-set source of truth). */
21
+ export const zh = {
22
+ 'block.code': '代码块 {n}',
23
+ 'block.text': '文本块 {n}',
24
+ 'detail.aria': '代码块详情',
25
+ 'slot.label': '粘贴代码块详情',
26
+ 'lines.one': '{count} 行',
27
+ 'lines.other': '{count} 行',
28
+ 'expand': '展开',
29
+ 'collapse': '折叠',
30
+ 'copy': '复制',
31
+ 'copied': '已复制',
32
+ 'hide': '收起',
33
+ 'remove': '移除',
34
+ 'edit.aria': '编辑代码块内容',
35
+ 'error.stale': '代码块已失效,请重新粘贴',
36
+ 'error.remove': '无法删除该块,请重试',
37
+ 'fold.code': '代码块',
38
+ 'fold.text': '文本块',
39
+ 'fold.aria': '展开或折叠该块',
40
+ }
41
+
42
+ /** English dictionary, key-identical to the Chinese source of truth. */
43
+ export const en = {
44
+ 'block.code': 'Code #{n}',
45
+ 'block.text': 'Text #{n}',
46
+ 'detail.aria': 'Block details',
47
+ 'slot.label': 'Pasted block details',
48
+ 'lines.one': '{count} line',
49
+ 'lines.other': '{count} lines',
50
+ 'expand': 'Expand',
51
+ 'collapse': 'Collapse',
52
+ 'copy': 'Copy',
53
+ 'copied': 'Copied',
54
+ 'hide': 'Hide',
55
+ 'remove': 'Remove',
56
+ 'edit.aria': 'Edit block content',
57
+ 'error.stale': 'This block is no longer valid — paste it again',
58
+ 'error.remove': 'Could not remove the block — please retry',
59
+ 'fold.code': 'Code',
60
+ 'fold.text': 'Text',
61
+ 'fold.aria': 'Expand or collapse this block',
62
+ }
63
+
64
+ /** Shipped dictionaries keyed by locale id (mirrors DSH's built-in zh/en set). */
65
+ export const dictionaries = { zh, en }
66
+
67
+ /**
68
+ * Pre-0.1.3 chip labels ("复制代码块N" / "Code block #N" …). No longer
69
+ * rendered, but still recognized by `parseBlockLabel` so chips inserted by an
70
+ * older bundle keep resolving to their block after an in-place page refresh.
71
+ */
72
+ export const legacyLabels = {
73
+ zh: { 'block.code': '复制代码块{n}', 'block.text': '复制文本块{n}' },
74
+ en: { 'block.code': 'Code block #{n}', 'block.text': 'Text block #{n}' },
75
+ }
76
+
77
+ /**
78
+ * Standalone translator used only when no DSH locale service is present
79
+ * (defensive fallback outside the standard web composition). English is the
80
+ * fallback language, matching DSH's own `FALLBACK_LOCALE`.
81
+ */
82
+ export function createT(locale) {
83
+ const dict = dictionaries[locale] || dictionaries.en
84
+ return (key, params) => {
85
+ let text = dict[key] != null ? dict[key] : (dictionaries.en[key] != null ? dictionaries.en[key] : key)
86
+ if (params) text = text.replace(/\{(\w+)\}/g, (m, name) => (name in params ? String(params[name]) : m))
87
+ return text
88
+ }
89
+ }
90
+
91
+ /** Guess a locale id from the browser's language list (fallback path only). */
92
+ export function detectBrowserLocale() {
93
+ try {
94
+ const langs = [...(navigator.languages || []), navigator.language].filter(Boolean).map((l) => String(l).toLowerCase())
95
+ return langs.some((l) => l.startsWith('zh')) ? 'zh' : 'en'
96
+ } catch (e) {
97
+ return 'en'
98
+ }
99
+ }
100
+
101
+ /** Build a block chip label from a translator: 代码块 2 / Code #2. */
102
+ export function blockLabel(t, type, n) {
103
+ return t(type === 'code' ? 'block.code' : 'block.text', { n })
104
+ }
105
+
106
+ /**
107
+ * Build the title of a SENT-message fold card: `python · 128 行` /
108
+ * `Code · 128 lines`. `lang` (the fence info string) names code cards when
109
+ * present; text cards and untagged code cards fall back to the localized
110
+ * type name. Line count always renders, so users can gauge size while the
111
+ * card is collapsed.
112
+ */
113
+ export function foldTitle(t, type, lang, lines) {
114
+ const meta = t(lines === 1 ? 'lines.one' : 'lines.other', { count: lines })
115
+ const name = type === 'code' ? (lang || t('fold.code')) : t('fold.text')
116
+ return `${name} · ${meta}`
117
+ }
118
+
119
+ /**
120
+ * Recognize a rendered block label in ANY shipped locale (or a pre-0.1.3
121
+ * legacy locale) and recover its `{ type, n }` identity. This lets a chip
122
+ * click, an ✕ delete, and the locale-switch retitling find the right block
123
+ * even when the chip was inserted under a different language than the one
124
+ * currently active. Returns null for anything that is not a block label.
125
+ */
126
+ export function parseBlockLabel(text) {
127
+ if (!text) return null
128
+ for (const dict of [dictionaries.zh, dictionaries.en, legacyLabels.zh, legacyLabels.en]) {
129
+ for (const key of ['block.code', 'block.text']) {
130
+ const pattern = new RegExp(
131
+ '^' +
132
+ dict[key].replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\{n\\\}/g, '(\\d+)') +
133
+ '$',
134
+ )
135
+ const m = pattern.exec(text)
136
+ if (m) return { type: key === 'block.code' ? 'code' : 'text', n: Number(m[1]) }
137
+ }
138
+ }
139
+ return null
140
+ }
package/src/index.js CHANGED
@@ -1,11 +1,11 @@
1
- /**
2
- * dsh-paste-code-block — host (node) half.
3
- *
4
- * Pure UI plugin: the host body is intentionally empty. The browser half ships
5
- * via exports["./client"], discovered through the package.json `dsh.client`
6
- * declaration and mounted into the Web composer by the host layer.
7
- */
8
- export const name = 'dsh-paste-code-block'
9
-
10
- /** Host plugin body — no host-side behavior. */
11
- export function apply() {}
1
+ /**
2
+ * dsh-paste-code-block — host (node) half.
3
+ *
4
+ * Pure UI plugin: the host body is intentionally empty. The browser half ships
5
+ * via exports["./client"], discovered through the package.json `dsh.client`
6
+ * declaration and mounted into the Web composer by the host layer.
7
+ */
8
+ export const name = 'dsh-paste-code-block'
9
+
10
+ /** Host plugin body — no host-side behavior. */
11
+ export function apply() {}
package/src/parse.js CHANGED
@@ -37,6 +37,26 @@ export function detectLang(text) {
37
37
  return ''
38
38
  }
39
39
 
40
+ /**
41
+ * The single complete fence of `text`, or null when the text is not exactly one
42
+ * fenced block (blank lines around it are tolerated; any real prose is not).
43
+ * Shares splitFencedSegments' pairing rules, so a 4-backtick wrapper around
44
+ * content that itself contains ``` is recognised as one fence, not two.
45
+ */
46
+ export function wholeFence(text) {
47
+ const segs = splitFencedSegments(text)
48
+ let fence = null
49
+ for (const seg of segs) {
50
+ if (seg.kind === 'fence') {
51
+ if (fence) return null
52
+ fence = seg
53
+ } else if (seg.text.trim() !== '') {
54
+ return null
55
+ }
56
+ }
57
+ return fence
58
+ }
59
+
40
60
  /**
41
61
  * Parse pasted text into a block, or null when it is ordinary short prose
42
62
  * that should paste plainly. `isCode` drives both the chip label (registered
@@ -47,12 +67,46 @@ export function parseBlock(raw) {
47
67
  const text = String(raw || '').replace(/^\uFEFF/, '')
48
68
  if (!text) return null
49
69
 
50
- const fm = /^\s*```([\w+-]*)[^\n]*\n([\s\S]*?)\n?\s*```\s*$/.exec(text)
51
- if (fm) {
52
- const lang = (fm[1] || '').trim()
53
- const content = fm[2].replace(/\n+$/, '')
70
+ // (1) A paste that is exactly ONE complete fence — this plugin's own output
71
+ // shape, and what "copy the block back out" produces. The type comes from the
72
+ // fence's info string, so an explicit ```text paste is a TEXT block and
73
+ // round-trips, instead of degrading into a bare code fence on the next send.
74
+ const whole = wholeFence(text)
75
+ if (whole) {
76
+ const content = whole.body.replace(/\n+$/, '')
54
77
  if (!content) return null
55
- return { lang, content, lines: content.split(/\r?\n/), isCode: true }
78
+ const infoLang = (whole.lang || '').toLowerCase()
79
+ return {
80
+ lang: infoLang === 'text' ? '' : whole.lang,
81
+ content,
82
+ lines: content.split(/\r?\n/),
83
+ isCode: infoLang !== 'text',
84
+ }
85
+ }
86
+
87
+ // (2) A paste that OPENS with a fence but carries trailing prose after it —
88
+ // copying a whole previous message (fenced block + the line typed under it).
89
+ // Keep the entire paste as ONE block so its fence pairing survives, and take
90
+ // the type from the leading fence's info string. 0.2.5 fell through to the
91
+ // heuristic branch here: `fenced` forced isCode = true and serializeBlock
92
+ // re-wrapped the already-fenced text in a BARE fence, so the bubble received
93
+ // two nested fences (with the trailing line inside the outer one) even though
94
+ // the chip had said 文本块.
95
+ const segs = splitFencedSegments(text)
96
+ let leadFence = -1
97
+ for (let i = 0; i < segs.length; i += 1) {
98
+ if (segs[i].kind === 'fence') { leadFence = i; break }
99
+ if (segs[i].text.trim() !== '') break
100
+ }
101
+ if (leadFence >= 0) {
102
+ const leadLang = (segs[leadFence].lang || '').toLowerCase()
103
+ const content = text.replace(/\n+$/, '')
104
+ return {
105
+ lang: leadLang === 'text' ? '' : segs[leadFence].lang,
106
+ content,
107
+ lines: content.split(/\r?\n/),
108
+ isCode: leadLang !== 'text',
109
+ }
56
110
  }
57
111
 
58
112
  const multi = /\r?\n/.test(text)
@@ -96,9 +150,25 @@ export function countMarkdownSignals(text) {
96
150
  return n
97
151
  }
98
152
 
153
+ /**
154
+ * Backtick marker long enough to wrap `content` unambiguously: one longer than
155
+ * the longest backtick run inside it (GFM nesting rule). Without this, content
156
+ * that already contains a fence closes the wrapper early and the sent message
157
+ * re-parses as several blocks — the bubble then shows the wall of text plus a
158
+ * card built from whatever fragment happened to look like a fence.
159
+ */
160
+ export function fenceMarkerFor(content) {
161
+ const src = String(content == null ? '' : content)
162
+ let longest = 0
163
+ const runs = src.match(/`+/g)
164
+ if (runs) for (const run of runs) if (run.length > longest) longest = run.length
165
+ return '`'.repeat(Math.max(3, longest + 1))
166
+ }
167
+
99
168
  export function serializeBlock(block) {
100
169
  const lang = block.isCode ? (block.lang || '') : 'text'
101
- return `\n\`\`\`${lang}\n${block.content}\n\`\`\`\n`
170
+ const marker = fenceMarkerFor(block.content)
171
+ return `\n${marker}${lang}\n${block.content}\n${marker}\n`
102
172
  }
103
173
 
104
174
  /**
@@ -163,4 +233,87 @@ export function splitFencedSegments(text) {
163
233
  }
164
234
  flushProse(lines.length)
165
235
  return segments
236
+ }
237
+
238
+ /**
239
+ * Character ranges of every complete fence in `text`, in order, as
240
+ * `{ start, end }` (end exclusive, covering both fence lines). Same pairing
241
+ * rules as splitFencedSegments — index k here is the fence of the k-th fence
242
+ * segment it returns. Used by the sent-message fold scan to tell whether a
243
+ * fence sits inside one rendered run or straddles several.
244
+ */
245
+ export function fenceRanges(text) {
246
+ const src = String(text == null ? '' : text)
247
+ const out = []
248
+ if (!src) return out
249
+ const starts = [0]
250
+ for (let i = 0; i < src.length; i += 1) if (src.charCodeAt(i) === 10) starts.push(i + 1)
251
+ const lineEnd = (n) => (n + 1 < starts.length ? starts[n + 1] - 1 : src.length)
252
+ const lineText = (n) => src.slice(starts[n], lineEnd(n)).replace(/\r$/, '')
253
+ const OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
254
+ let i = 0
255
+ while (i < starts.length) {
256
+ const m = OPEN.exec(lineText(i))
257
+ if (!m) { i += 1; continue }
258
+ const marker = m[1]
259
+ const ch = marker[0]
260
+ const info = m[2].trim()
261
+ if (ch === '`' && info.includes('`')) { i += 1; continue }
262
+ const closeRe = new RegExp('^ {0,3}' + (ch === '`' ? '`' : '~') + '{' + marker.length + ',}[ \\t]*$')
263
+ let j = i + 1
264
+ let closed = false
265
+ while (j < starts.length) {
266
+ if (closeRe.test(lineText(j))) { closed = true; break }
267
+ j += 1
268
+ }
269
+ if (!closed) { i += 1; continue }
270
+ out.push({ start: starts[i], end: starts[j] + lineText(j).length })
271
+ i = j + 1
272
+ }
273
+ return out
274
+ }
275
+
276
+ /**
277
+ * How the sent-message fold scan should fold `text`, given the `runTexts` DSH
278
+ * actually rendered it as. DSH splits a user message into one plain run per
279
+ * mention chip, so a serialized block whose body mentions someone arrives as
280
+ * several sibling runs — with the fence opener in one and its closer in
281
+ * another, which no per-run scan can ever pair up.
282
+ *
283
+ * Returns null when nothing is foldable, else:
284
+ * - { mode: 'single', segments, runs } every fence lies inside ONE run;
285
+ * `runs` lists the run indices to fold, each on its own.
286
+ * - { mode: 'bubble', segments, fences } a fence straddles run boundaries;
287
+ * the caller must rebuild the whole bubble from `segments`, because
288
+ * per-run folding only ever sees a broken half of the fence.
289
+ */
290
+ export function planFoldRuns(text, runTexts) {
291
+ const src = String(text == null ? '' : text)
292
+ const segments = splitFencedSegments(src)
293
+ const fenceSegs = segments.filter((seg) => seg.kind === 'fence')
294
+ const spans = fenceRanges(src)
295
+ if (fenceSegs.length !== spans.length) return null // defensive: keep the two walks in step
296
+ const fences = []
297
+ fenceSegs.forEach((seg, k) => { if (seg.body.trim() !== '') fences.push(spans[k]) })
298
+ if (!fences.length) return null
299
+
300
+ const bounds = []
301
+ let at = 0
302
+ for (const rt of (runTexts || [])) {
303
+ const len = String(rt == null ? '' : rt).length
304
+ bounds.push([at, at + len])
305
+ at += len
306
+ }
307
+ const runOf = (off) => {
308
+ for (let k = 0; k < bounds.length; k += 1) if (off >= bounds[k][0] && off < bounds[k][1]) return k
309
+ return -1
310
+ }
311
+ const runs = []
312
+ for (const f of fences) {
313
+ const a = runOf(f.start)
314
+ const b = runOf(Math.max(f.start, f.end - 1))
315
+ if (a < 0 || a !== b) return { mode: 'bubble', segments, fences }
316
+ if (!runs.includes(a)) runs.push(a)
317
+ }
318
+ return { mode: 'single', segments, runs }
166
319
  }
package/src/projection.js CHANGED
@@ -1,54 +1,54 @@
1
- /**
2
- * Composer-projection geometry.
3
- *
4
- * The input shell publishes two coordinate systems for the same document:
5
- *
6
- * - `draft` (clipboard projection): every chip expands to its `clipboardText`,
7
- * so a chip occupies `occurrence.length` characters.
8
- * - the editor's detect projection (what the scoped edit verbs address): every
9
- * chip collapses to exactly ONE placeholder character (U+FFFC), while plain
10
- * text is identical in both.
11
- *
12
- * Editing verbs (`slash/input-insert-text`, chip insertion) take detect spans,
13
- * so clipboard offsets must be converted before addressing an occurrence.
14
- *
15
- * Pure and dependency-free so the conversion is unit-testable; keep the embedded
16
- * copy in src/client.js in sync (same rule as parse.js/i18n.js/chip-nodes.js).
17
- */
18
-
19
- /**
20
- * Convert one occurrence's clipboard offset into a detect offset.
21
- * @param occurrences - all occurrences of the same projection.
22
- * @param occ - the occurrence to convert.
23
- * @returns the detect offset where the occurrence's placeholder sits.
24
- */
25
- export function detectOffsetOf(occurrences, occ) {
26
- let shift = 0
27
- for (const other of occurrences || []) {
28
- if (other === occ || other.offset >= occ.offset) continue
29
- shift += Math.max(0, (other.length || 1) - 1)
30
- }
31
- return occ.offset - shift
32
- }
33
-
34
- /**
35
- * Detect span of the separating space the shell appends right after a freshly
36
- * inserted chip — or null when the character after that chip is not a space.
37
- *
38
- * `SessionInputShell.insertReference` inserts `[chip, ' ']` whenever the
39
- * character at the pick-time span is not itself a space. Blocks are inserted at
40
- * the end of the draft, so that character can only have come from the insertion
41
- * itself: removing it never touches a space the draft already contained.
42
- *
43
- * @param projection - published input state (`{ draft, occurrences }`).
44
- * @param occ - OUR chip, the one that was just inserted.
45
- * @returns `{ start, end }` in detect coordinates, or null.
46
- */
47
- export function separatorSpaceSpan(projection, occ) {
48
- if (!projection || !occ) return null
49
- const draft = projection.draft || ''
50
- const afterChip = occ.offset + (occ.length || 0)
51
- if (draft.charAt(afterChip) !== ' ') return null
52
- const start = detectOffsetOf(projection.occurrences, occ) + 1
53
- return { start, end: start + 1 }
54
- }
1
+ /**
2
+ * Composer-projection geometry.
3
+ *
4
+ * The input shell publishes two coordinate systems for the same document:
5
+ *
6
+ * - `draft` (clipboard projection): every chip expands to its `clipboardText`,
7
+ * so a chip occupies `occurrence.length` characters.
8
+ * - the editor's detect projection (what the scoped edit verbs address): every
9
+ * chip collapses to exactly ONE placeholder character (U+FFFC), while plain
10
+ * text is identical in both.
11
+ *
12
+ * Editing verbs (`slash/input-insert-text`, chip insertion) take detect spans,
13
+ * so clipboard offsets must be converted before addressing an occurrence.
14
+ *
15
+ * Pure and dependency-free so the conversion is unit-testable; keep the embedded
16
+ * copy in src/client.js in sync (same rule as parse.js/i18n.js/chip-nodes.js).
17
+ */
18
+
19
+ /**
20
+ * Convert one occurrence's clipboard offset into a detect offset.
21
+ * @param occurrences - all occurrences of the same projection.
22
+ * @param occ - the occurrence to convert.
23
+ * @returns the detect offset where the occurrence's placeholder sits.
24
+ */
25
+ export function detectOffsetOf(occurrences, occ) {
26
+ let shift = 0
27
+ for (const other of occurrences || []) {
28
+ if (other === occ || other.offset >= occ.offset) continue
29
+ shift += Math.max(0, (other.length || 1) - 1)
30
+ }
31
+ return occ.offset - shift
32
+ }
33
+
34
+ /**
35
+ * Detect span of the separating space the shell appends right after a freshly
36
+ * inserted chip — or null when the character after that chip is not a space.
37
+ *
38
+ * `SessionInputShell.insertReference` inserts `[chip, ' ']` whenever the
39
+ * character at the pick-time span is not itself a space. Blocks are inserted at
40
+ * the end of the draft, so that character can only have come from the insertion
41
+ * itself: removing it never touches a space the draft already contained.
42
+ *
43
+ * @param projection - published input state (`{ draft, occurrences }`).
44
+ * @param occ - OUR chip, the one that was just inserted.
45
+ * @returns `{ start, end }` in detect coordinates, or null.
46
+ */
47
+ export function separatorSpaceSpan(projection, occ) {
48
+ if (!projection || !occ) return null
49
+ const draft = projection.draft || ''
50
+ const afterChip = occ.offset + (occ.length || 0)
51
+ if (draft.charAt(afterChip) !== ' ') return null
52
+ const start = detectOffsetOf(projection.occurrences, occ) + 1
53
+ return { start, end: start + 1 }
54
+ }