@citisen/litearea 0.1.0
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 -0
- package/README.md +514 -0
- package/README.zh.md +362 -0
- package/dist/grammars.cjs +1228 -0
- package/dist/grammars.cjs.map +1 -0
- package/dist/grammars.js +1213 -0
- package/dist/grammars.js.map +1 -0
- package/dist/index.cjs +3103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +3040 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +3032 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +3010 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.cjs +453 -0
- package/dist/styles.cjs.map +1 -0
- package/dist/styles.css +432 -0
- package/dist/styles.js +447 -0
- package/dist/styles.js.map +1 -0
- package/dist/types/core/complete.d.ts +70 -0
- package/dist/types/core/complete.d.ts.map +1 -0
- package/dist/types/core/format.d.ts +34 -0
- package/dist/types/core/format.d.ts.map +1 -0
- package/dist/types/core/grammar.d.ts +14 -0
- package/dist/types/core/grammar.d.ts.map +1 -0
- package/dist/types/core/hover.d.ts +23 -0
- package/dist/types/core/hover.d.ts.map +1 -0
- package/dist/types/core/index.d.ts +12 -0
- package/dist/types/core/index.d.ts.map +1 -0
- package/dist/types/core/inspect.d.ts +34 -0
- package/dist/types/core/inspect.d.ts.map +1 -0
- package/dist/types/core/rank.d.ts +82 -0
- package/dist/types/core/rank.d.ts.map +1 -0
- package/dist/types/core/scan.d.ts +51 -0
- package/dist/types/core/scan.d.ts.map +1 -0
- package/dist/types/core/segments.d.ts +44 -0
- package/dist/types/core/segments.d.ts.map +1 -0
- package/dist/types/core/text.d.ts +113 -0
- package/dist/types/core/text.d.ts.map +1 -0
- package/dist/types/core/types.d.ts +604 -0
- package/dist/types/core/types.d.ts.map +1 -0
- package/dist/types/core/vocabulary.d.ts +82 -0
- package/dist/types/core/vocabulary.d.ts.map +1 -0
- package/dist/types/dom/create.d.ts +17 -0
- package/dist/types/dom/create.d.ts.map +1 -0
- package/dist/types/dom/editing.d.ts +89 -0
- package/dist/types/dom/editing.d.ts.map +1 -0
- package/dist/types/dom/editor.d.ts +366 -0
- package/dist/types/dom/editor.d.ts.map +1 -0
- package/dist/types/dom/index.d.ts +9 -0
- package/dist/types/dom/index.d.ts.map +1 -0
- package/dist/types/dom/mirror.d.ts +107 -0
- package/dist/types/dom/mirror.d.ts.map +1 -0
- package/dist/types/dom/overlay.d.ts +52 -0
- package/dist/types/dom/overlay.d.ts.map +1 -0
- package/dist/types/dom/popup.d.ts +95 -0
- package/dist/types/dom/popup.d.ts.map +1 -0
- package/dist/types/dom/support.d.ts +41 -0
- package/dist/types/dom/support.d.ts.map +1 -0
- package/dist/types/dom/tooltip.d.ts +39 -0
- package/dist/types/dom/tooltip.d.ts.map +1 -0
- package/dist/types/grammars/dshFont.d.ts +127 -0
- package/dist/types/grammars/dshFont.d.ts.map +1 -0
- package/dist/types/grammars/dshSentry.d.ts +84 -0
- package/dist/types/grammars/dshSentry.d.ts.map +1 -0
- package/dist/types/grammars/index.d.ts +3 -0
- package/dist/types/grammars/index.d.ts.map +1 -0
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/react/index.d.ts +91 -0
- package/dist/types/react/index.d.ts.map +1 -0
- package/dist/types/styles.d.ts +29 -0
- package/dist/types/styles.d.ts.map +1 -0
- package/docs/architecture.md +316 -0
- package/docs/completion.md +320 -0
- package/docs/grammar.md +823 -0
- package/package.json +105 -0
- package/scripts/browser-check.mjs +838 -0
- package/scripts/build-css.mjs +35 -0
- package/scripts/release.mjs +91 -0
- package/scripts/verify-package.mjs +253 -0
- package/src/core/complete.ts +286 -0
- package/src/core/format.ts +71 -0
- package/src/core/grammar.ts +40 -0
- package/src/core/hover.ts +129 -0
- package/src/core/index.ts +98 -0
- package/src/core/inspect.ts +198 -0
- package/src/core/rank.ts +317 -0
- package/src/core/scan.ts +720 -0
- package/src/core/segments.ts +185 -0
- package/src/core/text.ts +238 -0
- package/src/core/types.ts +681 -0
- package/src/core/vocabulary.ts +196 -0
- package/src/dom/create.ts +31 -0
- package/src/dom/editing.ts +213 -0
- package/src/dom/editor.ts +1143 -0
- package/src/dom/index.ts +46 -0
- package/src/dom/mirror.ts +305 -0
- package/src/dom/overlay.ts +106 -0
- package/src/dom/popup.ts +323 -0
- package/src/dom/support.ts +88 -0
- package/src/dom/tooltip.ts +112 -0
- package/src/grammars/dshFont.ts +1004 -0
- package/src/grammars/dshSentry.ts +742 -0
- package/src/grammars/index.ts +57 -0
- package/src/index.ts +122 -0
- package/src/react/index.tsx +248 -0
- package/src/styles.ts +529 -0
package/README.zh.md
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# litearea
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
**一个架在原生 `<textarea>` 上的代码编辑器:能用的撤销/重做、由你自己写规则的高亮、VSCode 形状的补全 —— 且没有任何运行时依赖。**
|
|
6
|
+
|
|
7
|
+
`@citisen/litearea` 是一个库,不是一个插件。它不注册任何界面插槽、不读设置命名空间、也不知道 favicon 是什么。它是一个小引擎:把一段文本和一个 grammar 变成 token、诊断、装饰和候选列表;外加一层很薄的 DOM,把这一切放在一个真实的 textarea 后面。React 绑定是可选的,而且是单独的入口。
|
|
8
|
+
|
|
9
|
+
真正值得说的是它**不做**什么:它不持有文本。文本属于 textarea,由浏览器的编辑管线修改,库只负责读。
|
|
10
|
+
|
|
11
|
+
## 为什么
|
|
12
|
+
|
|
13
|
+
这个库出自两个 DeepSeek Harness 插件 —— `dsh-font` 和 `dsh-sentry`。两个插件各带一门小 DSL,也各自在 textarea 上手搓了一个编辑器。两者坏在了同样的三个地方:
|
|
14
|
+
|
|
15
|
+
- 补全列表像在跟打字的人较劲;
|
|
16
|
+
- 一旦接受了某条建议,Ctrl+Z 就失效了;
|
|
17
|
+
- 在行中间编辑时,光标会跳到末尾。
|
|
18
|
+
|
|
19
|
+
这不是三个 bug,而是一个 bug 的三张脸:文本存在组件状态里,每次按键都被写回 textarea,然后被**读三遍** —— 一遍上色、一遍诊断、一遍出建议 —— 三个互不相干的通道,当然可以互相矛盾。
|
|
20
|
+
|
|
21
|
+
用脚本给 textarea 的 `value` 赋值不是一次编辑。它替换元素的内容,顺带扔掉浏览器的撤销栈,并把选区重置到末尾。就是这一行同时造成了"撤销没了"和"光标乱跳"。第三个症状来自那三遍读取:一个词可能被上色成合法取值,同时补全器认为它不认识,而波浪线指向一个早就移动过的区间。
|
|
22
|
+
|
|
23
|
+
所以这个库在每一个点上都做了相反的选择,而且每一条都是承重的:
|
|
24
|
+
|
|
25
|
+
- **撤销和重做活着,因为文本归 DOM 所有。** 输入框只在构造时被写入一次,那时还监听器都还没有。库代替用户做的每一次修改都走 `document.execCommand('insertText')`,于是浏览器把它记进自己的撤销栈 —— 一次编辑,用浏览器自己的 Ctrl+Z 就能撤。这里刻意**没有**替代 API:合成的 `input` 事件是不可信的,浏览器不会让它走编辑管线;而 `setRangeText` 能改文本,但完全绕过历史记录。
|
|
26
|
+
- **光标不动,因为没有任何东西重写它下面的文本。** 上色层只读输入框,从不写它。唯一会写选区的,是补全自己要求的那几次 `setSelectionRange`。
|
|
27
|
+
- **一段文本只解析一次。** `inspect(text, grammar)` 跑一遍,上色、波浪线、语义标记、候选列表、悬浮提示读的都是同一个值。它们不可能互相漂移,因为根本没有第二个来源。
|
|
28
|
+
|
|
29
|
+
## 安装
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm install @citisen/litearea
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
三个入口加一份样式表:
|
|
36
|
+
|
|
37
|
+
| 导入 | 是什么 |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `@citisen/litearea` | 纯引擎加上 DOM 层(`createEditor`、`LiteArea`) |
|
|
40
|
+
| `@citisen/litearea/react` | React 绑定(`LiteAreaEditor`) |
|
|
41
|
+
| `@citisen/litearea/grammars` | 两个作为范例的 grammar |
|
|
42
|
+
| `@citisen/litearea/styles.css` | 编辑器自己注入的那份样式表,给想用 `<link>` 的宿主 |
|
|
43
|
+
|
|
44
|
+
React 是可选的 peer 依赖(`react >= 18`),也是唯一的 peer 依赖。这个包没有任何运行时依赖。样式表在第一个编辑器创建时注入文档一次,除非传 `injectStyles: false`。
|
|
45
|
+
|
|
46
|
+
## 快速开始
|
|
47
|
+
|
|
48
|
+
原生用法,grammar 短到可以一口气读完:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { createEditor, defineGrammar, defineVocabulary } from '@citisen/litearea'
|
|
52
|
+
|
|
53
|
+
// 一个 vocabulary 一次声明完一组封闭词汇需要的四件事 —— 词表、上色用的
|
|
54
|
+
// scope、非成员拿到的消息、悬浮时显示的文档 —— 所以它们没有机会各自漂移。
|
|
55
|
+
const COLORS = defineVocabulary({
|
|
56
|
+
id: 'color',
|
|
57
|
+
words: ['red', 'green', 'blue'],
|
|
58
|
+
unknownMessage: '"{word}" is not a colour — expected {allowed}.',
|
|
59
|
+
docs: { red: 'The default swatch.' },
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const grammar = defineGrammar({
|
|
63
|
+
id: 'swatch',
|
|
64
|
+
// 每个位置都按顺序试规则,第一个命中的赢。
|
|
65
|
+
rules: [
|
|
66
|
+
{ kind: 'match', scope: 'comment', pattern: /#[^\n]*/ },
|
|
67
|
+
{ kind: 'words', words: COLORS, unknown: {} },
|
|
68
|
+
],
|
|
69
|
+
compose: [
|
|
70
|
+
{
|
|
71
|
+
id: 'color',
|
|
72
|
+
range: (context) => context.word,
|
|
73
|
+
items: () =>
|
|
74
|
+
['red', 'green', 'blue'].map((color) => ({ label: color, kind: 'color' })),
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const editor = createEditor(document.querySelector('#editor')!, {
|
|
80
|
+
grammar,
|
|
81
|
+
value: 'red # a comment',
|
|
82
|
+
placeholder: 'red, green, blue',
|
|
83
|
+
onChange: (value) => {
|
|
84
|
+
// 只报告用户自己的编辑。库自己做的写入 —— 补全,或者下面的 setValue ——
|
|
85
|
+
// 在动手之前就被标记过,不会回传给调用方。
|
|
86
|
+
console.log(value)
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// 宿主主动写入。`true` 表示这是一次可撤销的编辑,Ctrl+Z 能把旧文本拿回来;
|
|
91
|
+
// 默认是直接赋值,会清掉历史记录。
|
|
92
|
+
editor.setValue('green', true)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
React,复用同一个 `grammar` 对象:
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
import * as React from 'react'
|
|
99
|
+
import type { LiteArea } from '@citisen/litearea'
|
|
100
|
+
import { LiteAreaEditor } from '@citisen/litearea/react'
|
|
101
|
+
import { grammar } from './swatch-grammar'
|
|
102
|
+
|
|
103
|
+
export function SwatchEditor() {
|
|
104
|
+
const [text, setText] = React.useState('')
|
|
105
|
+
const editorRef = React.useRef<LiteArea | null>(null)
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
<LiteAreaEditor
|
|
110
|
+
grammar={grammar}
|
|
111
|
+
defaultValue="red # a comment"
|
|
112
|
+
onChange={setText}
|
|
113
|
+
sizing={{ minRows: 2, maxRows: 10 }}
|
|
114
|
+
editorRef={(editor) => {
|
|
115
|
+
editorRef.current = editor ?? null
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
<button type="button" onClick={() => editorRef.current?.undo()}>
|
|
119
|
+
Undo
|
|
120
|
+
</button>
|
|
121
|
+
<pre>{text}</pre>
|
|
122
|
+
</>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`LiteAreaEditor` 只渲染一个空 `div`,把命令式的编辑器挂进去。它**不**让文本经过 React,原因见[为什么](#为什么):那正是毁掉撤销的做法。初始文本用 `defaultValue`;`value` 是给"宿主想写入文本"的场景(重置按钮、切换文档)准备的,它走编辑管线所以仍然可以撤销,而且它不会触发 `onChange` —— 调用方自己知道刚写了什么。
|
|
128
|
+
|
|
129
|
+
## 它做了什么
|
|
130
|
+
|
|
131
|
+
| 能力 | 背后的机制 |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| 撤销与重做 | 输入框只在构造时写入一次;库的每次修改都走 `execCommand('insertText')`,由浏览器自己的历史记录记下来 |
|
|
134
|
+
| 光标不跳 | 没有任何东西重写光标下的文本;唯一写选区的地方是补全要求的那几次 |
|
|
135
|
+
| 自定义高亮 | `rules` 给文本刷 scope;一个 scope 变成类名 `litearea-scope-<scope>`,长什么样由样式表决定。核心不含任何语法 |
|
|
136
|
+
| 补全 | 每次过滤都从光标重新计算替换区间、分档的模糊排序、文档面板、提交字符,以及不会让输入框失焦的鼠标点选 |
|
|
137
|
+
| 诊断 | vocabulary 的拒绝、声明式 `checks`、`validate` 钩子,合并去重后按四种严重级别画波浪线 |
|
|
138
|
+
| 悬浮提示 | `resolveHover`:诊断优先于一切;否则 decoration 的标题会和 grammar 自己的 `describe` 一起显示 |
|
|
139
|
+
| 语义标记 | `decorate` 返回的区间刻意不是 token,画成 `litearea-dec-<kind>`,重算时不需要重新词法分析 |
|
|
140
|
+
| 自动高度 | 量的是离屏 mirror,不是活的输入框;写回高度和 overflow,并把量到的滚动条宽度发布给上色层 |
|
|
141
|
+
| 一段文本一次解析 | `inspect` 一次性产出 token、诊断、装饰和分析结果,并按文本缓存;文本和分析都没变时直接跳过重绘 |
|
|
142
|
+
| 键盘与无障碍 | `role="combobox"`、`aria-expanded`、`aria-activedescendant`、`aria-invalid`、`aria-label`,以及带真实行 id 的 listbox |
|
|
143
|
+
| 体积 | 没有运行时依赖,没有 CodeMirror,没有 Monaco,引擎里没有虚拟 DOM |
|
|
144
|
+
|
|
145
|
+
## 写一个 grammar
|
|
146
|
+
|
|
147
|
+
一个 grammar 就是一个对象。下面这门语言描述一张很小的表单 schema:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
# the signup form
|
|
151
|
+
form signup
|
|
152
|
+
text email required
|
|
153
|
+
text password secret "at least 12 characters"
|
|
154
|
+
number age optional
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { defineGrammar, defineVocabulary } from '@citisen/litearea'
|
|
159
|
+
|
|
160
|
+
const FIELD_TYPES = ['text', 'number', 'bool'] as const
|
|
161
|
+
const OPTION_WORDS = ['required', 'optional', 'secret'] as const
|
|
162
|
+
|
|
163
|
+
const TYPES = defineVocabulary({
|
|
164
|
+
id: 'field-type',
|
|
165
|
+
words: FIELD_TYPES,
|
|
166
|
+
scope: 'field.type',
|
|
167
|
+
unknownMessage: '"{word}" is not a field type — expected {allowed}.',
|
|
168
|
+
docs: {
|
|
169
|
+
text: { detail: 'one line of text', body: 'The only type that can be `secret`.' },
|
|
170
|
+
number: { detail: 'a number' },
|
|
171
|
+
bool: { detail: 'yes or no' },
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const OPTIONS = defineVocabulary({
|
|
176
|
+
id: 'option',
|
|
177
|
+
words: OPTION_WORDS,
|
|
178
|
+
scope: 'option',
|
|
179
|
+
docs: {
|
|
180
|
+
required: { detail: 'cannot be left empty' },
|
|
181
|
+
optional: { detail: 'may be left empty' },
|
|
182
|
+
secret: { detail: 'never shown again' },
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
export const formSchema = defineGrammar({
|
|
187
|
+
id: 'form-schema',
|
|
188
|
+
name: 'form schema',
|
|
189
|
+
// 名字里可以有连字符,所以 `email-address` 是**一个**词:补全替换整个名字,
|
|
190
|
+
// 双击也会选中整个名字。
|
|
191
|
+
wordChars: /[\p{L}\p{N}_-]/u,
|
|
192
|
+
rules: [
|
|
193
|
+
{ kind: 'match', scope: 'comment', pattern: /#[^\n]*/ },
|
|
194
|
+
{ kind: 'match', scope: 'keyword', pattern: /form/, when: { prevNot: '\\w' } },
|
|
195
|
+
{
|
|
196
|
+
kind: 'match',
|
|
197
|
+
scope: 'form.name',
|
|
198
|
+
pattern: /[A-Za-z][\w-]*/,
|
|
199
|
+
when: { after: ['keyword'] },
|
|
200
|
+
},
|
|
201
|
+
// 一行以字段类型开头;行首出现别的东西会被报出来,而不是安静地变成普通文本。
|
|
202
|
+
{ kind: 'words', words: TYPES, when: { firstOnLine: true }, unknown: {} },
|
|
203
|
+
{ kind: 'words', words: OPTIONS },
|
|
204
|
+
{ kind: 'match', scope: 'name', pattern: /[A-Za-z][\w-]*/, when: { after: ['field.type'] } },
|
|
205
|
+
{ kind: 'region', scope: 'note', begin: /"/, end: /"/, unclosed: { severity: 'warning' } },
|
|
206
|
+
{ kind: 'match', scope: 'invalid', pattern: /\S+/ },
|
|
207
|
+
],
|
|
208
|
+
fallbackScope: 'text',
|
|
209
|
+
compose: [
|
|
210
|
+
{
|
|
211
|
+
id: 'field-type',
|
|
212
|
+
// 用 `firstWord` 而不是 `firstOnLine`:列表要在第一个词拼写的过程中一直有效,
|
|
213
|
+
// 而不是只在整行还是空的时候有效。
|
|
214
|
+
when: (context) => context.firstWord,
|
|
215
|
+
range: (context) => context.word,
|
|
216
|
+
items: () =>
|
|
217
|
+
FIELD_TYPES.map((type) => ({
|
|
218
|
+
label: type,
|
|
219
|
+
append: ' ',
|
|
220
|
+
kind: 'type',
|
|
221
|
+
detail: TYPES.entryFor(type)?.detail,
|
|
222
|
+
documentation: TYPES.entryFor(type)?.body,
|
|
223
|
+
})),
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: 'option',
|
|
227
|
+
// 选项跟在名字后面,而名字本身可能只写了一半。
|
|
228
|
+
when: (context) =>
|
|
229
|
+
context.tokens.some(
|
|
230
|
+
(token) =>
|
|
231
|
+
token.line === context.line.number &&
|
|
232
|
+
token.scope === 'name' &&
|
|
233
|
+
token.to <= context.caret,
|
|
234
|
+
),
|
|
235
|
+
range: (context) => context.word,
|
|
236
|
+
items: () =>
|
|
237
|
+
OPTION_WORDS.map((word) => ({
|
|
238
|
+
label: word,
|
|
239
|
+
kind: 'option',
|
|
240
|
+
detail: OPTIONS.entryFor(word)?.detail,
|
|
241
|
+
})),
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
describe: (context) => {
|
|
245
|
+
const token = context.token
|
|
246
|
+
if (token === undefined) return undefined
|
|
247
|
+
if (token.scope === 'note') return { title: 'note', body: 'Shown under the field.' }
|
|
248
|
+
const entry =
|
|
249
|
+
token.scope === 'field.type' ? TYPES.entryFor(token.text) : OPTIONS.entryFor(token.text)
|
|
250
|
+
return entry === undefined ? undefined : { title: token.text, body: entry.body }
|
|
251
|
+
},
|
|
252
|
+
})
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
整个语言就是这些:八条规则、两个 vocabulary、两个补全来源和一个悬浮说明。`TYPES` 走 vocabulary,所以放错位置的词会得到一条消息,而不是安静地变成普通文本;`OPTIONS` 是不拒绝任何东西的普通规则,于是排在它下面的规则仍然有机会处理自己不认识的词。
|
|
256
|
+
|
|
257
|
+
完整参考 —— 每种规则的全部字段、优先级、诊断词汇,以及在同一门语言上补出 `analyze`、`checks`、`validate` 的端到端走查 —— 在 [docs/grammar.md](docs/grammar.md)。
|
|
258
|
+
|
|
259
|
+
## 两个参考 grammar
|
|
260
|
+
|
|
261
|
+
核心不含语法:没有内置语言、没有可以 switch 的语言标识,`src/core/` 里没有任何代码知道字体栈是什么。`@citisen/litearea/grammars` 里的两个 grammar 是为了**验证**这个说法,而不是仅仅声明它。两门语言都是真实的 DSL,来自这个库所出的那两个插件;不想重打一遍已有语言的宿主可以直接导入:
|
|
262
|
+
|
|
263
|
+
| Grammar | 语言 | 用到了什么 |
|
|
264
|
+
| --- | --- | --- |
|
|
265
|
+
| `dshSentryStyleGrammar()` | dsh-sentry 的外观文档:一个会话状态一行,后面跟位置取值或 `key=value` | 行首词汇表加拒绝、一次同时填槽位并记录问题的分析、一条声明式 `check`、两个用 `sortText` 的补全来源、来自 vocabulary 文档的悬浮说明 |
|
|
266
|
+
| `dshFontQueryGrammar()` | dsh-font 的字体查询:一条 CSS font-family 列表,字重写在它所属的字族旁边 | 词法规则里处理引号、读分析结果的 `scope` 函数、从宿主已安装目录解析出来的动态 vocabulary、多词短语、**插到前面**的补全、以及一个语义 decoration |
|
|
267
|
+
|
|
268
|
+
两个都不是内置的。`src/core/` 不导入它们,没有任何选项能打开它们,不带 grammar 构造出来的编辑器一点语法都没有。
|
|
269
|
+
|
|
270
|
+
`dshSentryStyleGrammar` 里有两个决定值得单独说明,因为它们都是拿 grammar 和被编辑的解析器对照之后发现的:
|
|
271
|
+
|
|
272
|
+
- **它刻意比宿主解析器更严格。** 插件里的 `parseStyle` 对已知选项不做取值检查:它把 `shape=bogus` 原样写进规则,之后由 `resolveLook` 悄悄换成出厂默认值,于是拼错的表现只是"图标怎么都不变"。这个 grammar 会把它报出来 —— 报成 warning 而不是 error,因为文档仍然能用,只是它说的不是它想说的。
|
|
273
|
+
- **它不接受 `fallback` 作为一个状态。** 插件自己的模块注释里写着 `fallback none`,但 `STYLE_STATES` 只有那四个状态,`fallback` 是内部从 `STYLE_FALLBACK_LOOK` 推导出来的,从来就没有被解析过。那句注释是过期的,把它照抄进 grammar,只会让编辑器和它服务的解析器互相矛盾。
|
|
274
|
+
|
|
275
|
+
## 自动高度
|
|
276
|
+
|
|
277
|
+
五个选项,各管一件事:
|
|
278
|
+
|
|
279
|
+
| 选项 | 默认 | 作用 |
|
|
280
|
+
| --- | --- | --- |
|
|
281
|
+
| `autoGrow` | `true` | 高度跟随内容。设为 `false` 时输入框拿到 `resize: vertical`,高度归宿主管 |
|
|
282
|
+
| `minRows` | `1` | 至少显示几行。它写进 textarea 原生的 `rows` 属性,所以第一帧就是对的 |
|
|
283
|
+
| `maxRows` | — | 超过几行开始出现滚动条 |
|
|
284
|
+
| `minHeight` | — | 像素高度的下限,和 `minRows` 叠加 |
|
|
285
|
+
| `maxHeight` | — | 像素高度的上限,和 `maxRows` 叠加 |
|
|
286
|
+
|
|
287
|
+
上面这几个选项合起来就是三条行为:
|
|
288
|
+
|
|
289
|
+
- **跟着内容长也跟着内容缩,装得下就没有滚动条。** 量到的高度没超过上限时,这个高度被写进输入框,`overflow-y` 保持 `hidden`。
|
|
290
|
+
- **有了上限就会出现滚动条。** 超过上限后高度固定,`overflow-y` 变成 `auto`。滚动条会压窄文本,所以量到的滚动条宽度被发布成 `--litearea-scrollbar` 并加到上色层自己的 padding 上;否则两边的换行位置会不同,每一种颜色都会从它该在的字符上滑开。
|
|
291
|
+
- **下限由 `minHeight` 或 `minRows` 撑住**:取两者中较大的那个,再加上输入框的纵向 padding 和边框。上限低于下限时会被抬到下限,因为照着上限做会让盒子比宿主被告知的还小。
|
|
292
|
+
|
|
293
|
+
没挂载的输入框根本不会被量:元素还没进文档时没有布局,宽度是 0,量出来的高度会高出好几倍。`createEditor` 在挂载之后**同步**做第一次测量,原因就是为了这个;直接构造 `LiteArea` 的宿主则应该在把 `editor.element` 挂上去之后自己调一次 `refresh()`。
|
|
294
|
+
|
|
295
|
+
## 键盘
|
|
296
|
+
|
|
297
|
+
编辑器会拦截的全部按键,以及它刻意不碰的那些:
|
|
298
|
+
|
|
299
|
+
| 按键 | 作用 |
|
|
300
|
+
| --- | --- |
|
|
301
|
+
| `ArrowDown` / `ArrowUp` | 上下移动当前行,到头就停,不环绕 |
|
|
302
|
+
| `PageDown` / `PageUp` | 移动八行 |
|
|
303
|
+
| `Enter` | 接受当前行 |
|
|
304
|
+
| `Tab` | 接受当前行 |
|
|
305
|
+
| `Escape` | 关掉列表;列表没开时收起悬浮提示 |
|
|
306
|
+
| `Ctrl+Space` / `Cmd+Space` | 打开列表;已经打开时把它关掉 |
|
|
307
|
+
| 某一行的 `commitCharacters` | 接受当前行并把刚敲的字符写在后面,这样这个按键不会被吞掉;随后列表关闭 |
|
|
308
|
+
| `Ctrl+Z`、`Ctrl+Shift+Z`、`Ctrl+Y` | **不拦截。** 这些是浏览器自己在输入框上的撤销重做,也正是"非受控"的意义所在 |
|
|
309
|
+
| `Shift+Arrow`、`Home`、`End` | 不拦截。它们移动光标但不产生 `input` 事件,所以编辑器只是把光标已经走出去的那个列表关掉 |
|
|
310
|
+
|
|
311
|
+
## 主题
|
|
312
|
+
|
|
313
|
+
一个 scope 变成一个类名(`litearea-scope-value-color`),一个 decoration kind 变成一个类名(`litearea-dec-effective`),一个严重级别变成一个类名(`litearea-diag-error`)。点和其它标点会折成连字符,所以 `value.color` 直接写 `litearea-scope-value-color`,永远不需要反斜杠转义。
|
|
314
|
+
|
|
315
|
+
颜色、间距和字号来自挂在容器上的自定义属性:
|
|
316
|
+
|
|
317
|
+
| 属性 | 默认值 |
|
|
318
|
+
| --- | --- |
|
|
319
|
+
| `--litearea-font` | 一条等宽字体栈,从 `ui-monospace` 开始 |
|
|
320
|
+
| `--litearea-font-size` | `13px` |
|
|
321
|
+
| `--litearea-line-height` | `20px` |
|
|
322
|
+
| `--litearea-padding-block` / `--litearea-padding-inline` | `6px` / `10px` |
|
|
323
|
+
| `--litearea-radius` | `8px` |
|
|
324
|
+
| `--litearea-fg` / `--litearea-fg-dim` / `--litearea-fg-strong` | `#1f2328` / `#6b7280` / `#111827` |
|
|
325
|
+
| `--litearea-bg` / `--litearea-bg-raised` | `#ffffff` |
|
|
326
|
+
| `--litearea-border` / `--litearea-border-focus` | `#d8dbe0` / `#4d6bfe` |
|
|
327
|
+
| `--litearea-accent` / `--litearea-accent-soft` / `--litearea-selection` | `#4d6bfe` 以及两个带透明度的变体 |
|
|
328
|
+
| `--litearea-error` / `--litearea-warning` / `--litearea-info` / `--litearea-hint` | 四种严重级别的颜色 |
|
|
329
|
+
| `--litearea-shadow` | 浮层的阴影 |
|
|
330
|
+
| `--litearea-scope-*` | 两个参考 grammar 用到的每个 scope 的颜色 |
|
|
331
|
+
|
|
332
|
+
深色方案由 `prefers-color-scheme` 自动应用。
|
|
333
|
+
|
|
334
|
+
```css
|
|
335
|
+
.myEditor .litearea {
|
|
336
|
+
--litearea-font: "IBM Plex Mono", ui-monospace, monospace;
|
|
337
|
+
--litearea-font-size: 12px;
|
|
338
|
+
--litearea-line-height: 18px;
|
|
339
|
+
--litearea-scope-comment: #8b919b;
|
|
340
|
+
--litearea-scope-value-color: #0f766e;
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
改这些属性是安全的,因为上色层、输入框和 mirror 读的是同一组值。但只给上色层单独加一条文本属性就不安全了:上色层只允许改颜色、背景和 `text-decoration`,不能碰任何会移动字形的东西。`--litearea-scrollbar` 是编辑器写的,不要自己去设。
|
|
345
|
+
|
|
346
|
+
## 限制与诚实的说明
|
|
347
|
+
|
|
348
|
+
- **`document.execCommand` 已被废弃,而且没有替代品。** 它同时也是唯一一种在保留浏览器撤销栈的前提下修改 textarea 取值的办法。没有它的环境里,编辑靠 `setRangeText` 仍然会落地,只是历史记录拿不到 —— `canEditThroughPipeline()` 会告诉你身处哪种环境,`EditOutcome` 会告诉你某一次编辑是 `'pipeline'`、`'direct'` 还是 `'unchanged'`。
|
|
349
|
+
- **上色层的排版被固定成一种等宽字体,并且关掉了连字。** 连字在上色层画一个字形,而输入框画两个,于是它之后每个字符都会被画到错的位置。
|
|
350
|
+
- **`onChange` 只报告用户自己的编辑。** 库做的每一次写入 —— 补全,或者两种模式下的 `setValue` —— 动手前都会先被标记成自己的写入,所以调用方不会收到自己刚写进去的内容。React 的 `value` prop 也走 `setValue`,同样不会触发它。
|
|
351
|
+
- **`onDiagnostics` 在编辑器挂载时会先触发一次**,哪怕文档干干净净;之后只在问题列表真的变了时才触发 —— 比较的是位置、code 和 message。等待"被告知列表已清空"的宿主因此不会永远等下去。
|
|
352
|
+
- **在 React 绑定里,`sizing`、`completion`、`hover`、`decorations`、`injectStyles`、`styleNonce` 只在编辑器挂载时读取一次。** 之后再改这些只会重渲染外层容器。每次渲染都会重新读的只有 `grammar`、`value` 和 `readOnly`;其中只有 grammar 可以放心地每次渲染都重建(`refresh()` 会重新解析它,而不重建元素,这也是换语言时撤销历史能活下来的原因)。
|
|
353
|
+
- **上色层只能改颜色、背景和 text-decoration。** 任何会改变字符前进量的东西 —— 换字体、改字重、字距、字体特性 —— 都会让上色从它所属的字符上滑开,而且每个字符滑开的量都不一样。
|
|
354
|
+
- **每来一段新文本,`inspect` 都会完整扫一遍。** 没有增量重新词法分析,所以很大的文档每敲一个键都要付一整趟的开销。文本和分析都没变时会跳过重绘,这也是移动光标和悬浮很便宜的原因。
|
|
355
|
+
- **悬浮需要一个光标命中测试。** 它会问 `caretPositionFromPoint`/`caretRangeFromPoint` 指针落在哪个偏移上,`hasCaretHitTest()` 会告诉你环境里有没有。没有的话,提示就永远不会出现。
|
|
356
|
+
- **这不是一个完整编辑器。** 没有行号、没有搜索、没有多光标、没有括号匹配、没有折叠、没有 snippet,也没有撤销按钮 —— 浏览器的历史记录就是撤销栈,`editor.undo()` 和 `editor.redo()` 只是它上面很薄的一层,而且只能报告"这次调用是否可能",无法报告"是否真的撤销了什么"。
|
|
357
|
+
- **悬浮提示和文档面板都是纯文本。** 不渲染 markdown,也不渲染 HTML:一个叫 `<b>` 的字体族就显示成 `<b>`。
|
|
358
|
+
- **没有 `'commit'` 这个补全触发类型。** 提交字符会接受当前行、写入那个字符并关掉列表;之后再敲什么都是普通的 `'auto'`。`CompletionTrigger` 只有 `'auto' | 'explicit'`。
|
|
359
|
+
|
|
360
|
+
## 许可证
|
|
361
|
+
|
|
362
|
+
MIT
|