#末尾标记
suffix 在当前可见内容的末尾追加一个小标记。可以选择终端风格的方块光标,或柔和呼吸的圆点。标记跟随文字,适用于段落、列表、行内格式、表格和代码块。
播放完成后保留标记,便于查看位置。实际应用中,将 streaming 设为 false 即可移除。
切换内容类型,可以查看不同结尾的表现;暂停或重播,可以观察标记的位置;选择自定义类型,可以体验 renderer 的扩展方式。演示播放完成后仍保留标记,方便检查间距。
#使用方式
选择对应框架查看代码。Core 示例中的 mappers 传入 Core 输入,并显式启用 mapper;组件通过 streaming.effect 启用相同效果,并内置视觉渲染器。
import type { MapperPluggable } from '@fluxdown/core';
import { Smooth } from '@fluxdown/core-plugins/plugins/mapper/smooth';
import { Suffix } from '@fluxdown/core-plugins/plugins/mapper/suffix';
const mappers: MapperPluggable[] = [
[Smooth, { enabled: true }],
[Suffix, { enabled: true, type: 'cursor' }],
];import { Fluxdown } from 'fluxdown';
type MessageProps = {
text: string;
};
export function Message({ text }: MessageProps) {
return (
<Fluxdown
streaming={{ effect: ['suffix', { type: 'cursor' }] }}
text={text}
/>
);
}<script setup lang="ts">
import { Fluxdown } from '@fluxdown/vue';
defineProps<{ text: string }>();
</script>
<template>
<Fluxdown
:streaming="{ effect: ['suffix', { type: 'cursor' }] }"
:text="text"
/>
</template><script lang="ts">
import { Fluxdown } from '@fluxdown/svelte';
type MessageProps = {
text: string;
};
let { text }: MessageProps = $props();
</script>
<Fluxdown
streaming={{ effect: ['suffix', { type: 'cursor' }] }}
{text}
/>import type { StreamingConfig } from '@fluxdown/angular';
import { Component, input } from '@angular/core';
import { Fluxdown } from '@fluxdown/angular';
@Component({
selector: 'app-message',
imports: [Fluxdown],
template: `<fluxdown [streaming]="streaming" [text]="text()" />`,
})
export class Message {
readonly text = input.required<string>();
readonly streaming: StreamingConfig = {
effect: ['suffix', { type: 'cursor' }],
};
}import { Fluxdown } from '@fluxdown/react-native';
type MessageProps = {
text: string;
};
export function Message({ text }: MessageProps) {
return (
<Fluxdown
streaming={{ effect: ['suffix', { type: 'cursor' }] }}
text={text}
/>
);
}import { Fluxdown } from '@fluxdown/lynx';
type MessageProps = {
text: string;
};
export function Message({ text }: MessageProps) {
return (
<Fluxdown
streaming={{ effect: ['suffix', { type: 'cursor' }] }}
text={text}
/>
);
}import { Fluxdown } from '@fluxdown/solid';
type MessageProps = {
text: string;
};
export function Message(props: MessageProps) {
return (
<Fluxdown
streaming={{ effect: ['suffix', { type: 'cursor' }] }}
text={props.text}
/>
);
}import { Fluxdown } from '@fluxdown/taro';
type MessageProps = {
text: string;
};
export function Message({ text }: MessageProps) {
return (
<Fluxdown
streaming={{ effect: ['suffix', { type: 'cursor' }] }}
text={text}
/>
);
}将 type 设为 'dot' 即可使用圆点。effect: 'suffix' 默认使用方块光标。两种形状都继承当前文字颜色,随文字缩放,呼吸动画遵循系统的 prefers-reduced-motion 设置。
只要 effect 启用,标记就会保持显示。响应完成后,将 streaming 设为 false;如果需要继续保留其他流式行为,也可以只把 suffix 的 enabled 设为 false:
import type { MapperPluggable } from '@fluxdown/core';
import { Smooth } from '@fluxdown/core-plugins/plugins/mapper/smooth';
import { Suffix } from '@fluxdown/core-plugins/plugins/mapper/suffix';
import { MutableState, toClosure } from 'stative';
const isStreaming = MutableState.of(true);
const active = toClosure(isStreaming);
const mappers: MapperPluggable[] = [
[Smooth, { enabled: true }],
[Suffix, { enabled: active, type: 'dot' }],
];import { Fluxdown } from 'fluxdown';
type MessageProps = {
text: string;
isStreaming: boolean;
};
export function Message({ text, isStreaming }: MessageProps) {
return (
<Fluxdown
streaming={{
smooth: true,
effect: ['suffix', { type: 'dot', enabled: isStreaming }],
}}
text={text}
/>
);
}<script setup lang="ts">
import { Fluxdown } from '@fluxdown/vue';
defineProps<{ text: string; isStreaming: boolean }>();
</script>
<template>
<Fluxdown
:streaming="{
smooth: true,
effect: ['suffix', { type: 'dot', enabled: isStreaming }],
}"
:text="text"
/>
</template><script lang="ts">
import { Fluxdown } from '@fluxdown/svelte';
type MessageProps = {
text: string;
isStreaming: boolean;
};
let { text, isStreaming }: MessageProps = $props();
</script>
<Fluxdown
streaming={{
smooth: true,
effect: ['suffix', { type: 'dot', enabled: isStreaming }],
}}
{text}
/>import type { StreamingConfig } from '@fluxdown/angular';
import { Component, input, computed } from '@angular/core';
import { Fluxdown } from '@fluxdown/angular';
@Component({
selector: 'app-message',
imports: [Fluxdown],
template: `<fluxdown [streaming]="streaming()" [text]="text()" />`,
})
export class Message {
readonly text = input.required<string>();
readonly isStreaming = input(false);
readonly streaming = computed<StreamingConfig>(() => {
return {
smooth: true,
effect: ['suffix', { type: 'dot', enabled: this.isStreaming() }],
};
});
}import { Fluxdown } from '@fluxdown/react-native';
type MessageProps = {
text: string;
isStreaming: boolean;
};
export function Message({ text, isStreaming }: MessageProps) {
return (
<Fluxdown
streaming={{
smooth: true,
effect: ['suffix', { type: 'dot', enabled: isStreaming }],
}}
text={text}
/>
);
}import { Fluxdown } from '@fluxdown/lynx';
type MessageProps = {
text: string;
isStreaming: boolean;
};
export function Message({ text, isStreaming }: MessageProps) {
return (
<Fluxdown
streaming={{
smooth: true,
effect: ['suffix', { type: 'dot', enabled: isStreaming }],
}}
text={text}
/>
);
}import { Fluxdown } from '@fluxdown/solid';
type MessageProps = {
text: string;
isStreaming: boolean;
};
export function Message(props: MessageProps) {
return (
<Fluxdown
streaming={{
smooth: true,
effect: ['suffix', { type: 'dot', enabled: props.isStreaming }],
}}
text={props.text}
/>
);
}import { Fluxdown } from '@fluxdown/taro';
type MessageProps = {
text: string;
isStreaming: boolean;
};
export function Message({ text, isStreaming }: MessageProps) {
return (
<Fluxdown
streaming={{
smooth: true,
effect: ['suffix', { type: 'dot', enabled: isStreaming }],
}}
text={text}
/>
);
}将标记和入场效果一起使用:
import type { MapperPluggable } from '@fluxdown/core';
import { Smooth } from '@fluxdown/core-plugins/plugins/mapper/smooth';
import { Scale } from '@fluxdown/core-plugins/plugins/mapper/scale';
import { Suffix } from '@fluxdown/core-plugins/plugins/mapper/suffix';
const mappers: MapperPluggable[] = [
[Smooth, { enabled: true }],
[Scale, { enabled: true }],
[Suffix, { enabled: true, type: 'dot' }],
];import { Fluxdown } from 'fluxdown';
type MessageProps = {
text: string;
};
export function Message({ text }: MessageProps) {
return (
<Fluxdown
streaming={{ effect: ['scale', ['suffix', { type: 'dot' }]] }}
text={text}
/>
);
}<script setup lang="ts">
import { Fluxdown } from '@fluxdown/vue';
defineProps<{ text: string }>();
</script>
<template>
<Fluxdown
:streaming="{ effect: ['scale', ['suffix', { type: 'dot' }]] }"
:text="text"
/>
</template><script lang="ts">
import { Fluxdown } from '@fluxdown/svelte';
type MessageProps = {
text: string;
};
let { text }: MessageProps = $props();
</script>
<Fluxdown
streaming={{ effect: ['scale', ['suffix', { type: 'dot' }]] }}
{text}
/>import type { StreamingConfig } from '@fluxdown/angular';
import { Component, input } from '@angular/core';
import { Fluxdown } from '@fluxdown/angular';
@Component({
selector: 'app-message',
imports: [Fluxdown],
template: `<fluxdown [streaming]="streaming" [text]="text()" />`,
})
export class Message {
readonly text = input.required<string>();
readonly streaming: StreamingConfig = {
effect: ['scale', ['suffix', { type: 'dot' }]],
};
}import { Fluxdown } from '@fluxdown/react-native';
type MessageProps = {
text: string;
};
export function Message({ text }: MessageProps) {
return (
<Fluxdown
streaming={{ effect: ['scale', ['suffix', { type: 'dot' }]] }}
text={text}
/>
);
}import { Fluxdown } from '@fluxdown/lynx';
type MessageProps = {
text: string;
};
export function Message({ text }: MessageProps) {
return (
<Fluxdown
streaming={{ effect: ['scale', ['suffix', { type: 'dot' }]] }}
text={text}
/>
);
}import { Fluxdown } from '@fluxdown/solid';
type MessageProps = {
text: string;
};
export function Message(props: MessageProps) {
return (
<Fluxdown
streaming={{ effect: ['scale', ['suffix', { type: 'dot' }]] }}
text={props.text}
/>
);
}import { Fluxdown } from '@fluxdown/taro';
type MessageProps = {
text: string;
};
export function Message({ text }: MessageProps) {
return (
<Fluxdown
streaming={{ effect: ['scale', ['suffix', { type: 'dot' }]] }}
text={text}
/>
);
}#渲染自定义类型
type 的类型是 'cursor' | 'dot' | (string & {})。所有值都会生成相同结构的 HAST 节点:一个 span,其 dataFluxdownSuffix 属性保存类型。默认 renderer 只识别 cursor 和 dot,遇到其他值直接返回 null。未知类型是提供给 renderer 的信息,不会作为文字显示。
通过各框架的渲染插件显示一个笑脸。SuffixRenderer 复用内置标记的布局与呼吸动画;Vue 使用 h,Svelte 使用 createElement,Lynx 将 JSX 渲染函数放在插件类外:
import type {
ReactRenderMatchParams,
ReactRenderParams,
} from '@fluxdown/react-plugins/base';
import {
isSuffixNode,
SUFFIX_DATA_ATTR,
} from '@fluxdown/core-plugins/plugins/mapper/suffix';
import { BaseReactRenderPlugin } from '@fluxdown/react-plugins/base';
import { SuffixRenderer } from '@fluxdown/react-plugins/plugins/effect-suffix';
import { Fluxdown } from 'fluxdown';
class SmileRenderPlugin extends BaseReactRenderPlugin {
static readonly key = 'render-smile';
match({ node }: ReactRenderMatchParams) {
return isSuffixNode(node) && node.properties[SUFFIX_DATA_ATTR] === 'smile';
}
render({ node }: ReactRenderParams) {
if (!isSuffixNode(node) || node.properties[SUFFIX_DATA_ATTR] !== 'smile') {
return null;
}
return <SuffixRenderer type="smile">🙂</SuffixRenderer>;
}
}
type MessageProps = {
text: string;
};
export function Message({ text }: MessageProps) {
return (
<Fluxdown
plugins={{ renders: [SmileRenderPlugin] }}
streaming={{ effect: ['suffix', { type: 'smile' }] }}
text={text}
/>
);
}<script setup lang="ts">
import type {
VueRenderMatchParams,
VueRenderParams,
} from '@fluxdown/vue-plugins/base';
import {
isSuffixNode,
SUFFIX_DATA_ATTR,
} from '@fluxdown/core-plugins/plugins/mapper/suffix';
import { BaseVueRenderPlugin } from '@fluxdown/vue-plugins/base';
import { SuffixRenderer } from '@fluxdown/vue-plugins/plugins/effect-suffix';
import { Fluxdown } from '@fluxdown/vue';
import { h } from 'vue';
class SmileRenderPlugin extends BaseVueRenderPlugin {
static readonly key = 'render-smile';
match({ node }: VueRenderMatchParams) {
return isSuffixNode(node) && node.properties[SUFFIX_DATA_ATTR] === 'smile';
}
render({ node }: VueRenderParams) {
if (!isSuffixNode(node) || node.properties[SUFFIX_DATA_ATTR] !== 'smile') {
return null;
}
return h(SuffixRenderer, { type: 'smile', children: '🙂' });
}
}
const plugins = { renders: [SmileRenderPlugin] };
defineProps<{ text: string }>();
</script>
<template>
<Fluxdown
:plugins="plugins"
:streaming="{ effect: ['suffix', { type: 'smile' }] }"
:text="text"
/>
</template><script lang="ts">
import type {
SvelteRenderMatchParams,
SvelteRenderParams,
} from '@fluxdown/svelte-plugins/base';
import {
isSuffixNode,
SUFFIX_DATA_ATTR,
} from '@fluxdown/core-plugins/plugins/mapper/suffix';
import { BaseSvelteRenderPlugin, createElement } from '@fluxdown/svelte-plugins/base';
import { SuffixRenderer } from '@fluxdown/svelte-plugins/plugins/effect-suffix';
import { Fluxdown } from '@fluxdown/svelte';
class SmileRenderPlugin extends BaseSvelteRenderPlugin {
static readonly key = 'render-smile';
match({ node }: SvelteRenderMatchParams) {
return isSuffixNode(node) && node.properties[SUFFIX_DATA_ATTR] === 'smile';
}
render({ node }: SvelteRenderParams) {
if (!isSuffixNode(node) || node.properties[SUFFIX_DATA_ATTR] !== 'smile') {
return null;
}
return createElement(SuffixRenderer, { type: 'smile' }, '🙂');
}
}
const plugins = { renders: [SmileRenderPlugin] };
type MessageProps = {
text: string;
};
let { text }: MessageProps = $props();
</script>
<Fluxdown
{plugins}
streaming={{ effect: ['suffix', { type: 'smile' }] }}
{text}
/>import type {
AngularRenderMatchParams,
AngularRenderParams,
} from '@fluxdown/angular-plugins/typings';
import type { StreamingConfig } from '@fluxdown/angular';
import { Component, input } from '@angular/core';
import {
BaseAngularRenderPlugin,
createComponentNode,
} from '@fluxdown/angular-plugins/base';
import { SuffixRenderer } from '@fluxdown/angular-plugins/plugins/effect-suffix';
import { Fluxdown } from '@fluxdown/angular';
import {
isSuffixNode,
SUFFIX_DATA_ATTR,
} from '@fluxdown/core-plugins/plugins/mapper/suffix';
class SmileRenderPlugin extends BaseAngularRenderPlugin {
static readonly key = 'render-smile';
match({ node }: AngularRenderMatchParams) {
return isSuffixNode(node) && node.properties[SUFFIX_DATA_ATTR] === 'smile';
}
render({ node }: AngularRenderParams) {
if (!isSuffixNode(node) || node.properties[SUFFIX_DATA_ATTR] !== 'smile') {
return null;
}
return createComponentNode(
SuffixRenderer,
{ Raw: null, type: 'smile' },
{ tagName: 'span', children: '🙂' },
);
}
}
@Component({
selector: 'app-message',
imports: [Fluxdown],
template: `<fluxdown
[plugins]="plugins"
[streaming]="streaming"
[text]="text()"
/>`,
})
export class Message {
readonly text = input.required<string>();
readonly plugins = { renders: [SmileRenderPlugin] };
readonly streaming: StreamingConfig = {
effect: ['suffix', { type: 'smile' }],
};
}import type {
ReactRenderMatchParams,
ReactRenderParams,
} from '@fluxdown/react-native-plugins/base';
import {
isSuffixNode,
SUFFIX_DATA_ATTR,
} from '@fluxdown/core-plugins/plugins/mapper/suffix';
import { BaseReactRenderPlugin } from '@fluxdown/react-native-plugins/base';
import { SuffixRenderer } from '@fluxdown/react-native-plugins/plugins/effect-suffix';
import { Fluxdown } from '@fluxdown/react-native';
class SmileRenderPlugin extends BaseReactRenderPlugin {
static readonly key = 'render-smile';
match({ node }: ReactRenderMatchParams) {
return isSuffixNode(node) && node.properties[SUFFIX_DATA_ATTR] === 'smile';
}
render({ node }: ReactRenderParams) {
if (!isSuffixNode(node) || node.properties[SUFFIX_DATA_ATTR] !== 'smile') {
return null;
}
return (
<SuffixRenderer Raw={null} type="smile">
🙂
</SuffixRenderer>
);
}
}
type MessageProps = {
text: string;
};
export function Message({ text }: MessageProps) {
return (
<Fluxdown
plugins={{ renders: [SmileRenderPlugin] }}
streaming={{ effect: ['suffix', { type: 'smile' }] }}
text={text}
/>
);
}import type {
LynxRenderMatchParams,
LynxRenderParams,
} from '@fluxdown/lynx-plugins/base';
import {
isSuffixNode,
SUFFIX_DATA_ATTR,
} from '@fluxdown/core-plugins/plugins/mapper/suffix';
import { BaseLynxRenderPlugin } from '@fluxdown/lynx-plugins/base';
import { SuffixRenderer } from '@fluxdown/lynx-plugins/plugins/effect-suffix';
import { Fluxdown } from '@fluxdown/lynx';
function renderSmile({ node }: LynxRenderParams) {
if (!isSuffixNode(node) || node.properties[SUFFIX_DATA_ATTR] !== 'smile') {
return null;
}
return <SuffixRenderer type="smile">🙂</SuffixRenderer>;
}
class SmileRenderPlugin extends BaseLynxRenderPlugin {
static readonly key = 'render-smile';
match({ node }: LynxRenderMatchParams) {
return isSuffixNode(node) && node.properties[SUFFIX_DATA_ATTR] === 'smile';
}
readonly render = renderSmile;
}
type MessageProps = {
text: string;
};
export function Message({ text }: MessageProps) {
return (
<Fluxdown
plugins={{ renders: [SmileRenderPlugin] }}
streaming={{ effect: ['suffix', { type: 'smile' }] }}
text={text}
/>
);
}import type {
SolidRenderMatchParams,
SolidRenderParams,
} from '@fluxdown/solid-plugins/base';
import {
isSuffixNode,
SUFFIX_DATA_ATTR,
} from '@fluxdown/core-plugins/plugins/mapper/suffix';
import { BaseSolidRenderPlugin } from '@fluxdown/solid-plugins/base';
import { SuffixRenderer } from '@fluxdown/solid-plugins/plugins/effect-suffix';
import { Fluxdown } from '@fluxdown/solid';
import { Show } from 'solid-js';
class SmileRenderPlugin extends BaseSolidRenderPlugin {
static readonly key = 'render-smile';
match({ node }: SolidRenderMatchParams) {
return isSuffixNode(node) && node.properties[SUFFIX_DATA_ATTR] === 'smile';
}
render(props: SolidRenderParams) {
return (
<Show
when={
isSuffixNode(props.node) &&
props.node.properties[SUFFIX_DATA_ATTR] === 'smile'
}
>
<SuffixRenderer type="smile">🙂</SuffixRenderer>
</Show>
);
}
}
type MessageProps = {
text: string;
};
export function Message(props: MessageProps) {
return (
<Fluxdown
plugins={{ renders: [SmileRenderPlugin] }}
streaming={{ effect: ['suffix', { type: 'smile' }] }}
text={props.text}
/>
);
}import type {
ReactRenderMatchParams,
ReactRenderParams,
} from '@fluxdown/taro-plugins/base';
import {
isSuffixNode,
SUFFIX_DATA_ATTR,
} from '@fluxdown/core-plugins/plugins/mapper/suffix';
import { BaseReactRenderPlugin } from '@fluxdown/taro-plugins/base';
import { SuffixRenderer } from '@fluxdown/taro-plugins/plugins/effect-suffix';
import { Fluxdown } from '@fluxdown/taro';
class SmileRenderPlugin extends BaseReactRenderPlugin {
static readonly key = 'render-smile';
match({ node }: ReactRenderMatchParams) {
return isSuffixNode(node) && node.properties[SUFFIX_DATA_ATTR] === 'smile';
}
render({ node }: ReactRenderParams) {
if (!isSuffixNode(node) || node.properties[SUFFIX_DATA_ATTR] !== 'smile') {
return null;
}
return <SuffixRenderer type="smile">🙂</SuffixRenderer>;
}
}
type MessageProps = {
text: string;
};
export function Message({ text }: MessageProps) {
return (
<Fluxdown
plugins={{ renders: [SmileRenderPlugin] }}
streaming={{ effect: ['suffix', { type: 'smile' }] }}
text={text}
/>
);
}相同优先级下,传入的 renderer 先于预设处理 smile,其他类型仍由预设处理。上面的演示使用了相同的插件与动画。
Core 示例用 MutableState 保存接收状态;完成时调用 isStreaming.next(false),销毁 Core 后再调用 active.destroy() 和 isStreaming.destroy() 清理外部状态。
#代码块与 Core
代码块中的标记跟在可见代码之后,与最后一个字符保持在同一行。代码末尾如果包含显式空行,标记会出现在该空行上。标记独立于代码内容,复制代码时不会被带入。自定义代码 slot 需要在高亮后的代码内容之后渲染 suffix 属性,才能保留这个行为。
Core 的 Suffix mapper 将标记插入当前可见 HAST 的末尾,不增加文本长度,具体外观由 renderer 决定。
Suffixmapper、SuffixConfig、SuffixType、SUFFIX_DATA_ATTR和isSuffixNode从@fluxdown/core-plugins/plugins/mapper/suffix导出。
#配置
以下字段属于 ['suffix', config] 中的 suffix 配置对象。
#enabled 可选
类型: boolean
默认值: true
在当前可见内容的末尾显示标记。生成完成后可以单独禁用,也可以将 streaming 设为 false 关闭所有流式行为。
#type 可选
类型: 'cursor' | 'dot' | (string & {})
默认值: 'cursor'
选择方块光标或圆点。所有类型都保存在相同的 HAST 标记节点上;默认 renderer 对未知类型返回 null。