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/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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|