元素
TextView
TextView 顯示以 Markdown、HTML 或由應用程式建構的區塊所寫成的文件:標題、段落、強調、連結、清單與任務清單、引用與 GitHub 的提示框(alerts)、程式碼、表格、圖片、公式、註腳及展開區塊(disclosures)。它專為聊天中 AI 模型的回覆而設:回覆以 Markdown 串流送達,每個片段都會連同格式一併顯示文字。解析在 Rust 中進行,文件以 Text 元素與視圖繪製。
import { cleanup, mount, signal, Scroll, Text, View } from "nsh";
import { TextView } from "nsh/base";
import { button, buttonText, desktop, ui } from "./palette";
const REPLY = `## Moving to offline sync
Sync now runs **in the background**, and conflicts merge *per field*:
1. Export with \`nsh export\`
2. Check the dry run
3. Switch over
| Step | Time |
|--------|------:|
| Export | 2 min |
| Check | 5 min |
\`\`\`sh
nsh export --out backup.json
\`\`\`
> [!TIP]
> ${desktop ? "Drag across the paragraphs to select them, then copy." : "Long-press a paragraph to copy it."}`;
function App() {
const text = signal(REPLY);
const streaming = signal(false);
let timer: number | undefined;
const stop = () => {
if (timer !== undefined) clearInterval(timer);
timer = undefined;
streaming.set(false);
};
cleanup(stop);
// Replays the reply a few characters at a time, as a model streams it.
const play = () => {
stop();
const tokens = REPLY.match(/\s*\S{1,4}|\s+/g) ?? [];
let i = 0;
text.set("");
streaming.set(true);
timer = setInterval(() => (i < tokens.length ? text.set(text() + tokens[i++]) : stop()), 40);
};
return (
<Scroll style={{ flex: 1, backgroundColor: ui().page }}>
<View style={{ width: "100%", maxWidth: 680, alignSelf: "center", padding: 20, gap: 16 }}>
<TextView
markdown={text()}
options={{ streaming: streaming() }}
streamingCaret={streaming() ? <View style={{ width: 8, height: 16, marginLeft: 2, borderRadius: 2, backgroundColor: ui().accent }} /> : undefined}
selectable
/>
<View
onPress={play}
disabled={streaming()}
{...button()}
>
<Text style={buttonText()}>{streaming() ? "Streaming…" : "Stream it again"}</Text>
</View>
</View>
</Scroll>
);
}
mount(() => <App />);它自行負責行為,並附帶專屬的閱讀排版:15pt 文字配寬鬆的 25.5pt 行高、半粗體標題且上方留有空間、連結以主題的 primary 顏色加底線、程式碼使用主題的 font-mono 並置於有邊框的圓角方框中、表格四邊皆有線並帶有淡色表頭列、引用旁帶一條線,而提示框則是以 GitHub 提示顏色加上圖示的淡色方框。其顏色來自主題的 token(foreground、muted-foreground、border、muted、primary,以及用於警示的 destructive),因此主題與深色外觀都會套用到它。每個部分都會以應用程式的樣式覆蓋這些預設值:TSX 中使用 classes 和 styles,Rust 中則按部分提供類型化樣式(TextViewParts)。Rust 以 nsh_base::text_view() 建立,TSX 以來自 nsh/base 的 <TextView> 建立。
use nsh_base::{TextViewLinkPressEvent, text_view, theme};
use nsh_core::prelude::*;
const README: &str = "# Release notes
**Faster lists** and a new `TextView`:
- Markdown and HTML
- [x] Streaming replies
- [ ] Your own syntax
> [!TIP]
> Style every part with `TextViewParts`.";
fn release_notes() -> impl IntoElement {
scroll().flex_1().child(
text_view()
.markdown(README)
.on_link_press(|TextViewLinkPressEvent { url, .. }: &TextViewLinkPressEvent| log::info!("open {url}"))
.p_5()
.text_base()
.text_color(theme().foreground),
)
}import { mount, Scroll } from "nsh";
import { TextView } from "nsh/base";
const README = `# Release notes
**Faster lists** and a new \`TextView\`:
- Markdown and HTML
- [x] Streaming replies
- [ ] Your own syntax
> [!TIP]
> Style every part with \`classes\`.`;
function App() {
return (
<Scroll class="flex-1" contentClass="p-5">
<TextView
markdown={README}
class="text-base text-neutral-900 dark:text-neutral-100"
classes={{
heading1: "text-3xl",
code: "rounded bg-neutral-100 dark:bg-neutral-800",
link: "text-blue-600",
alertTip: "border-l-4 border-green-600 pl-3",
}}
onLinkPress={({ url }) => console.log("open", url)}
/>
</Scroll>
);
}
mount(App);在 Rust 中,text_view() 的建立方式如同 view() 建立視圖:每個 prop 一個 setter(.markdown(..)、.options(..)、.selectable(..))、每個渲染函式一個閉包(.render_image(|image| ..))、每個事件一個監聽器(.on_link_press(..)),以及用於根的類型化樣式方法(.p_5()、.text_color(..)),其文字樣式會傳給每個部分。
#建基於 Text
TextView 由 Text 元素組成,其文字行為完全如 Text 頁面所述。每個段落、標題、表格儲存格和程式碼區塊都是一個原生的 Text:一個會排版、換行、跟隨字體縮放,並且在配合 selectable 時可選取及複製的區塊。粗體、斜體、刪除線及其他行內標記是它的 span;行內程式碼是帶有 span 背景 的 span,因此 code: "rounded bg-neutral-100" 會為它加上圓角背景;連結是 可按壓的 span;圖片及行內自訂節點是 行內附件。根上的文字樣式會傳給每個部分,就像 文字樣式 由 Text 傳給其 span 一樣。
配合 selectable,整份文件可像網頁一樣整體選取:拖曳(在觸控螢幕上則為長按再拖動控點)可由一個段落、清單項目、儲存格或程式碼區塊延伸至下一個,而複製會取得所有這些內容的文字。selectable TextView 會自行單獨選取;在 可選取視圖 內,其選取範圍亦會延伸至周圍的文字及其他 TextView。
TextView 所新增的是其餘部分:將 Markdown、HTML 或文件讀取為區塊、文字周圍的視圖(清單、引用、表格、程式碼區塊、展開區塊、註腳)、串流、按部分設定樣式、渲染函式及擴充。
#文件
內容為三個 prop 之一,按此順序讀取:
markdown:CommonMark 加上 GitHub 的擴充(表格、任務清單、刪除線、自動連結、註腳、提示框)、公式($…$和$$…$$)以及 Markdown 內的 HTML。html:以閱讀器顯示的方式呈現 HTML。文字、結構和行內格式都會保留;腳本、樣式、表單控制項及隱藏元素則不會出現。片段或整個頁面均可(頁面會顯示其 body)。document:由應用程式建構或保存的區塊:TSX 中為普通物件,Rust 中為nsh_base::text_view::Document(文件模型)。
use nsh_base::text_view;
use nsh_base::text_view::{Block, Document, Inline};
use nsh_core::prelude::*;
fn shipping() -> impl IntoElement {
text_view().html(r#"<h2>Shipping</h2><p>Orders ship in <b>2 days</b>. <a href="/faq">FAQ</a></p>"#)
}
fn total() -> impl IntoElement {
text_view().document(Document::new(vec![
Block::Heading { level: 2, content: vec![Inline::text("Total")], anchor: None },
Block::paragraph(vec![Inline::Strong { content: vec![Inline::text("$42.00")] }]),
]))
}<TextView html={`<h2>Shipping</h2><p>Orders ship in <b>2 days</b>. <a href="/faq">FAQ</a></p>`} />
<TextView document={{
blocks: [
{ type: "heading", level: 2, content: [{ type: "text", text: "Total" }] },
{ type: "paragraph", content: [{ type: "strong", content: [{ type: "text", text: "$42.00" }] }] },
],
}} />TextView 會隨內容增長。較長的文件請放入 Scroll(或 List 的一列)。
#為各部分設定樣式
class 和 style 為根設定樣式,根是由各區塊組成的一欄;在此設定的文字樣式(顏色、大小、字型)會傳給每個部分。根同時是 root 這個部分:styles.root 接受帶類型的文字樣式(style 的類型與視圖的相同),classes.root 接受 class。classes 按部分接受 Tailwind class,styles 按部分接受行內樣式。部分的 class 排在其自身預設樣式之後,因此優先;其 styles 則優先於其 class。在 Rust 中,各部分接受類型化樣式:TextViewParts 為每個 TextViewPart 提供一個細化其樣式的函式,作用於提供它的視圖以下的每個 TextView(類型化部分樣式),而根則接受樣式方法。預設值中的行高以點(pt)設定:更改某部分大小的 class 也會一併設定其行高(Tailwind 的 text-* class 即如此)。
use nsh_base::{TextViewPart, TextViewParts, text_view, theme};
use nsh_core::prelude::*;
fn reply(markdown: Signal<SharedString>) -> impl IntoElement {
provide_context(
TextViewParts::new()
.part(TextViewPart::Heading, |s| s.text_color(rgb(0x0a0a0a)))
.part(TextViewPart::Heading2, |s| s.text_xl().mt_2())
.part(TextViewPart::Blockquote, |s| {
s.border_l(px(2.0)).border_color(rgb(0xd4d4d4)).pl_3().text_color(rgb(0x525252))
})
.part(TextViewPart::CodeBlock, |s| s.rounded_lg().bg(rgb(0xf5f5f5)))
.part(TextViewPart::CodeBlockHeader, |s| s.px_3().pt_2())
.part(TextViewPart::CodeBlockCode, |s| s.p_3().text_size(px(13.0)))
.part(TextViewPart::Table, |s| s.rounded_lg().border_1().border_color(rgb(0xe5e5e5)))
.part(TextViewPart::TableHead, |s| s.bg(rgb(0xfafafa)))
.part(TextViewPart::TableCell, |s| {
s.px_3().py_2().border_t_1().border_color(rgb(0xe5e5e5))
})
.part(TextViewPart::TaskMarkerChecked, |s| s.text_color(rgb(0x16a34a)))
.part(TextViewPart::Link, |s| s.text_color(theme().primary)),
);
text_view()
.markdown(move || markdown.get())
.gap_3()
.text_size(px(15.0))
.line_height(px(24.0))
.text_color(rgb(0x262626))
.font_family("Inter")
}<TextView
markdown={reply()}
class="gap-3 text-[15px] leading-6 text-neutral-800"
classes={{
heading: "text-neutral-950",
heading2: "text-xl mt-2",
blockquote: "border-l-2 border-neutral-300 pl-3 text-neutral-600",
codeBlock: "rounded-lg bg-neutral-100",
codeBlockHeader: "px-3 pt-2",
codeBlockCode: "p-3 text-[13px]",
table: "rounded-lg border border-neutral-200",
tableHead: "bg-neutral-50",
tableCell: "px-3 py-2 border-t border-neutral-200",
taskMarkerChecked: "text-green-600",
}}
styles={{ root: { fontFamily: "Inter" }, link: { color: "#2563eb" } }}
/>有些節點由數個部分組成,並按順序套用:2 級標題是 heading 再加 heading2;提示框是 blockquote、alert,然後是其種類(alertNote、……);任務標記是 listMarker、taskMarker,完成時再加 taskMarkerChecked;表頭列是 tableRow 再加 tableHead,其儲存格則是 tableCell 再加 tableHeaderCell。
| 部分 | Rust | 說明 |
|---|---|---|
root |
Root |
文字視圖本身,由各區塊組成的一欄;此處的文字樣式會傳給每個部分。 |
paragraph |
Paragraph |
每個段落。 |
heading |
Heading |
每個標題,在其級別的部分之前。 |
heading1 … heading6 |
Heading1 … Heading6 |
該級別的標題(27、20、17,然後 15pt,半粗體)。 |
blockquote |
Blockquote |
引用與提示框:由其區塊組成的一欄,旁邊有一條線。 |
alert |
Alert |
每個提示框(> [!NOTE]),在 blockquote 之後:淡色方框,由其圖示和區塊組成的一列。 |
alertNote、alertTip、alertImportant、alertWarning、alertCaution |
AlertNote、AlertTip、AlertImportant、AlertWarning、AlertCaution |
該種類的提示框。 |
alertIcon |
AlertIcon |
提示框的圖示:一個圓環,內有提示框顏色的符號。 |
list |
List |
每個清單:由其項目組成的一欄。 |
orderedList、unorderedList |
OrderedList、UnorderedList |
編號清單與項目符號清單,在 list 之後。 |
listItem |
ListItem |
每個項目:由其標記和區塊組成的一列。 |
listMarker |
ListMarker |
項目的標記(•、◦、▪ 按深度而定,或 1.),在項目前的縮排中靠右對齊。 |
taskMarker |
TaskMarker |
任務項目的標記:以視圖繪製的方框,完成時填上 ✓;在 listMarker 之後。 |
taskMarkerChecked |
TaskMarkerChecked |
已完成任務項目的標記,在 taskMarker 之後。 |
listItemContent |
ListItemContent |
項目的區塊所在的一欄。 |
codeBlock |
CodeBlock |
程式碼區塊:有邊框的圓角方框,載有程式碼,上方為標頭。 |
codeBlockHeader |
CodeBlockHeader |
程式碼區塊的操作列(codeBlockActions),浮於其右上角。 |
codeBlockScroll |
CodeBlockScroll |
圍繞程式碼區塊中程式碼的水平捲動視圖。 |
codeBlockCode |
CodeBlockCode |
程式碼區塊的程式碼(等寬,照原樣顯示)。 |
code |
Code |
文字中的程式碼(等寬,帶圓角背景)。 |
keyboard |
Keyboard |
按鍵(<kbd>,以程式碼呈現)。 |
strong |
Strong |
粗體文字(半粗體)。 |
emphasis |
Emphasis |
強調文字(斜體)。 |
strikethrough |
Strikethrough |
刪除線文字。 |
underline |
Underline |
底線文字(<u>、<ins>)。 |
highlight |
Highlight |
標示文字(<mark>,黃色圓角背景)。 |
superscript、subscript |
Superscript、Subscript |
上標和下標(較小)。 |
link |
Link |
連結(以主題的 primary 顏色,帶底線)。 |
image |
Image |
圖片。 |
math |
Math |
文字中的公式,在沒有 renderMath 時由其自行排版。 |
mathBlock |
MathBlock |
公式區塊,在沒有 renderMath 時由其自行排版並置中。 |
table |
Table |
表格:renderTableActions 的內容,然後是儲存格的捲動視圖。 |
tableScroll |
TableScroll |
表格儲存格的水平捲動視圖:空間足夠時各欄貼合其內容,視圖較窄時會換行至下限,超過後表格便會捲動。 |
tableRow |
TableRow |
表格的每一列:位於其儲存格後面的視圖,用於背景和邊框(文字樣式放在 tableCell)。 |
tableHead |
TableHead |
表頭列,在 tableRow 之後。 |
tableCell |
TableCell |
每個儲存格。 |
tableHeaderCell |
TableHeaderCell |
表頭列的儲存格(較小、淡化),在 tableCell 之後。 |
rule |
Rule |
分隔線:以上邊框繪製的一條線。 |
details |
Details |
展開區塊(<details>)。 |
summary |
Summary |
展開區塊的摘要;按壓會開啟及關閉它。 |
summaryMarker |
SummaryMarker |
摘要的標記(▸、▾)。 |
footnotes |
Footnotes |
區塊之後的註腳:一欄。 |
footnote |
Footnote |
一則註腳:由其編號和區塊組成的一列。 |
footnoteLabel |
FootnoteLabel |
註腳的編號。 |
footnoteReference |
FootnoteReference |
文字中對註腳的參照(其編號,較小)。 |
custom |
Custom |
以其內容顯示的自訂節點(在沒有渲染函式顯示它們時)。 |
#串流 AI 回覆
以 options={{ streaming: true }}(Rust 中為 MarkdownOptions { streaming: true, .. })傳入逐步增長的回覆。仍在到達的文字,其末端常留有未閉合的語法(**bold、`code, [a link](htt, a table whose header just arrived);串流會將它顯示為閉合後的樣子,讀者因而不會看到多餘的 ** 或 [。區塊按位置保留:已完成的區塊保留其視圖,只有正在寫入的區塊會重新建立,因此長回覆的更新成本依然低廉。
streamingCaret 跟隨文件末端:若最後一個區塊在行內結束(段落、清單項目、程式碼區塊、表格的最後一個儲存格),它便位於最後一段文字的末端,否則另起一行置於最後一個區塊之後(分隔線、渲染函式的內容)。回覆完成後請將它移除。
use nsh_base::{MarkdownOptions, text_view};
use nsh_core::prelude::*;
/// A reply the app appends to as chunks arrive, with `done` set at the end.
fn reply(text: Signal<SharedString>, done: Signal<bool>) -> impl IntoElement {
text_view()
.markdown(move || text.get())
.options(move || MarkdownOptions { streaming: !done.get(), ..Default::default() })
.streaming_caret(when(
move || !done.get(),
|| view().ml_0p5().h_4().w_2().rounded_sm().bg(rgb(0xa3a3a3)),
))
}import { signal, View } from "nsh";
import { TextView } from "nsh/base";
function Reply(props: { stream: AsyncIterable<string> }) {
const text = signal("");
const done = signal(false);
(async () => {
for await (const chunk of props.stream) text.update((t) => t + chunk);
done.set(true);
})();
return (
<TextView
markdown={text()}
options={{ streaming: !done() }}
streamingCaret={done() ? undefined : <View class="ml-0.5 h-4 w-2 rounded-sm bg-neutral-400" />}
/>
);
}插入符是應用程式自己的視圖,因此可用 nsh 的動畫 API 閃爍或脈動,新區塊亦可配合應用程式的動畫淡入。
#連結
連結是 可按壓的 span。按壓連結會呼叫 onLinkPress,並傳入 { url };開啟甚麼由應用程式決定(自己的頁面、WebView、捲動至某個標題)。標題有錨點(GitHub 式的:## Getting started 是 #getting-started),因此指向 #getting-started 的連結會以該字串送達,而註腳參照則以 #footnote-<label> 送達。
use nsh_base::{TextViewLinkPressEvent, text_view};
use nsh_core::prelude::*;
fn doc(markdown: &'static str) -> impl IntoElement {
text_view().markdown(markdown).on_link_press(|TextViewLinkPressEvent { url, .. }: &TextViewLinkPressEvent| {
match url.strip_prefix('#') {
Some(anchor) => scroll_to_anchor(anchor),
None => open_web(url),
}
})
}<TextView
markdown={doc}
onLinkPress={({ url }) => {
if (url.startsWith("#")) scrollToAnchor(url.slice(1));
else nav.push({ name: "web", url });
}}
/>scroll_to_anchor 和 open_web(TSX 中為 scrollToAnchor 和 nav)代表應用程式自己的函式。
設定 links="open" 後,按壓指向其他網站或應用程式的連結(帶 scheme 的 URL:https:、mailto:、……)時,亦會以 openUrl 在應用程式外開啟,受應用程式的 openUrl 權限規限,並且在 onLinkPress 回報之後;文件內的錨點(#…)則只會回報。預設值 "report" 不會開啟任何內容。
use nsh_base::{TextViewLinks, text_view};
text_view().markdown(doc).links(TextViewLinks::Open)<TextView markdown={doc} links="open" />#渲染函式
渲染函式將應用程式自己的內容放入文件。每個函式接收某個部分的值,並回傳其內容(TSX 中為 JSX,Rust 中為元素);只要值不變,該部分便會保留為它渲染的內容,因此串流期間只有正在寫入的部分會重新渲染。
| Prop | Rust | 值 | 取代或新增 |
|---|---|---|---|
renderCodeBlock |
.render_code_block(|code| ..) |
TextViewCodeBlock(CodeBlock) |
取代程式碼區塊(自行提供語法突顯)。 |
renderCodeBlockAction |
.render_code_block_action(|action| ..) |
TextViewCodeAction |
應用程式的程式碼區塊操作(codeBlockActions)的圖示;其周圍的按鈕由 TextView 提供。 |
renderTableActions |
.render_table_actions(|table| ..) |
TextViewTable(Table) |
在表格上方新增內容(複製、匯出)。 |
renderMath |
.render_math(|math| ..) |
TextViewMath |
取代公式:{ tex, display },而 $$…$$ 區塊則為 display。 |
renderImage |
.render_image(|image| ..) |
TextViewImage(Image) |
取代圖片({ src, alt, title }):自訂載入、尺寸,以及按壓時的檢視器。 |
renderCustom |
.render_custom(|node| ..) |
TextViewCustomNode(CustomNode) |
以其 name 顯示自訂節點(Markdown 擴充)。 |
程式碼區塊在右上角帶有操作,繪製方式一致:幽靈按鈕,懸停或按壓時才顯示背景;在桌面上指標位於區塊上時顯示,在觸控螢幕上則常駐顯示。copy 為內建:它會複製程式碼,並短暫顯示一個勾號。其他操作屬於應用程式:renderCodeBlockAction 繪製其圖示,onCodeBlockAction 回報其按壓(內建的 copy 在複製之後亦會回報)。copy 像 Clipboard 一樣寫入剪貼簿,因此需要應用程式的 clipboard 權限(nsh.json)。
use nsh_base::{TextViewCodeAction, TextViewCodeActionEvent, text_view};
use nsh_core::prelude::*;
fn reply(markdown: Signal<SharedString>) -> impl IntoElement {
text_view()
.markdown(move || markdown.get())
.code_block_actions(vec![
TextViewCodeAction::new("copy").with_label("Copy code"),
TextViewCodeAction::new("run").with_label("Run"),
])
.render_code_block_action(|_action: &TextViewCodeAction| text("▶").text_xs())
.on_code_block_action(|event: &TextViewCodeActionEvent| {
if event.id == "run" {
run(&event.code.code);
}
})
}import { Text } from "nsh";
import { TextView } from "nsh/base";
<TextView
markdown={reply()}
codeBlockActions={[
{ id: "copy", label: "Copy code" },
{ id: "run", label: "Run" },
]}
renderCodeBlockAction={(action) => <Text class="text-xs">▶</Text>}
onCodeBlockAction={({ id, code }) => id === "run" && run(code.code)}
/>程式碼區塊開箱即有語法突顯:對於圍欄上指明的語言(ts、tsx、rust、python、bash、json、toml、yaml、diff 及另外約二百種),程式碼會以 Allsum 的程式碼調色盤顯示,並隨外觀而為淺色或深色;沒有指明語言的區塊則以純文字顯示。擁有自己突顯方案的應用程式,會以 renderCodeBlock 顯示其程式碼,來自它自己的元件或其 Rust crate 之一;Rust 視圖以 CodeHighlighter 為 TextView 提供其突顯器。以下是使用應用程式自有突顯的程式碼,以及填滿寬度的圖片:
use nsh_base::text_view;
use nsh_base::text_view::{CodeBlock, Image};
use nsh_base::TextViewMath;
use nsh_core::prelude::*;
fn reply(markdown: Signal<SharedString>) -> impl IntoElement {
text_view()
.markdown(move || markdown.get())
.render_code_block(|code: &CodeBlock| highlighted(code.language.clone(), code.code.clone()))
.render_image(|img: &Image| {
image(img.src.clone())
.label(img.alt.clone())
.aspect_ratio(16.0 / 9.0)
.w_full()
.rounded_lg()
})
.render_math(|math: &TextViewMath| math_view(math.tex.to_string(), math.display))
}import { Image, View } from "nsh";
<TextView
markdown={reply()}
renderCodeBlock={(code) => <Highlighted language={code.language} code={code.code} />}
renderImage={(image) => (
<Image src={image.src} accessibilityLabel={image.alt} class="aspect-video w-full rounded-lg" />
)}
renderMath={(math) => <MathView tex={math.tex} display={math.display} />}
/>Highlighted 和 MathView(Rust 中為 highlighted 和 math_view)代表應用程式自己的元件(突顯器、公式渲染器,或其 Rust crate 的元件)。沒有 renderMath 時,TextView 會自行排版公式(math 和 mathBlock 部分),使用 KaTeX 的 TeX 支援與字型,以文字的顏色顯示,在每個平台上都一致;無法讀取的 TeX 則以等寬的原始碼顯示。
#Markdown 擴充功能
extensions 把應用程式自己的語法轉換為自訂節點,並由 renderCustom 顯示。每個擴充功能都有一個 name(即其所產生節點的 name),以及四種語法之一:
| 種類 | Rust | 匹配對象 | 節點 |
|---|---|---|---|
pattern |
MarkdownExtension::pattern(name, regex) |
文字中的正規表示式(Rust 的 regex 語法),程式碼和連結之外 |
行內節點;text 為匹配內容,attributes 為其群組:"1"、"2"、…,具名群組則以名稱存取 |
language |
MarkdownExtension::language(name, language) |
該語言的圍欄程式碼區塊(```chart) |
A block; text is the code, attributes.meta 為資訊字串的其餘部分) |
tag |
MarkdownExtension::tag(name, tag) |
該標籤的 HTML 或 MDX 元素,不分大小寫(<Callout kind="tip">) |
其屬性,其內容會被解析:獨立成行的元素為 children(區塊),文字內的為 content(行內) |
container |
MarkdownExtension::container(name, container) |
容器 :::name … ::: |
內含 Markdown 的區塊(children);:::note[Heads up] 提供 attributes.label,{key=value} 為其他屬性 |
use nsh_base::text_view::{CustomNode, Document};
use nsh_base::{MarkdownExtension, text_view};
use nsh_core::prelude::*;
const MESSAGE: &str = r#"Thanks @ada! Here are the numbers:
```chart
12, 19, 7, 25
```
<Callout kind="tip">Charts update live.</Callout>
:::note[Heads up]
Numbers are in thousands.
:::"#;
fn custom(node: &CustomNode) -> AnyElement {
match node.name.as_str() {
"mention" => text(node.text.clone())
.font_weight(FontWeight::SEMIBOLD)
.text_color(rgb(0x2563eb))
.into_element(),
"chart" => bar_chart(node.text.split(',').filter_map(|v| v.trim().parse().ok()).collect())
.into_element(),
"callout" => view()
.rounded_lg()
.bg(rgb(0xfffbeb))
.p_3()
.child(text_view().document(Document::new(node.children.clone())))
.into_element(),
_ => view()
.gap_1()
.border_l(px(4.0))
.border_color(rgb(0x3b82f6))
.pl_3()
.child(
text(node.attributes.get("label").cloned().unwrap_or_else(|| "Note".into()))
.font_weight(FontWeight::SEMIBOLD),
)
.child(text_view().document(Document::new(node.children.clone())))
.into_element(),
}
}
fn message() -> impl IntoElement {
text_view()
.markdown(MESSAGE)
.extensions(vec![
MarkdownExtension::pattern("mention", r"@(\w+)"),
MarkdownExtension::language("chart", "chart"),
MarkdownExtension::tag("callout", "callout"),
MarkdownExtension::container("note", "note"),
])
.render_custom(custom)
}import { Text, View } from "nsh";
import { TextView } from "nsh/base";
import type { MarkdownExtension, TextViewCustomNode } from "nsh/base";
const extensions: MarkdownExtension[] = [
{ name: "mention", pattern: "@(\\w+)" },
{ name: "chart", language: "chart" },
{ name: "callout", tag: "callout" },
{ name: "note", container: "note" },
];
const message = `Thanks @ada! Here are the numbers:
\`\`\`chart
12, 19, 7, 25
\`\`\`
<Callout kind="tip">Charts update live.</Callout>
:::note[Heads up]
Numbers are in thousands.
:::`;
function Custom(props: { node: TextViewCustomNode }) {
const node = props.node;
switch (node.name) {
case "mention":
return <Text class="font-semibold text-blue-600">{node.text}</Text>;
case "chart":
return <BarChart values={node.text!.split(",").map(Number)} />;
case "callout":
return (
<View class="rounded-lg bg-amber-50 p-3">
<TextView document={{ blocks: node.children ?? [] }} />
</View>
);
default:
return (
<View class="gap-1 border-l-4 border-blue-500 pl-3">
<Text class="font-semibold">{node.attributes?.label ?? "Note"}</Text>
<TextView document={{ blocks: node.children ?? [] }} />
</View>
);
}
}
<TextView markdown={message} extensions={extensions} renderCustom={(node) => <Custom node={node} />} />;BarChart(Rust 中為 bar_chart)代表應用程式自己的圖表。提及(mention)模式的群組位於 node.attributes?.["1"](Rust 中:node.attributes["1"]:ada),整個匹配內容位於 node.text(@ada)。沒有 render 函式的自訂節點會顯示其內容。行內節點所回傳的 view(例如 chip)會按自身的 verticalAlign 放置在行內:align-middle(verticalAlign: "middle")會使其對齊文字中線,除非該 view 較高,否則該行會保持段落的 lineHeight。
#選項
options 選擇在 CommonMark 和 GitHub 語法之外所讀取的 Markdown 語法;在 Rust 中則是具有相同欄位的 MarkdownOptions(MarkdownOptions { streaming: true, ..Default::default() }):
| 鍵 | 型別 | 預設值 | 說明 |
|---|---|---|---|
math |
boolean |
true |
公式:文字中用 $…$,區塊用 $$…$$。 |
html |
boolean |
true |
Markdown 內的 HTML 會按 html prop 的讀取方式處理;false 則將其顯示為文字。 |
alerts |
boolean |
true |
GitHub 的 alerts:> [!NOTE]、[!TIP]、[!IMPORTANT]、[!WARNING]、[!CAUTION]。 |
frontmatter |
boolean |
false |
YAML front matter(以 --- … --- 開首)會成為自訂節點 frontmatter,其 text 即該 YAML。 |
mdx |
boolean |
false |
MDX:JSX 元素會成為以其標籤命名的自訂節點,{expressions} 自訂節點 expression;此時原始 HTML 會被當作文字讀取。 |
streaming |
boolean |
false |
文字仍在傳入:結尾處未閉合的語法會顯示為已閉合。 |
#文件模型
document prop 和 render 函式的值都是純物件,每個節點以其 type 標記。型別來自 nsh/base:TextViewDocument、TextViewBlock、TextViewInline、TextViewCodeBlock、TextViewTable、TextViewListItem、TextViewImage、TextViewFootnote、TextViewAlert 和 TextViewCustomNode。在 Rust 中,它們是 nsh_base::text_view 的型別(Document、Block、Inline、CodeBlock、Table、ListItem、Image、Footnote、Alert、CustomNode):Block 和 Inline 是 enum,其 variant 即下列型別的 PascalCase 寫法(Block::Heading { level, content, anchor }、Inline::Strong { content }),欄位相同,但採用 snake case。
區塊 type |
欄位 |
|---|---|
paragraph |
content |
heading |
level(1–6)、content、anchor? |
blockquote |
children、alert?("note"、"tip"、"important"、"warning"、"caution") |
list |
ordered?、start?、loose?、items({ checked?, children }) |
codeBlock |
language?、meta?、code |
math |
tex |
table |
align?(每欄的 "none"、"left"、"center"、"right")、head(儲存格)、rows?(儲存格的列);儲存格為 { content } |
rule |
|
details |
open?、summary(行內)、children |
custom |
name、attributes?、text?、data?、children?、content? |
行內 type |
欄位 |
|---|---|
text |
text |
strong、emphasis、strikethrough、underline、highlight、superscript、subscript |
content |
code |
code |
keyboard |
text |
link |
url、title?、content |
image |
src、alt?、title? |
math |
tex |
break |
|
footnoteReference |
label、index |
custom |
與區塊相同 |
文件還有 footnotes?:{ label, index, children },按首次引用的順序顯示在各區塊之後。應用程式可以將回覆儲存為文件、根據自己的資料建立文件,或混入由 renderCustom 顯示的自訂節點。
#解析與純文字
TextView 亦可同步讀取文件而不顯示:
| 函式 | Rust | 回傳 |
|---|---|---|
TextView.parse(markdown, options?, extensions?) |
TextView::parse(markdown, options, extensions) |
markdown prop 會顯示的 TextViewDocument。 |
TextView.parseHtml(html, extensions?) |
TextView::parse_html(html, extensions) |
html prop 會顯示的 TextViewDocument。 |
TextView.plainText(document) |
TextView::plain_text(document)、Document::plain_text() |
文件不含標記的文字:區塊以空行分隔,清單項目和表格列以換行分隔,儲存格以 Tab 分隔。 |
整則回覆的複製按鈕:
use nsh_base::{TextView, button};
use nsh_core::prelude::*;
fn copy_reply(reply: Signal<SharedString>) -> impl IntoElement {
button()
.on_press(move || {
let document = TextView::parse(reply.get().to_string(), None, None);
set_clipboard(TextView::plain_text(document));
})
.child(text("Copy"))
}<Button onPress={() => Clipboard.writeText(TextView.plainText(TextView.parse(reply())))}>
<Text>Copy</Text>
</Button>應用程式也可以只解析一次並保留該文件(document={doc}),或在顯示前修改它。
#在 Rust 中
Rust 的 view 使用來自 nsh-base crate 的同一個元件,上述範例可在其 Rust 分頁中找到。有三項是 Rust 獨有的:應用程式的 highlighter、按部件設定的具型別樣式,以及 Markdown 外掛,三者都透過 provide_context 提供給 view 之下的每個 TextView。文件型別位於 nsh_base::text_view 之下,Parser 亦可配合外掛讀取文件(Parser::new().plugin(..).markdown(source))。
#語法突顯
TextView 使用 syntect,按 Allsum 的程式碼色板突顯程式碼(nsh-base 的功能 highlight,預設啟用);較大的區塊會先以純文字即時顯示,待背景執行緒完成突顯後再套用顏色。CodeHighlighter 是取而代之的應用程式自訂 highlighter,透過 provide_context 提供給 view 之下的每個 TextView。它會取得每個程式碼區塊,並回傳其程式碼中各 byte 範圍的樣式(按順序且不重疊;未涵蓋的部分為純文字);TextView 會在其慣常的程式碼區塊(標題、操作、捲動 view、部件)內,以這些樣式將程式碼顯示為多個 span。highlighter 可以是任何能將程式碼轉為帶樣式範圍的東西:tree-sitter、使用應用程式自選主題的 syntect,等等。
use std::ops::Range;
use nsh_base::{CodeHighlighter, text_view::CodeBlock};
use nsh_core::{element::StyleRefinement, prelude::*};
/// The app's highlighter (here with syntect, set up once by the app).
fn highlight(code: &CodeBlock) -> Vec<(Range<usize>, StyleRefinement)> {
my_syntect::ranges(code.language.as_deref(), &code.code)
.map(|(range, color, bold)| {
let style = StyleRefinement::default().text_color(color);
(range, if bold { style.font_weight(FontWeight::BOLD) } else { style })
})
.collect()
}
fn chat() -> impl IntoElement {
provide_context(CodeHighlighter::new(highlight));
view().child(messages())
}
my_syntect::ranges 代表應用程式與其 highlighter 之間的銜接程式碼,messages 代表應用程式的聊天 view。如有提供 renderCodeBlock,則由它取代以上所有內容來顯示程式碼區塊。
#具型別的部件樣式
TextViewParts 以具型別的樣式取代 class 字串來設定部件樣式,供 Rust 的 view 使用:每個 TextViewPart(對應上表中的每個部件)都有一個用來調整其 StyleRefinement 的函式。透過 provide_context 提供後,它們會套用於其下的每個 TextView,先於其 classes 和 styles;其中讀取的主題顏色會隨淺色和深色外觀變化。
use nsh_base::{TextViewPart, TextViewParts, theme};
use nsh_core::prelude::*;
fn chat_parts() {
provide_context(
TextViewParts::new()
.part(TextViewPart::Heading1, |s| s.text_2xl().font_weight(FontWeight::BOLD))
.part(TextViewPart::Code, |s| s.bg(theme().muted).rounded(px(4.0)))
.part(TextViewPart::Link, |s| s.text_color(theme().primary)),
);
}
#Markdown 外掛
MarkdownPlugin 在 Rust 中讀取 Markdown 語法:它會在解析器讀取語法樹的每個節點(markdown-rs 的 mdast)之前先取得該節點,並回傳自己的區塊或行內內容,通常是 CustomNode;它亦可顯示自己產生的自訂節點。view 會向其下的每個 TextView 提供外掛:
use nsh_base::text_view::{mdast, CustomNode, Inline, MarkdownPlugin, MarkdownPlugins, ParseContext};
use nsh_core::prelude::*;
struct Tickers;
impl MarkdownPlugin for Tickers {
fn name(&self) -> &str {
"tickers"
}
/// `$AAPL` in inline code becomes a ticker node.
fn inline(&self, node: &mdast::Node, _cx: &mut ParseContext<'_>) -> Option<Inline> {
let mdast::Node::InlineCode(code) = node else { return None };
let symbol = code.value.strip_prefix('$')?;
Some(Inline::Custom(CustomNode::new("ticker").with_text(symbol)))
}
fn render(&self, node: &CustomNode) -> Option<AnyElement> {
(node.name == "ticker").then(|| text(node.text.clone()).font_weight(FontWeight::BOLD).into_element())
}
}
fn chat() -> impl IntoElement {
provide_context(MarkdownPlugins::new().with(Tickers));
view().child(messages())
}
外掛的 render 排在最前,其次是 renderCustom,然後才是節點的內容。configure 會啟用更多 markdown-rs 的語法,ParseContext 則按解析器的方式讀取節點的子節點。
#Props
| Prop | Rust | 型別 | 預設值 | 說明 |
|---|---|---|---|---|
markdown |
.markdown(..) |
string |
以 Markdown 表示的文件。 | |
html |
.html(..) |
string |
以 HTML 表示的文件,在未提供 markdown 時使用。 |
|
document |
.document(..) |
TextViewDocument |
以區塊表示的文件,在 markdown 和 html 都未提供時使用。 |
|
options |
.options(..) |
MarkdownOptions |
全部使用預設值 | 所讀取的 Markdown 語法(選項)。 |
extensions |
.extensions(..) |
MarkdownExtension[] |
[] |
應用程式自己的語法,以自訂節點表示(Markdown 擴充功能)。 |
classes |
.classes(..) |
TextViewClasses |
{} |
按部件設定的 Tailwind class。 |
styles |
.styles(..) |
TextViewStyles |
{} |
按部件設定的行內樣式(每項為 StyleProp<TextStyle>),優先於 class。 |
selectable |
.selectable(..) |
boolean |
false |
整份文件可作為一個整體選取:選取範圍可跨越其段落、標題、儲存格和程式碼區塊(文字選取)。 |
links |
.links(..) |
"report" | "open" |
"report" |
按下連結時的行為:回報該事件(onLinkPress),搭配 "open" 時,亦會在應用程式之外開啟指向其他網站或應用程式的連結(連結)。 |
codeBlockScrollbar |
.code_block_scrollbar(..) |
boolean |
true |
程式碼區塊顯示其水平捲動指示器。 |
tableScrollbar |
.table_scrollbar(..) |
boolean |
true |
表格顯示其水平捲動指示器。 |
streamingCaret |
.streaming_caret(..) |
JSX.Element |
顯示於文件末端:若有最後一段文字則接在其後以行內方式顯示,否則獨佔一行。 | |
renderCustom |
.render_custom(|node| ..) |
(node: TextViewCustomNode) => JSX.Element |
顯示自訂節點。 | |
renderCodeBlock |
.render_code_block(|code| ..) |
(code: TextViewCodeBlock) => JSX.Element |
顯示程式碼區塊。 | |
codeBlockActions |
.code_block_actions(..) |
TextViewCodeAction[] |
[] |
每個程式碼區塊右上角上方的操作:{ id: "copy" } 為內建,其餘由應用程式提供。 |
renderCodeBlockAction |
.render_code_block_action(|action| ..) |
(action: TextViewCodeAction) => JSX.Element |
應用程式自訂操作的圖示。 | |
renderTableActions |
.render_table_actions(|table| ..) |
(table: TextViewTable) => JSX.Element |
每個表格上方的內容。 | |
renderMath |
.render_math(|math| ..) |
(math: TextViewMath) => JSX.Element |
顯示公式。 | |
renderImage |
.render_image(|image| ..) |
(image: TextViewImage) => JSX.Element |
顯示圖片。 | |
class、style |
樣式方法 | string、StyleProp<ViewStyle> |
根節點,以及區塊的一欄;其文字樣式會傳遞給每個部件(具型別的文字樣式:styles.root)。 |
在 Rust 中,按部件設定的 class 和樣式為 TextViewClasses 和 TextViewStyles;Rust 的 view 通常以 TextViewParts 來設定部件樣式。
#事件
| 事件 | Rust | 說明 |
|---|---|---|
onLinkPress |
.on_link_press(|event: &TextViewLinkPressEvent| ..) |
按下了連結:{ url }:其 URL,標題為 #anchor,註腳為 #footnote-<label>。 |
onCodeBlockAction |
.on_code_block_action(|event: &TextViewCodeActionEvent| ..) |
按下了程式碼區塊的操作(在 copy 複製之後)。 |
onImagePress |
.on_image_press(|image: &Image| ..) |
按下了不在連結內的圖片:{ image },供應用程式以較大尺寸顯示(應用程式自己的可縮放檢視器)。 |
#無障礙
螢幕閱讀器會按順序逐段朗讀文件。TextView 為其結構提供角色:標題為 header,連結為 link(其標題為提示),任務標記為帶有勾選狀態的核取方塊,disclosure 的摘要為會說明是否已展開的按鈕,圖片則朗讀其 alt 文字。