元素

Editor

Editor 是一个代码编辑器:五十多种语言的语法高亮、行号、多光标、折叠、软换行、查找与替换、诊断和装饰,以及代码编辑器的按键(移动和复制行、注释、缩进、输入时自动闭合的括号和引号)。它能打开任意长度的文件:只创建视野中的行,高亮随每次编辑逐行更新,所以长文件滚动和输入起来与短文件一样流畅。输入经由平台的输入系统,所以输入法(拼音、假名、死键)和软键盘在其中的表现与任何文本框相同。

el-editor.tsx
el-editor.tsx运行中
import { Input, mount, signal, Text, View } from "nsh";
import { Editor, type EditorDiagnostic, type EditorRef, type EditorSearchResult } from "nsh/base";
import { desktop, Log, Panel, Segmented, Split, size, ui, wide } from "./palette";

const SAMPLES = {
  rust: `/// The n-th Fibonacci number.
fn fib(n: u64) -> u64 {
    match n {
        0 | 1 => n,
        _ => fib(n - 1) + fib(n - 2),
    }
}

fn main() {
    let unused = 0;
    for n in 0..10 {
        println!("fib({n}) = {}", fib(n));
    }
}
`,
  typescript: `type Shape = { kind: "circle"; r: number } | { kind: "square"; side: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.r ** 2;
    case "square":
      return shape.side ** 2;
  }
}

console.log(area({ kind: "circle", r: 2 }));
`,
  json: `{
  "name": "my-app",
  "entry": "App.tsx",
  "capabilities": ["network"]
}
`,
} as const;

type Language = keyof typeof SAMPLES;

// A problem to mark in each file, as a linter would report it.
const PROBLEMS: Record<Language, EditorDiagnostic[]> = {
  rust: [{ range: { start: { line: 9, column: 8 }, end: { line: 9, column: 14 } }, severity: "warning", message: "unused variable" }],
  typescript: [],
  json: [],
};

// The text is the editor's own (`defaultValue`), an editor per language so
// each keeps its own undo; the app passes what to find (`search`) and reads
// the caret and the matches from the events.
function App() {
  const language = signal<Language>("rust");
  const wrap = signal<"Scroll" | "Wrap">("Scroll");
  const query = signal("");
  const caret = signal("1:1");
  const found = signal<EditorSearchResult>({ count: 0 });
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 4));
  let editor: EditorRef | undefined;

  const button = (label: string, onPress: () => void) => (
    <View
      onPress={onPress}
      role="button"
      activeStyle={{ opacity: 0.6 }}
      style={{ height: size.controlSm, paddingHorizontal: 12, borderRadius: 8, justifyContent: "center", ...ui().raised }}
    >
      <Text style={{ fontSize: 13, fontWeight: "500", color: ui().text }}>{label}</Text>
    </View>
  );

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>editor</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click into the code and type; Alt-click adds a caret, Cmd/Ctrl+D the next match" : "Tap into the code and type"}
      </Text>

      <Split
        grow
        width={640}
        inspector={
          <>
            <Segmented label="language" options={["rust", "typescript", "json"] as const} value={language()} onChange={(v) => language.set(v)} />
            <Segmented label="long lines" options={["Scroll", "Wrap"] as const} value={wrap()} onChange={(v) => wrap.set(v)} />
            <Panel title="find">
              <Input
                value={query()}
                onChange={(v) => query.set(v)}
                onSubmit={() => editor?.run("findNext")}
                submitBehavior="submit"
                placeholder="fib"
                accessibilityLabel="Find"
                autoCapitalize="none"
                autoCorrect={false}
                style={{ height: size.controlSm, fontSize: 13, color: ui().text, placeholderColor: ui().faint }}
              />
              <Text style={{ fontSize: 13, fontVariant: ["tabular-nums"], color: ui().text }}>
                {query() ? (found().error ?? `${found().current == null ? "–" : found().current! + 1} of ${found().count}`) : "–"}
              </Text>
              <View style={{ flexDirection: "row", gap: 8 }}>
                {button("Next", () => editor?.run("findNext"))}
                {button("Undo", () => editor?.run("undo"))}
              </View>
            </Panel>
            <Panel title="caret">
              <Text style={{ fontSize: 13, fontVariant: ["tabular-nums"], color: ui().text }}>{caret()}</Text>
            </Panel>
            <Log title="events" lines={events()} minHeight={96} />
          </>
        }
      >
        <View style={{ height: wide() ? 380 : 300, ...ui().card, borderRadius: 14, overflow: "hidden" }}>
          {() => {
            const lang = language();
            return (
              <Editor
                ref={(r) => (editor = r)}
                defaultValue={SAMPLES[lang]}
                language={lang}
                softWrap={wrap() === "Wrap"}
                search={{ query: query() }}
                diagnostics={PROBLEMS[lang]}
                accessibilityLabel="Code"
                onChange={(v) => log(`onChange: ${v.length} characters`)}
                onSelectionChange={(selections) => {
                  const head = selections[selections.length - 1].head;
                  caret.set(`${head.line + 1}:${head.column + 1}${selections.length > 1 ? ` (${selections.length} carets)` : ""}`);
                }}
                onSearchChange={(result) => found.set(result)}
                onFocus={() => log("onFocus")}
                onBlur={() => log("onBlur")}
                onLineNumberPress={(line) => log(`onLineNumberPress: ${line + 1}`)}
                onContextMenu={(e) => log(`onContextMenu: ${e.position.line + 1}:${e.position.column + 1}`)}
                style={{ flex: 1 }}
              />
            );
          }}
        </View>
      </Split>
    </View>
  );
}

mount(App);

编辑器负责行为,并带有一套由主题令牌构成的默认外观:主题的 font-mono,13pt 字号、20pt 行高,行号用 muted-foreground,有光标的行后面是一条淡淡的 muted 底色,选区是 primary 的浅色,缩进参考线用 border,语法颜色是内置的,与 TextView 的代码块相同,分浅色和深色两套。每个部件都在这些默认值之上接受应用的样式(classes 和 styles)。Rust 用 nsh_base::editor() 创建它,TSX 用 nsh/base 中的 <Editor>。

use nsh_base::editor;
use nsh_core::ui::prelude::*;

const MAIN: &str = "fn main() {\n    println!(\"Hello, world!\");\n}\n";

fn code() -> impl IntoElement {
    let source = signal(SharedString::from(MAIN));
    editor()
        .value(move || Some(source.get()))
        .language("rust")
        .on_change(move |value: &SharedString| source.set(value.clone()))
        .flex_1()
}

编辑器填满应用给它的盒子(flex-1、一个高度),在其中滚动文本:纵向总是可以滚动;行比盒子长时可以横向滚动;开启 softWrap 后长行在编辑器的宽度处换行,不再横向滚动。

#文本

  • 受控:传入 value,并在 onChange 中重新设置它,onChange 报告完整的新文本。应用修改的 value(加载了文件、格式化工具的输出)会作为一次可撤销的编辑替换文本,远离修改处的光标和折叠保持原位。
  • 非受控:传入 defaultValue,从 onChange 或 ref 的 value() 读取文本。

换行符是 \n:传入文本中的 \r\n 和 \r 都变成 \n。

#语言

language 用名称、别名、扩展名或文件名指定语言,不区分大小写:"rust"、"rs"、"typescript"、"tsx"、"main.go"、"Dockerfile"、"Makefile"。它决定高亮、mod+/ 切换的注释、可匹配并参与缩进的括号、输入时自动闭合的符号对、组成单词的字符,以及默认的缩进:四个空格,Web 语言(JavaScript、TypeScript、JSON、HTML、CSS、YAML 等)两个空格,Go 和 Makefile 用制表符。tabSize 和 hardTabs 为所有语言设置缩进。其他名称都是纯文本,除非应用注册了它(应用自己的语言)。

支持的语言:Rust、JavaScript、JSX、TypeScript、TSX、JSON、Python、Go、C、C++、C#、Java、Kotlin、Swift、Objective-C、Objective-C++、Ruby、PHP、Lua、Shell、Fish、PowerShell、SQL、HTML、XML、Vue、Svelte、CSS、SCSS、Sass、Less、Markdown、YAML、TOML、INI、Dockerfile、Makefile、CMake、Dart、Scala、Elixir、Erlang、Haskell、OCaml、F#、Clojure、Zig、Perl、R、Julia、Nix、Terraform、Protocol Buffers、GraphQL 和 Diff。

<Editor value={source()} onChange={(v) => source.set(v)} language={file().name} tabSize={2} softWrap class="flex-1" />

#应用自己的语言

Rust 代码可以让应用的编辑器认识更多语言,并用应用自己的高亮器(tree-sitter 语法、应用自己的解析器)高亮任何语言。register_language 注册一个 EditorLanguage:它的名称、别名、扩展名和文件名,以及编辑规则(注释、括号、输入时自动闭合的符号对、单词字符、缩进),syntax 指定用来高亮它的内置语法。register_highlighter 为一种语言(内置的或注册的)指定应用的高亮器:一个实现 EditorHighlighter 的类型,它收到每次编辑(行、字节和位置,与 tree-sitter 的编辑所需一致),在时间预算内、在 UI 线程之外给出每一行的记号(EditorToken,或经 EditorToken::from_scope 转换的 tree-sitter 捕获名或 TextMate 作用域)。编辑器保存这些记号,在编辑时让它们随文本移动,并为可见的行再次请求,与对内置高亮器的做法一样。language 先按名称、别名、扩展名或路径查找注册的语言,编辑器和差异视图都是如此。

每个引擎保存各自的语言。Rust 应用在挂载编辑器之前在根组件中注册;应用的 crate 在其 UI 集的 setup 中为 TSX 应用注册,运行该应用的每个引擎都会先调用它。

use nsh_base::editor::{EditorLanguage, register_highlighter, register_language};
use nsh_core::ui::prelude::*;

static NIMBLE: EditorLanguage = EditorLanguage::new("Nimble")
    .extensions(&["nb"])
    .line_comment("#")
    .tab_size(2);

#[native_module("my-app/native")]
fn native(registry: &mut Registry) {
    registry.setup(|| {
        register_language(&NIMBLE);
        register_highlighter("nimble", || Box::new(NimbleHighlighter::default()));
    });
}
<Editor value={source()} onChange={(v) => source.set(v)} language="build.nb" class="flex-1" />

#编辑

编辑器的工作方式与代码编辑器相同:

  • 多光标。 Alt 点击(Option 点击)添加一个光标,mod+d 选中选区的下一处出现,mod+shift+l 选中全部出现,添加光标的按键在上一行或下一行添加一个。输入、删除、移动和每个命令都在每个光标处执行;Escape 回到一个。undoSelection(cmd+u、ctrl+u)回到上一次移动之前的选区。
  • 选区扩展。 expandSelection(ctrl+shift+cmd+right、shift+alt+right)按单位扩大选区:单词、字符串的内容和整个字符串、一对括号的内容和这对括号、整行、全文;shrinkSelection 按原路缩回。splitSelectionIntoLines(shift+alt+i)把跨多行的选区拆成每行一个选区。
  • 缩进。 新行保持当前行的缩进,在左括号(Python 的 :、Ruby 的 do 等)之后再加一级;在一对括号之间按 Enter,右括号单独放到下一行。Tab 和 Shift+Tab 缩进和反缩进所选的行;在行首空格中按 Backspace 回到上一个缩进位置。autoIndent 关闭这些行为。
  • 括号和引号 在输入时自动闭合,包住选区,再次输入闭合符号时跳过它(autoClose)。光标处的括号和与它配对的括号会加上边框。
  • 撤销 以一整段输入为单位:一个单词、一次删除、一个命令。移动或点击开始新的一步。
  • 折叠。 后面跟着缩进更深的行的那一行,可以用行号栏中的标记把这些行折叠起来:⌄ 折叠,› 和行后的 ⋯ 展开。光标移入折叠处时,折叠会打开(折叠)。
  • 点击。 使用指针时,单击放置光标,双击选中一个单词,三击选中一行,Shift 点击扩展选区,拖动按点击所选的单位选择。Alt+Shift 拖动(Option+Shift;Linux 上也可用 Control+Alt)选择一个矩形块:每行一个选区,列的范围相同,在较短的行末尾之后也保持这些列。在触摸屏上,轻点放置光标,双击或长按选中手指下的单词,选区两端的手柄随手指拖动移动各自的一端(手柄周围的文本仍可用手指滚动)。
  • 剪贴板。 没有选区时复制和剪切取光标所在的整行,粘贴时这些行会作为整行插入到光标所在行的上方。

readOnly 保持文本不变,同时仍可选择、搜索和复制。

#按键

编辑器的按键在它拥有焦点时生效;其他输入框保留各自的按键。mod 在 macOS 和 iOS 上是 Command,在其他平台上是 Control(nsh/keymap 的按键写法)。

按键(macOS)按键(Windows、Linux)命令
方向键,加 Shift 选择方向键,加 Shift 选择按字符和行移动
alt+left / alt+rightctrl+left / ctrl+right按单词移动
cmd+left / cmd+right、home / end、ctrl+a / ctrl+ehome / end行的第一个非空白字符(再按到行首)/ 行尾
cmd+up / cmd+downctrl+home / ctrl+end文本的开头 / 结尾
pageup / pagedownpageup / pagedown上翻 / 下翻一页
alt+backspace / alt+deletectrl+backspace / ctrl+delete向左 / 向右删除一个单词
cmd+backspace / cmd+delete、ctrl+kctrl+shift+backspace / ctrl+shift+delete删除到行首 / 行尾
cmd+z / cmd+shift+zctrl+z / ctrl+shift+z、ctrl+y撤销 / 重做
cmd+actrl+a全选
cmd+lctrl+l选中整行(再按:连同下一行)
cmd+dctrl+d选中下一处出现
cmd+shift+lctrl+shift+l选中每一处出现
cmd+alt+up / cmd+alt+downctrl+alt+up / ctrl+alt+down在上方 / 下方添加光标
escapeescape回到一个选区
alt+up / alt+downalt+up / alt+down把行上移 / 下移
alt+shift+up / alt+shift+downalt+shift+up / alt+shift+down向上 / 向下复制行
cmd+shift+kctrl+shift+k删除行
cmd+enter / cmd+shift+enterctrl+enter / ctrl+shift+enter在下方 / 上方新建一行
tab / shift+tab、cmd+] / cmd+[tab / shift+tab、ctrl+] / ctrl+[缩进 / 反缩进
cmd+/ctrl+/切换行注释
cmd+shift+\ctrl+shift+\跳到匹配的括号
ctrl+jctrl+j合并行
ctrl+t交换光标两侧的字符
ctrl+alt+left / ctrl+alt+right,加 Shift 选择按单词的一部分移动(camelCase、snake_case)
shift+alt+ashift+alt+a切换块注释
shift+alt+ishift+alt+i把选区拆成每行一个
ctrl+shift+cmd+right / ctrl+shift+cmd+leftshift+alt+right / shift+alt+left扩展 / 收缩选区
cmd+uctrl+u回到之前的选区
cmd+g / cmd+shift+g、f3 / shift+f3f3 / shift+f3查找下一个 / 上一个匹配项
cmd+e查找选中的文本(onFindWithSelection)
cmd+alt+[ / cmd+alt+]ctrl+shift+[ / ctrl+shift+]折叠 / 展开光标所在行
f12f12跳转到光标处的定义(onDefinitionRequest)
cmd+.ctrl+.选区处的代码操作(onCodeActionRequest)
cmd+c / cmd+x / cmd+vctrl+c / ctrl+x / ctrl+v复制 / 剪切 / 粘贴

macOS 和 iOS 还有 Cocoa 的文本按键:ctrl+b / ctrl+f / ctrl+p / ctrl+n 移动,ctrl+h / ctrl+d 删除。

没有默认按键的命令和应用自己的命令,使用应用通过 nsh/keymap 绑定的按键,经由 ref 的 run 执行:

import { keymap } from "nsh/keymap";

let editor!: EditorRef;

keymap.bind({
  "f9": () => editor.run("sortLinesAscending"),
  "mod+shift+u": () => editor.run("transformToUppercase"),
  "mod+alt+0": () => editor.run("foldAll"),
  "mod+alt+j": () => editor.run("unfoldAll"),
});

#查找与替换

search 是要查找的内容:{ query, caseSensitive?, wholeWord?, regex?, inSelection? }。它的匹配项会被高亮,选区所在的那一项更醒目,onSearchChange 报告 { count, current, error }:匹配项的数量、选区所在的是哪一项(从 0 开始),以及正则表达式无效的原因。findNext 和 findPrevious 选中下一个和上一个匹配项,到头后绕回;replace(text) 替换当前匹配项并选中下一个,replaceAll(text) 把所有匹配项作为一次编辑替换,并返回替换的数量。正则表达式的替换文本用 $1 和 ${name} 引用它的分组。

inSelection 只在它打开时选中的内容里查找(没有选中内容时为光标所在的行):只要它保持打开,这个范围就一直保留,随编辑移动,选区从一个匹配项移到另一个时也不变。findWithSelection(macOS 上为 cmd+e)用 onFindWithSelection 报告选中的文本或光标处的单词,供查找栏作为查询内容。

查找栏由应用自己实现:

import { Input, signal, Text, View } from "nsh";
import { Button, Editor, type EditorRef, type EditorSearchResult } from "nsh/base";

function Find(props: { source: string; onChange: (value: string) => void }) {
  let editor!: EditorRef;
  const query = signal("");
  const replacement = signal("");
  const result = signal<EditorSearchResult>({ count: 0 });
  const status = () =>
    result().error ?? (result().current != null ? `${result().current! + 1} of ${result().count}` : `${result().count} found`);
  return (
    <View class="flex-1">
      <View class="flex-row items-center gap-2 border-b border-border p-2">
        <Input value={query()} onChange={(q) => query.set(q)} onSubmit={() => editor.run("findNext")} placeholder="Find" class="flex-1" />
        <Text class="text-sm text-muted-foreground">{status()}</Text>
        <Button onPress={() => editor.run("findPrevious")} class="px-2"><Text>↑</Text></Button>
        <Button onPress={() => editor.run("findNext")} class="px-2"><Text>↓</Text></Button>
        <Input value={replacement()} onChange={(r) => replacement.set(r)} placeholder="Replace" class="flex-1" />
        <Button onPress={() => editor.replace(replacement())} class="px-2"><Text>Replace</Text></Button>
        <Button onPress={async () => console.log("replaced", await editor.replaceAll(replacement()))} class="px-2"><Text>All</Text></Button>
      </View>
      <Editor
        ref={editor}
        value={props.source}
        onChange={props.onChange}
        language="ts"
        search={query() ? { query: query() } : undefined}
        onSearchChange={(r) => result.set(r)}
        class="flex-1"
      />
    </View>
  );
}

#诊断

diagnostics 标记语言服务器或 linter 报告的问题:在每个范围的文字下方画一条细波浪线,颜色由严重程度决定(error、warning、info、hint)。位置是从 0 开始的行和 UTF-16 列,与语言服务器相同,所以它的诊断可以原样传入。用户输入时这些标记留在它们的文本上,直到应用传入新的诊断;消息的显示(一个列表、行旁的悬浮卡片)由应用负责。

诊断也带着语言服务器 Diagnostic 的其余字段。它的 tags 按语言客户端的方式绘制文本:unnecessary 的代码(未使用的变量、不可达的代码)淡化显示,deprecated 的代码加删除线,分别使用 unnecessaryText 和 deprecatedText 部件;带标签的提示只体现在它的文本上。codeDescription.href 是描述其代码的页面(一条 lint 规则的页面),relatedInformation 列出与它相关的其他位置({ location: { uri?, range }, message }):悬停把两者交给应用,由应用在悬浮卡片中显示并打开页面。语言服务器以数字(1、2)发送标签,编辑器接收它们的名称。

const problems = signal<EditorDiagnostic[]>([]);

async function lint(text: string) {
  // [{ range, severity, message, source?, code?, codeDescription?, tags?, relatedInformation? }]
  problems.set(await check(text));
}

<Editor
  value={source()}
  onChange={(v) => {
    source.set(v);
    lint(v);
  }}
  language="ts"
  diagnostics={problems()}
  classes={{ diagnosticWarning: "bg-orange-500", unnecessaryText: "text-zinc-400" }}
  class="flex-1"
/>

#装饰

decorations 为应用选择的范围设置样式:来自语言服务器的语义高亮、正在执行的语句、评审中改动的行。装饰的 class 和 style 设置其文本的样式;lineClass 和 lineStyle 设置其所在各行的样式,位于文本后面。与诊断一样,它们在编辑中留在自己的文本上,直到应用设置新的装饰。

const range = (line: number, start: number, end: number) => ({ start: { line, column: start }, end: { line, column: end } });

<Editor
  value={source()}
  language="sql"
  readOnly
  decorations={[
    { range: range(4, 0, 0), lineClass: "bg-green-500/10" },
    { range: range(7, 6, 18), class: "underline text-blue-600" },
  ]}
  class="flex-1"
/>

#应用自己的高亮

装饰的 token 按内置高亮给这类记号的颜色给其文本着色(comment、keyword、string、number、title、attr、built、deletion),取代内置高亮器在那里找到的记号:应用自己的高亮器(语言服务器的语义记号、应用自己的解析器)把它的记号映射到这些种类上,就得到编辑器在浅色和深色下的颜色。最内层装饰的记号优先;class 和 style 作用在它之上。highlighting={false} 关闭内置高亮器,文本只由应用的记号着色,而 language 仍然决定注释、括号和缩进。

const kinds: Record<string, EditorToken> = { keyword: "keyword", function: "title", string: "string", type: "built" };

<Editor
  value={source()}
  onChange={(v) => source.set(v)}
  language="ts"
  highlighting={false}
  decorations={semanticTokens().map((t) => ({ range: range(t.line, t.start, t.end), token: kinds[t.type] }))}
  class="flex-1"
/>

#折叠

折叠从缩进比上一行更深的行开始。foldRanges 改用应用自己的范围({ startLine, endLine },即语言服务器折叠范围的形状,可以原样传入):第 startLine 行显示折叠,其后直到 endLine 的行被折叠起来。它们在编辑中留在自己的行上,直到应用传入新的范围。

折叠可以嵌套:折叠一个块时,块内的折叠保持折叠,所以展开这个块时它们仍是原来的样子。foldRecursive 折叠一个块和其中的每个块,unfoldRecursive 把它们全部展开,foldLevel(n) 折叠嵌套层级为 n 的每个块,foldSelection(或 foldRange(range))折叠任意的行,不论折叠从哪里开始。onFoldChange 在每次改变后报告已折叠的范围,folds() / setFolds(ranges) 读取并恢复它们,以便与文件一起保存。

const ranges = signal<EditorFoldRange[]>([]);
const folded = new Map<string, EditorFoldRange[]>();

<Editor
  ref={editor}
  value={file().text}
  language={file().name}
  foldRanges={ranges()} // 来自语言服务器的 textDocument/foldingRange
  onFoldChange={(folds) => folded.set(file().name, folds)}
  class="flex-1"
/>
<Button onPress={() => editor.foldLevel(2)}><Text>折叠方法</Text></Button>

#断点和上下文菜单

点击行号会报告 onLineNumberPress(line),可以在这里设置断点,由应用画成一个装饰。辅助按键(右键单击、Mac 上的 Control 点击)报告 onContextMenu({ position, x, y }):指针下文本中的位置和根视图中的坐标,应用在那里打开自己的菜单。在触摸屏上,长按、双击、松开手柄和轻点选区也会报告它,并带有 selection:选区在根视图中的范围({ x, y, width, height }),x / y 是它顶边的中点,用来放置编辑菜单。

<Editor
  value={source()}
  onContextMenu={({ x, y, selection }) =>
    Menu.popup(selection ? editItems : contextItems, { x, y })
  }
/>
const breakpoints = signal<number[]>([]);

<Editor
  value={source()}
  language="py"
  onLineNumberPress={(line) =>
    breakpoints.set(breakpoints().includes(line) ? breakpoints().filter((l) => l !== line) : [...breakpoints(), line])
  }
  decorations={breakpoints().map((line) => ({ range: range(line, 0, 0), lineClass: "bg-red-500/15" }))}
  class="flex-1"
/>

#语言功能

建议、悬停信息、跳转到定义和代码操作来自应用的语言服务器。补全列表、行内补全和定义链接由编辑器自己绘制,并带有它们的按键;其余的,编辑器告诉应用时机和位置,由应用画出自己的弹出层(锚定到 pageBounds 的 Overlay)。

#补全

设置 completion 后,编辑器向应用请求建议,并把它们显示为正在输入的单词下方的列表(靠近窗口底部时显示在上方):

  • 在单词末尾输入字母、数字或 _ 时,以 onCompletionRequest(event) 请求,触发方式为 "typing":event 包含它的 id、光标的 offset 和 position、query(目前输入的单词)以及查询开始处的偏移 queryStart。completionTriggerCharacters 中的每一项(["."]、["::", "<"])在输入后以空查询请求,触发方式为 "character"(并带有 character);showCompletions() 或 run("showCompletions") 在光标处请求,触发方式为 "invoked"。
  • 应用以 presentCompletions(event.id, items) 回应。每一项是语言服务器 CompletionItem 的形状:label,以及可选的 kind("function"、"variable"、"class"……)、detail、documentation、insertText、filterText、sortText 和 range。显示的是对最新请求的回应;更早的回应,或光标已离开的单词的回应,会被搁置。
  • 继续输入时列表就地过滤:查询按顺序、不区分大小写地匹配每一项的 filterText(否则是它的标签),以查询开头的项在前,其次是最紧凑的匹配,再按 sortText 排序。匹配的字母会突出显示。传入 incomplete: true(presentCompletions(id, items, true))时,编辑器随着继续输入再次请求。
  • 上下方向键(以及 Control+P / Control+N)在列表中移动,Page Up 和 Page Down 按页移动,Enter 和 Tab 接受,Escape 关闭。指针标记一项,点击(触摸屏上轻点)接受它。选中项的 documentation 显示在列表旁边。
  • 接受时,用该项的 insertText(否则是它的标签)替换它的 range(否则是查询),作为撤销一次撤回的一步,在光标处以及前面文本相同的其他每个光标处进行,并报告 onCompletionAccept({ item })。
  • 光标离开单词、输入空格或其他标点、编辑器失去焦点,以及光标滚出视野时,列表关闭。

在没有列表显示时停止输入一段时间(inlineCompletionDelay,默认 300 毫秒),会请求行内补全:onInlineCompletionRequest({ id, offset, position })。presentInlineCompletion(id, text) 以淡色在光标处显示 text,Tab 插入它,Escape、编辑或移动清除它。

let editor!: EditorRef;

<Editor
  ref={(r) => (editor = r)}
  value={source()}
  onChange={(v) => source.set(v)}
  language="ts"
  completion
  completionTriggerCharacters={["."]}
  onCompletionRequest={async (e) => {
    const items = await server.completion(e.position);
    editor.presentCompletions(e.id, items);
  }}
  onInlineCompletionRequest={async (e) => {
    editor.presentInlineCompletion(e.id, await assistant.suggest(e.offset));
  }}
  class="flex-1"
/>

#悬停与点击

  • onHover(event):鼠标停在一个字符上(桌面和 Web)。event 包含它的 position、UTF-16 的 offset、所在单词的 range、指针的 pagePosition、单词的 pageBounds、按住的 modifiers,以及那里的 diagnostics,应用显示它们的消息。移到另一个单词时立即报告下一次悬停。
  • onHoverEnd():鼠标离开了单词或编辑器,或者它下面的文本改变或滚动了。
  • onTextPress(event):一个字符被点击,在编辑器放好光标之后:{ position, offset, range, pagePosition, modifiers, clicks }。
  • caretBounds() 和 rangeBounds(range):光标和一个范围的位置 { bounds, pageBounds }(在编辑器中和在根视图中):应用的弹出层在那里打开。
const hover = signal<EditorHoverEvent | null>(null);

<Editor
  ref={editor}
  value={source()}
  language="ts"
  onHover={(event) => hover.set(event)}
  onHoverEnd={() => hover.set(null)}
  class="flex-1"
/>
<If when={hover()}>
  {(event) => (
    <Overlay anchor={event().pageBounds} placement="top" class="rounded-md border border-border bg-popover p-2">
      <Text>{event().diagnostics.map((d) => d.message).join("\n") || describe(event().range)}</Text>
    </Overlay>
  )}
</If>

#跳转到定义与代码操作

按住 Mod(macOS 和 iOS 上是 Command,其他平台是 Control)时,鼠标下的单词变成链接,与代码编辑器一样:

  • 在单词上按住 Mod(鼠标停在那里时按下,或按住时把鼠标移到那里),编辑器以 onDefinitionRequest(event) 请求,触发方式 "hover":event 包含它的 id、字符的 position 和 offset,以及单词的 range。语言服务器找到定义时,以 presentDefinitionLink(event.id, range) 回应:这个范围加上下划线(definitionLink 部件),指针在它上面变成手形。松开 Mod、鼠标离开它、文本改变或滚动,或者调用 hideDefinitionLink() 时,链接消失。显示的是对最新请求的回应;更早的回应会被搁置。
  • 在单词上 Mod 点击,以触发方式 "press" 请求;在链接上点击时光标保持原位。F12(goToDefinition,run("goToDefinition"))请求光标处的单词,触发方式 "key"。由应用跳转到定义:同一文本中的定义用 revealPosition 和 setSelections,另一个文件中的用它自己的导航。
  • Mod+.(showCodeActions,run("showCodeActions"))报告 onCodeActionRequest({ range, position, bounds }):主选区(起点在前)、光标和它的方框。应用向语言服务器请求操作,在 bounds.pageBounds 处显示自己的菜单(Menu 或 Overlay),再用 edit(edits) 应用所选操作的编辑。
let editor!: EditorRef;

<Editor
  ref={(r) => (editor = r)}
  value={source()}
  onChange={(v) => source.set(v)}
  language="ts"
  onDefinitionRequest={async (e) => {
    const target = await server.definition(e.position);
    if (!target) return;
    if (e.trigger === "hover") editor.presentDefinitionLink(e.id, target.originRange ?? e.range);
    else editor.revealPosition(target.range.start, true);
  }}
  onCodeActionRequest={async (e) => {
    const actions = await server.codeActions(e.range);
    const picked = await choose(actions, e.bounds?.pageBounds);
    if (picked) editor.edit(picked.edits);
  }}
  class="flex-1"
/>

#尺寸与滚动

设置 minRows 和 / 或 maxRows 时,编辑器随文本增高:它和文本的行一样高,至少 minRows 行,到 maxRows 行起文本滚动,就像消息输入框或笔记本的单元格。否则由应用决定它的尺寸,scrollBeyondLastLine 让文本滚动到最后一行之后:最多一屏(true,默认)、完全不(false)或若干行。光标移动时,它上下各保留 cursorSurroundingLines 行在视野内。拖动选择越过编辑器的边缘时会继续滚动,指针越远滚得越快。

showWhitespaces 把每个空格显示为 ·、每个制表符显示为 →(whitespace 部件),rulers 在它的每一列画一条线(ruler 部件),作为行长度的参考线。

<Editor
  defaultValue={message()}
  language="markdown"
  minRows={2}
  maxRows={8}
  rulers={[72]}
  showWhitespaces
/>

#方法

ref={editor} 提供编辑器的方法(EditorRef),供应用的按钮、菜单和按键使用。返回值的方法(value()、selections()、replaceAll()、canUndo()、caretBounds()、folds() 等)在 JS 中返回一个 Promise,所以要 await 它们:const text = await editor.value()。

方法说明
focus()、blur()获得焦点,或放弃焦点。
value()Promise<string>:文本。
setValue(value)替换文本,作为一次可撤销的编辑。
insert(text)在每个选区处原样输入 text(括号不会自动闭合)。
edit(edits)应用一组 { range, text } 替换(范围指当前文本;与前一个重叠的会被跳过),作为撤销一次撤回的一步;选区随周围的文本移动。代码操作和格式化的编辑由它完成。
selections()Promise<EditorSelection[]>:每个选区 { anchor, head },主选区(最新的)在最后。
setSelections(selections)设置选区(最后一个为主选区),并把主选区滚动到视野中。
run(command)执行一个命令,与它的按键效果相同(命令)。
replace(text)替换当前匹配项并选中下一个。
replaceAll(text)Promise<number>:把所有匹配项作为一次编辑替换;返回数量。
fold(line)、unfold(line)折叠第 line 行(从 0 开始)下面的行,或展开它。
foldRecursive(line)、unfoldRecursive(line)折叠第 line 行的折叠及其中的每个折叠,或把它们全部展开。
foldLevel(level)折叠嵌套层级为 level 的每个折叠(1:最外层)。
foldRange(range)折叠 range({ startLine, endLine })中的行,不论折叠从哪里开始。
folds()Promise<EditorFoldRange[]>:已折叠的范围,包括嵌套的。
setFolds(folds)恰好折叠 folds。
scrollToLine(line)把第 line 行滚动到视野中,并展开隐藏它的折叠。
canUndo()、canRedo()Promise<boolean>:是否有可以撤销或重做的编辑。
caretBounds()Promise<EditorBounds | null>:主光标的框 { bounds, pageBounds },在编辑器中(已计入它的滚动)和在根视图中;编辑器布局之前为 null。
rangeBounds(range)Promise<EditorBounds[]>:range 的框,它覆盖的每一行一个。
scrollOffset()、setScrollOffset(offset)Promise<Point>:文本滚动了多远;把它滚动到 { x, y }。
visibleRange()Promise<EditorRange>:视口显示的文本,从第一行的开头到最后一行的结尾。
selectedText()Promise<string>:主选区的文本。
offsetToPosition(offset)、positionToOffset(position)Promise<EditorPosition>、Promise<number>:在 value() 的 UTF-16 偏移和位置之间转换。
revealPosition(position, center)把 position 滚动到视野中,并展开隐藏它的折叠;center 时滚动到中间。
goToLine(line)把光标放到第 line 行(从 0 开始)的开头,并把它滚动到中间。
showCompletions()在光标处请求补全,以光标前的单词为查询(补全)。
presentCompletions(id, items, incomplete?)回应补全请求 id:列表显示 items,按此后输入的内容过滤;设置 incomplete 时继续输入会再次请求。没有项时列表关闭。
hideCompletions()关闭补全列表。
presentInlineCompletion(id, text)、hideInlineCompletion()以 text 回应行内补全请求 id,显示在光标处;清除它。
presentDefinitionLink(id, range)、hideDefinitionLink()回应定义请求 id(触发方式 "hover"):给 range 加上链接的下划线(跳转到定义);隐藏它。
let editor!: EditorRef;

<Button onPress={() => editor.run("toggleComment")}><Text>Comment</Text></Button>
<Button onPress={async () => save(await editor.value())}><Text>Save</Text></Button>
<Button onPress={async () => (await editor.canUndo()) && editor.run("undo")}><Text>Undo</Text></Button>
<Button onPress={() => editor.setSelections([{ anchor: { line: 0, column: 0 }, head: { line: 0, column: 0 } }])}>
  <Text>Go to the top</Text>
</Button>
<Editor ref={editor} value={source()} onChange={(v) => source.set(v)} language="rs" class="flex-1" />

#命令

run 按名称接受编辑器的命令(EditorCommand):

命令作用
moveLeft、moveRight、moveUp、moveDown、moveWordLeft、moveWordRight、moveLineStart、moveLineEnd、movePageUp、movePageDown、moveDocumentStart、moveDocumentEnd移动光标。
selectLeft、selectRight、selectUp、selectDown、selectWordLeft、selectWordRight、selectLineStart、selectLineEnd、selectPageUp、selectPageDown、selectDocumentStart、selectDocumentEnd扩展选区。
moveToMatchingBracket跳到与光标处括号匹配的括号。
selectAll、selectLine、selectWord、selectNextOccurrence、selectPreviousOccurrence、selectAllOccurrences选择。
moveSubwordLeft、moveSubwordRight、selectSubwordLeft、selectSubwordRight按单词的一部分(camelCase 的大写处、snake_case 的各段)移动或扩展。
moveParagraphUp、moveParagraphDown、selectParagraphUp、selectParagraphDown移动或扩展到段落前后的空行。
expandSelection、shrinkSelection按单位扩大选区,或缩回。
undoSelection、redoSelection回到上一次移动之前的选区,或再前进。
splitSelectionIntoLines把每个选区拆成每行一个。
addCaretAbove、addCaretBelow、singleSelection添加光标;回到一个选区。
deleteBackward、deleteForward、deleteWordLeft、deleteWordRight、deleteToLineStart、deleteToLineEnd、deleteLine删除。
newline、newlineBelow、newlineAbove、indent、outdent、toggleComment、toggleBlockComment行、缩进与注释。
sortLinesAscending、sortLinesDescending对所选的行排序(只有一个光标时为所有行)。
transformToUppercase、transformToLowercase、transformToTitleCase改变选区的大小写(光标:它所在的单词)。
trimTrailingWhitespace删除每行末尾的空白。
moveLinesUp、moveLinesDown、duplicateLinesUp、duplicateLinesDown、joinLines、transposeCharacters重新排列。
undo、redo撤销和重做。
fold、unfold、foldAll、unfoldAll在光标所在行折叠,或折叠全部。
foldRecursive、unfoldRecursive折叠光标所在的折叠及其中的每个折叠,或把它们展开。
foldSelection折叠每个跨越多行的选区中的行。
findNext、findPrevious选中 search 的下一个或上一个匹配项。
findWithSelection为查找栏报告选中的文本(onFindWithSelection)。
copy、cut、paste剪贴板。
showCompletions在光标处请求补全(补全)。
goToDefinition请求光标处单词的定义(onDefinitionRequest,触发方式 "key")。
showCodeActions请求选区处的代码操作(onCodeActionRequest)。

#部件样式

class 和 style 设置编辑器盒子的样式。classes 按部件接受 Tailwind 类,styles 按部件接受内联样式;部件的类在它自己的默认样式之后应用,因此优先,它的 styles 又优先于类。字体用 fontFamily、fontSize 和 lineHeight 设置,编辑器用它们测量文本。

<Editor
  value={source()}
  onChange={(v) => source.set(v)}
  language="tsx"
  fontFamily="JetBrains Mono"
  fontSize={14}
  lineHeight={22}
  class="flex-1 rounded-lg border border-border"
  classes={{
    gutter: "bg-muted",
    lineNumber: "text-xs",
    activeLine: "bg-yellow-500/10",
    selection: "bg-blue-500/30",
  }}
  styles={{ caret: { backgroundColor: "#f59e0b", width: 3 } }}
/>
部件是什么默认值
gutter行号和折叠标记所在的列数字前 12pt,标记后 4pt
lineNumber每个行号右对齐,muted-foreground
activeLineNumber有光标的行的行号,在 lineNumber 之后应用foreground
foldMarker折叠开始处那一行的标记⌄,折叠后为 ›,muted-foreground
content行号栏旁边的文本区域文本前 4pt
line每一行文本
activeLine有光标的行的背后muted,很淡
selection所选文本的背后primary 的浅色
caret光标2pt 宽,foreground,闪烁
composition输入法正在组合的文本下划线
searchMatchsearch 的匹配项半透明的琥珀色
currentSearchMatch选区所在的匹配项,在 searchMatch 之后应用更深的琥珀色
matchingBracket光标处的括号和与它配对的括号1pt 边框
indentGuide每级缩进一条参考线一条 1pt 的 border 线
foldPlaceholder折叠行在其文本之后显示的内容⋯,muted-foreground
placeholder文本为空时显示muted-foreground
diagnosticError错误下方的线destructive
diagnosticWarning警告下方的线琥珀色
diagnosticInfo信息下方的线蓝色
diagnosticHint提示下方的线muted-foreground
unnecessaryTextunnecessary 诊断的文本muted-foreground
deprecatedTextdeprecated 诊断的文本删除线
whitespace空格和制表符的标记(showWhitespaces)· 和 →,muted-foreground 的 60 %
rulerrulers 的线1 点宽的 border 线
selectionHandle触摸选区两端手柄的圆点10pt 的 primary 圆点
selectionHandleStart、selectionHandleEnd起点的圆点(在其行上方)和终点的圆点(在其行下方),在 selectionHandle 之后
completionMenu补全列表的框popover、1pt 的 border、圆角、阴影
completionItem列表的每一项一行的高度,8pt 的内边距
selectedCompletionItem选中的项,在 completionItem 之后accent
completionMatch标签中与查询匹配的字母半粗体,primary
completionDetail一项的详情,在标签之后右对齐,muted-foreground
completionKind一项的种类,在标签之前简短的名称(fn、var、cls),muted-foreground
completionDocumentation选中项的文档,在列表旁边同 completionMenu,最宽 320pt
inlineCompletion光标处的行内补全muted-foreground
definitionLink定义链接的下划线文本下方一条 1pt 的 primary 线

#属性

属性Rust类型默认值说明
value.value(..)string文本,由应用控制(在 onChange 中重新设置)。
defaultValue.default_value(..)string编辑器自己保存文本时的初始文本。
language.language(..)string纯文本语言的名称、别名、扩展名或文件名(语言)。
placeholder.placeholder(..)string文本为空时显示。
readOnly.read_only(..)booleanfalse文本可以选择和复制,不能修改。
autoFocus.auto_focus(..)booleanfalse出现时获得焦点。
lineNumbers.line_numbers(..)booleantrue行号栏中的行号。
softWrap.soft_wrap(..)booleanfalse长行在编辑器的宽度处换行。
tabSize.tab_size(..)number语言的设置一个制表符和一级缩进的列数。
hardTabs.hard_tabs(..)boolean语言的设置用制表符缩进。
folding.folding(..)booleantrue缩进的行可以用行号栏中的标记折叠。
foldRanges.fold_ranges(..)EditorFoldRange[]按缩进折叠开始的地方,由应用给出(折叠)。
highlighting.highlighting(..)booleantrue内置的语法高亮(应用自己的高亮)。
autoClose.auto_close(..)booleantrue括号和引号在输入时自动闭合。
autoIndent.auto_indent(..)booleantrue新行保持缩进,并在左括号之后增加缩进。
activeLine.active_line(..)booleantrue高亮有光标的行。
indentGuides.indent_guides(..)booleantrue缩进参考线。
scrollBeyondLastLine.scroll_beyond_last_line(..)boolean | numbertrue文本可以滚动到最后一行之后:最多一屏(true)、完全不(false)或若干行。
fontFamily.font_family(..)string主题的 font-mono绘制和测量文本所用的字体。
fontSize.font_size(..)number13字号,单位为点。
lineHeight.line_height(..)number20一行的高度,单位为点。
search.search(..)EditorSearch要查找的内容(查找与替换)。
diagnostics.diagnostics(..)EditorDiagnostic[][]要标记的问题(诊断)。
decorations.decorations(..)EditorDecoration[][]应用设置样式的范围(装饰)。
classes.classes(..)EditorClasses{}按部件的 Tailwind 类。
styles.styles(..)EditorStyles{}按部件的内联样式,优先于类。
accessibilityLabel.accessibility_label(..)string读屏软件对编辑器的称呼。
showWhitespaces.show_whitespaces(..)booleanfalse空格和制表符显示为 · 和 →。
rulers.rulers(..)number[][]要画竖线的列。
minRows.min_rows(..)number编辑器从这么多行起随文本增高(尺寸与滚动)。
maxRows.max_rows(..)number编辑器随文本增高到这么多行,之后滚动。
cursorSurroundingLines.cursor_surrounding_lines(..)number1光标上下保留在视野内的行数。
completion.completion(..)booleanfalse编辑器请求建议并把它们显示为列表(补全)。
completionTriggerCharacters.completion_trigger_characters(..)string[][]输入时还会请求补全的字符。
inlineCompletionDelay.inline_completion_delay(..)number300停止输入多少毫秒后请求行内补全。
class、style样式方法string、StyleProp<ViewStyle>编辑器的盒子。

#事件

事件Rust说明
onChange.on_change(|value: &SharedString| ..)文本改变了:完整的新文本。
onSelectionChange.on_selection_change(|selections: &Vec<EditorSelection>| ..)选区改变了:所有选区,主选区在最后。
onFocus、onBlur.on_focus(|| ..)、.on_blur(|| ..)编辑器获得或失去焦点。
onSearchChange.on_search_change(|result: &EditorSearchResult| ..)搜索的匹配项改变了:{ count, current, error }。
onLineNumberPress.on_line_number_press(|line: &u32| ..)行号被点击:行号,从 0 开始。
onContextMenu.on_context_menu(|event: &EditorContextMenuEvent| ..)在文本上按下了辅助按键,或触摸选中了文本:{ position, x, y, selection? }。
onHover.on_hover(|event: &EditorHoverEvent| ..)鼠标停在一个字符上(语言功能)。
onHoverEnd.on_hover_end(|| ..)悬停结束了。
onTextPress.on_text_press(|event: &EditorTextPressEvent| ..)一个字符被点击:带有它的单词、修饰键和点击次数。
onFindWithSelection.on_find_with_selection(|query: &String| ..)执行了 findWithSelection:作为查找栏查询内容的文本。
onFoldChange.on_fold_change(|folds: &Vec<EditorFoldRange>| ..)已折叠的范围改变了:每一个,包括嵌套的。
onCompletionRequest.on_completion_request(|event: &EditorCompletionRequest| ..)编辑器请求补全:{ id, offset, position, query, queryStart, trigger, character? }(补全)。
onCompletionAccept.on_completion_accept(|event: &EditorCompletionAcceptEvent| ..)一项被接受并插入:{ item }。
onInlineCompletionRequest.on_inline_completion_request(|event: &EditorInlineCompletionRequest| ..)输入停顿了:{ id, offset, position },用于行内补全。
onDefinitionRequest.on_definition_request(|event: &EditorDefinitionRequest| ..)编辑器请求一个定义:{ id, position, offset, range, trigger }(跳转到定义)。
onCodeActionRequest.on_code_action_request(|event: &EditorCodeActionRequest| ..)用户请求代码操作:{ range, position, bounds? }。

#无障碍

读屏软件用 accessibilityLabel 称呼编辑器;它的焦点就是一个文本输入框的焦点,所以平台的文本服务(输入法、听写、软键盘)都能在其中使用。

#相关内容

  • TextView:Markdown 和 HTML 文档,其代码块与编辑器使用相同的语法颜色。
  • DiffView:两段文本之间的改动,使用编辑器的语法颜色。
  • Input:单行和多行文本框,使用平台自己的编辑。
  • nsh/keymap:应用为编辑器命令绑定的按键。
  • nsh/base:其他基础组件。