@ clarvertask/ 写
一个多功能的,以块为基础的丰富文本编辑器,用于多种应用,用Tiptap来构建,并被Notion的直观界面所启发. @clevertask/scribe 允许您无缝查看、创建和编辑丰富的文本内容,同时添加对Markdown 解析的支持.
[尖叫声] Scribe是实验性的,还没有达到1.0版本. 它的API,默认扩展,编辑器行为,以及内置的UI可以在发布之间改变,包括小发布. 在部署前将一个精确的软件包版本进行测试。 欢迎叉子和贡献.
特征
- 基于块的编辑 : 享受熟悉而直观的口号风格的编辑体验.
- 标记下方支持 : 解析和使Markdown的内容不费力地.
- 标记下贴: 将纯文本标记下粘入编辑器并自动转换为丰富的内容.
- 表格作者 : 插入、调整大小并编辑表格,其中含有保留在活动表格旁的控件.
- 普通链接 : 编辑显示文本和 URL, 并带有无障碍的 popover 和本地 Tiptap 粘贴行为 .
- Versatile 整合: 轻松整合
@clevertask/scribe输入任何需要丰富文本编辑的工程. - 查看并编辑 : 在取景模式和编辑模式之间无缝切换.
- 实验目录: 订阅标题更改并生成编辑器外的应用程序目录 .
目录
安装
npm install --save-exact @clevertask/scribe @tiptap/[email protected]
Scribe与消费者共享准确的ProseMirror运行时间。 保持一个 已安装的版本可以防止内置和自定义插件之间的身份冲突.
无头计划
当服务器或迁移工具必须在不加载的情况下解析 Scribe 内容时使用 schema 子路径 反应编辑器或 Scribe 样式表 :
npm install --save-exact @clevertask/scribe @tiptap/[email protected] @tiptap/[email protected] @tiptap/[email protected]
import { createScribeSchemaExtensions } from "@clevertask/scribe/schema";
import { generateJSON } from "@tiptap/html";
const extensions = createScribeSchemaExtensions({ enableUndoRedo: false });
const document = generateJSON(storedHtml, extensions);
返回的扩展定义了 Scribe 的持久节点和标记而不加载 Scribe 的反射 编辑 UI. 使用 Tiptap 的服务器侧 HTML 工具; 这不是无头交互 编辑。 Scribe 的菜单、占位符、建议和目录行为不属于 此切入点。 消费者拥有的节点必须附在清单后。 例如,应用程序 在解析或渲染之前必须提供扩展 包含这些参考内容.
Pin Scribe and Tiptap 到写入客户端所用的准确版本. 这个切入点使得 schema可再用;它不会使不同的schema版本互换.
Scribe 与其他文字标记,例如粗体和链接一起保留了内置代码. 这适用 仅到内置代码; 代码块文本保持不变.
服务器还可以通过相同的无头条目检查斯克利贝的文档节点能力:
import {
createScribeDocumentNodeCapabilityManifest,
createScribeSchemaExtensions,
} from "@clevertask/scribe/schema";
import { getSchema } from "@tiptap/core";
const extensions = createScribeSchemaExtensions({ enableUndoRedo: false });
const capabilities = createScribeDocumentNodeCapabilityManifest(getSchema(extensions));
capabilities.paragraph.potentialOperations; // [{ type: "replace_content" }]
capabilities.callout.potentialOperations; // [{ type: "set_attributes", attributes: ["variant"] }]
capabilities.taskItem.potentialOperations; // [{ type: "set_attributes", attributes: ["checked"] }]
capabilities.tableCell.potentialOperations; // []
该清单有意保持结构清单更改应用程序的自主性。 一个消费者可以使用 任务项目属性能力,同时界定其自身的附加或移动限制政策 任务列表项目.
这些是结构可能性,而不是申请权限. 申请仍然必须检查 授权,当前节点及其祖先,被保护后人,修订能见度, 解析器支持,并在提供或执行写入之前限制大小.
如果应用程序将消费者拥有的节点附加到Scribe schema上,它也必须通过一个精确的
能力声明 createScribeDocumentNodeCapabilityManifest启用
defineScribeDocumentNodeCapability 输入该声明。 当节点时, 显式创建失败
缺少、复制或不再匹配计划。 自定义节点可以声明没有潜力
操作,并明确保留只读.
无头表变换
相同的无头条目可以插入或删除一个精确的逻辑表格行或列并可以合并或删除 不挂起编辑器或访问DOM而分割单元格:
import { applyScribeTableTransform, createScribeSchemaExtensions } from "@clevertask/scribe/schema";
import { getSchema } from "@tiptap/core";
const schema = getSchema(createScribeSchemaExtensions({ enableUndoRedo: false }));
const document = schema.nodeFromJSON(storedDocument);
const tablePosition = 42; // ProseMirror position immediately before the table node.
const result = applyScribeTableTransform(document, tablePosition, {
type: "insert_row",
index: 1,
});
persist(result.document);
行和列使用来自ProseMirror表格网格的零基逻辑坐标,而不是物理坐标
儿童指数。 插入索引可能等于当前行数或列数;必须删除索引
命名已有的逻辑行或列。 合并矩形使用半开边框 : topRow 财务报告和审定财务报表
leftColumn 包含,而 bottomRowExclusive 财务报告和审定财务报表 rightColumnExclusive 除外。 A 类
分数 row 财务报告和审定财务报表 column 必须识别合并单元格左上方的逻辑坐标。 现有
因此,行板和相间可以影响多个物理单元格,即使请求
逻辑边界仍然准确。 回来的 before 财务报告和审定财务报表 after 几何报告逻辑行,
逻辑列和物理单元格数.
applyScribeTableTransform 是一个不可改变的,精确的shopshot操作: 它返回一个新的ProseMirror
文档并保持其输入不变。 它没有重新定位坐标或探测到另一个
写入器在读取输入快照后更改了存储的文档 。 在同时变更时,
丢弃候选人,读取最新文件,解决预定的表格和坐标,
并重新运行转换。 无效的坐标、 错误或嵌入式的表格, 以及执行的操作
不应用抛出 ScribeTableTransformError。 。 。 .
这个API只拥有schema-valid表几何. 主机应用程序拥有授权, 保护 节点规则、修订和冲突检查、破坏性预览、有效载荷限制、Yjs转换和 坚忍不拔,坚忍不拔 在"聪明的任务"中,这些支票属于合作和API 分层而不是分层.
使用量
基本用途
import "@radix-ui/themes/styles.css";
import "@clevertask/scribe/styles.css";
import { Theme } from "@radix-ui/themes";
import { Scribe, ScribeRef } from "@clevertask/scribe";
function App() {
const onContentChange = useCallback(
({ markdownContent, htmlContent, jsonContent }: ScribeOnChangeContents) => {
console.log(markdownContent, htmlContent, jsonContent);
},
[],
);
return (
<Theme>
<Scribe onContentChange={onContentChange} />
</Theme>
);
}
与裁判
import "@radix-ui/themes/styles.css";
import "@clevertask/scribe/styles.css";
import { Theme } from "@radix-ui/themes";
import { Scribe, ScribeOnChangeContents } from "@clevertask/scribe";
function App() {
const editor = useRef<ScribeRef>(null);
const resetContent = useCallback(() => {
editor.current.resetContent();
}, []);
return (
<>
<Theme>
<Scribe ref={editor} />
</Theme>
<button onClick={resetContent}>Reset content</button>
</>
);
}
使用您的应用程序 主题
import "@radix-ui/themes/styles.css";
import "@clevertask/scribe/styles.css";
import { Theme } from "@radix-ui/themes";
import { Scribe } from "@clevertask/scribe";
function App() {
return (
<Theme appearance="dark">
<Scribe />
</Theme>
);
}
表格撰写
类型 /table 插入一个带有页眉行的3×3表。 选择一个表格单元格打开附近的控件,用于添加或删除行和列,将标题行缩放并删除表格。 拖动一列边界以调整其大小 .
这个 表格布局 菜单存储每个表格的明确布局选择. 自动 保持现有的分量行为. 适合宽度 将表格包入可用的编辑器宽度并暂时禁用列重新调整大小. 水平滚动 保留了作者的列宽,给没有被保存宽度的列一个可读128px最小值,当表格需要更多空间时会向编辑器内部滚动. 切换布局保留了表格所保存的列宽.
限制高度 将表格盖上并允许垂直滚动;短的表格保持紧凑。 单位 表格布局 ~ 最大高度..,输入120至2000像素的整数并选择 应用 设置表的最大. 使用默认 清除其覆盖并使用主机 --scribe-table-max-height,有一个360px倒置。 转动 限制高度 关闭时保留所保存的最大值。 改变最大值时会留下极限切换,宽度布局,并保持粘接头偏好不变.
版式和高度选择同时适用于编辑和只读视图,当内容被保存并被加载为JSON,HTML,或Markdown时仍然完整. 主机可以使用 editor.commands.setTableLayout("scroll") 联合国 "auto", (中文(简体) ), "fit",或 "scroll", (中文(简体) ), editor.commands.setTableHeightLimit(true) (接受布尔),和 editor.commands.setTableMaxHeight(600) (接受120至2000年的全部数字,或 null 以恢复默认值),而选择则在一个可编辑的表格中。 可选 maxHeight 表格属性序列化为 data-table-max-height 在 HTML; 现有表格中不保留主机默认值.
粘接头行 滚动时保持第一行可见, 独立于 限制高度新建表格 /table 或者说 insertTable 当他们的整个第一行都包含标题单元格时,它就启用了。 现有和输入的表格保留其保存的设置;没有保存粘性偏好表格不保留。 切入时 表格布局与 限制高度 上面,信头保留在表上自己的卷轴区域上方。 关闭后,页眉沿文档的垂直滚动区域行走,直到表格结束。 这也与水平滚动和保留所保存的列宽相配合. 如果删除了标题行, 并在恢复时再次激活, 偏好保留 。 它从事编辑和只读取视图的工作. 主机可以使用 editor.commands.setTableStickyHeaderRow(true) * 使委员会能够或 false 在可编辑的表格中清除它。 用于文档滚动, 设置 --scribe-table-sticky-header-offset 在编辑器的容器上为文档工具栏离开标题上方的房间;默认为 0px。 。 。 .
键盘用户可以按 Alt + F10 在编辑表格以聚焦其控件时,使用箭头、 Home 和 End 键在动作之间移动,并按 Escape 键返回活动单元格.
简单的标题表格,没有布局,高度,或粘相头偏好序列化为GFM Markdown. 带有这些首选项的表格(包括保存的最大值) 限制高度 关闭或非活动粘性偏好),合并单元格,单元格中的多个块,调整列大小,或GFM无法代表的其他结构被保存在Markdown输出内作为已消化的原始 HTML,这样其结构就不会默默丢失.
斯克丽贝的 insertTable 命令和 /table 动作在选择时不创建表格
已经在另一张桌子里了。 含有嵌入式表格的现有文件仍可装入如此陈旧
内容没有被销毁。 这个守卫只覆盖Scribe拥有的作者路径:任意消费者
呼吁 insertContent, Markdown 表格粘贴, 富豪- HTML 表格粘贴仍然可信
点。 需要硬性无记名表担保的应用程序必须验证或过滤
路径也.
读取时的复选框
当 editable={false}。让用户检查内容中的任务列表项目
允许它们更新,明确规定 readOnlyTaskItemChecks数字 :
<Scribe
content={canonicalHtml}
editable={false}
showBarMenu={false}
readOnlyTaskItemChecks={
canUpdateContent ? { disabled: isSaving, onChange: handleCheckboxChange } : undefined
}
/>
导出 ScribeReadOnlyTaskItemCheck 事件包含 checkboxIndex (零任务项目)
命令,包括嵌入物), previousChecked,以及 checked。只应用
复选框在引用回调前本地属性,而不聚焦文本编辑器.
回调是同步的;应用程序拥有权限检查,同步持续,
状态、 错误消息, 以及失败后恢复或调和权威内容 .
使用同步的飞行后卫 disabled 在保存时。 不提供能力
只读复选框; 取消功能或删除其生效而不重新创建编辑器.
索引描述的是动作前的文档,而不是永久的项目ID. 坚持反对
应用程序的原始内容/修订版,因此并行更改不能针对不同的内容
目 录(续) 不替换 editor.getHTML() 对于精确存储的 HTML : 序列化可以使其正常化.
只有专用的回调可以识别此复选框动作 。 普通类 onContentChange 更新
保留 programmatic ,包括程序恢复和复选框更改.
此选项适用于Scribe自有编辑器. 它不会取代打电话者拥有的 externalEditor是因为
节点视图, 允许任意只读写, 或更改无头图案或可编辑模式
复选框行为.
链接
Scribe使用Tiptap的链接扩展来进行普通链接和URL粘贴. 单击已有链接
打开其以对话框为焦点的弹出器,使软件键盘关闭. 磁带
键入,然后选择 应用 或者说 删除链接。弹出后
键盘打开时的可见视图,在空间有限时滚动其形式.
打开链接QQ 在新标签中打开当前在 URL 字段中显示的地址,而不包含
对文档应用更改。 该动作因空或无效地址而禁用 .
工具栏 Link 动作仍然会立即聚焦 URL .
一个不变的标签保留了链接的内在格式化. 键盘用户可以按 Alt + F10
从链接打开窗体并逃回文档.
在选定的文字上粘贴可识别的 URL 唯一内容, 应用使用本地 Tiptap 的链接 行为;普通文本取代选择。 丰富的剪贴板内容遵循 Tiptap 解析 内容处理。 Scribe 不获取元数据或创建 Contract/Preview 卡演示文稿. 自动链接和URL粘贴使用Tiptap的本地URL/protocol识别和安全检查, 包括电子邮件和FTP链接. 手动 URL 格式接受 HTTP( S), 赤域和 root - 相对链接。 应用程序自主的任务和文件参考扩展仍分开: URL 粘贴将合格文本连接到选定的内含原子周围,而不给原子本身标记, 而只选择原子则遵循通常的粘贴替换.
遗产预览相容性
已保存 externalLinkPreview 节点仍在交互式和无头图中加载并渲染
作为普通链接,使用其作者 linkText 准确 href显示其节点身份
值和元数据在 HTML、JSON 和 Markdown 中继续序列化。 阅读文档不
转换这些节点或丢弃元数据。 不需要存储的图像或预览图像 .
导出 ExternalLinkPreview 扩展、常数、类型和 externalLinkPreview 选项
用于兼容性. autoPreviewOnPaste, (中文(简体) ), resolve 财务报告和审定财务报表 shouldPreview 已贬值
忽略; HTMLAttributes 仍然适用于遗产包装。 一个叫人的人 externalEditor
必须注册兼容扩展以读取已有的预览节点.
堕落者 insertExternalLinkPreview 财务报告和审定财务报表 updateExternalLinkPreview 命令保留
遗留的节点形状和属性,作为普通链接显示,而不获取元数据.
setExternalLinkDisplay("compact" | "card") 财务报告和审定财务报表 refreshExternalLinkPreview 返回时 false。 。 。 .
setExternalLinkDisplay("plain") 财务报告和审定财务报表 convertExternalLinkPreviewToPlain 保持明确,
可撤销的转换为普通的 Link 标记文本 。 没有自动的内容迁移.
实验目录
Scribe 可以在可编辑文档中不显示内容表块的情况下,披露内容表数据. 启用实验性 API enableTableOfContents,保留应用程序状态中的最新项目,并呼叫 scrollToTableOfContentsItem 当用户选择一个条目时.
import { Scribe, ScribeRef, ScribeTableOfContentsItem } from "@clevertask/scribe";
import { useRef, useState } from "react";
function DocumentEditor() {
const scribe = useRef<ScribeRef>(null);
const [tableOfContentsItems, setTableOfContentsItems] = useState<ScribeTableOfContentsItem[]>([]);
return (
<>
<Scribe
ref={scribe}
enableTableOfContents
onTableOfContentsChange={setTableOfContentsItems}
/>
{tableOfContentsItems.length > 0 ? (
<nav aria-label="Table of contents">
{tableOfContentsItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => scribe.current?.scrollToTableOfContentsItem(item)}
>
{item.textContent}
</button>
))}
</nav>
) : null}
</>
);
}
目前目录只跟踪默认 TipTap 标题节点 。 每个项目包括标题文本,深度,文档位置,DOM节点,以及活动/滚动状态. 这个API是实验性的,而我们在真实的文档表面验证合同.
数学表达式
Scribe的默认UI被用Radix Themes组件来命名. 装入 @radix-ui/themes/styles.css 一次在你的应用程序旁边 @clevertask/scribe/styles.css在拉迪克斯的某个地方 <Theme>。 。 。 .
使用 @tiptap/extension-mathematics。当在 HTML 中接收数学节点时,扩展会使数学:
<span data-type="inline-math" data-latex="\alpha"></span>
<div data-type="block-math" data-latex="\sum_{i=1}^{n} x_i"></div>
打字限制符(输入规则)
直接输入编辑器时,内置输入规则使用:
Inline: $$\alpha$$
Block: $$$\sum_{i=1}^{n} x_i$$$
标出下限
如果您正在使用 Tiptap 标记下扩展解析标记( 不) md2html,表示器期望:
Inline: $\alpha$
Block: $$\sum_{i=1}^{n} x_i$$
如果您的内容以 HTML (例如从服务器) 的形式到达, 请使用下面的帮助将遗留的分隔符转换为 HTML 节点, 数学扩展能理解 .
外部撤消和重做所有权
Scribe 默认允许其内置撤销并重做历史 。 设定 enableUndoRedo={false} 当不同的扩展名拥有历史时, 如 Tiptap 协作 :
<Scribe enableUndoRedo={false} extensions={[Collaboration.configure({ document: ydoc })]} />
Scribe 创建编辑器时读取此选项 。 重挂斯克丽贝来更改. 如果你通过一个 externalEditor,则在编辑器上配置历史。 不通过此选项添加另一个普通历史扩展 extensions 当合作拥有撤销和重做.
这个 createScribeEditor 帮助者接受相同的 enableUndoRedo 选项.
道具
| Prop | 类型 | 默认 | 说明 |
|---|---|---|---|
content | string | undefined | 编辑部的初始内容. 通过此道具控制的更新被折旧; 偏好 ScribeRef.setContent 在挂起后更新程序. |
ariaLabel | string | Rich text editor | 设置富文本编辑表面的可访问名称。 当页面包含多个编辑器时,使用独有的标签. |
onContentChange | (content: ScribeOnChangeContents) => void; | undefined | 当编辑器的内容发生变化时,会触发回调功能. 它以各种格式接收包含当前内容的对象(jsonContent, (中文(简体) ), htmlContent, (中文(简体) ), markdownContent) (中文(简体) ). 内部目录元数据更新被忽略. |
editable | boolean | true | 控制编辑器是否可以编辑. |
readOnlyTaskItemChecks | ScribeReadOnlyTaskItemChecks | undefined | 将 Scribe 拥有的只读编辑器选择为只检查框动作 。 见 读取时的复选框 长期责任和有待履行的国家责任. |
autoFocus | boolean | false | 控制编辑器在挂载时是否自动聚焦 . |
extensions | Extension[] | undefined | 您可以为文本编辑器设置自己的扩展名 。 有关更多信息, 检查尖端水龙头扩展道克 |
externalEditor | Editor | undefined | 使用调用器拥有的Tiptap编辑器. 呼叫者仍负责其扩展和插件生命周期,包括表格再大小,并销毁. |
enableUndoRedo | boolean | true | 启用 Scribe 内置的取消和重做历史 。 准备吧 false 当另一个扩展拥有历史。 此创建时间选项不配置调用者所有 externalEditor。 。 。 . |
externalLinkPreview | Partial<ExternalLinkPreviewOptions> | undefined | 已折旧的相容性选项 。 只有 HTML 属性影响后遗症包;元数据分辨率,预览政策和自动预览被忽略. |
editorProps | EditorProps | undefined | 一个基于尖塔的道具,可以处理高级使用案例,你可以在他们的身上读取 文档 |
showBarMenu | boolean | true | 确定是否显示文本编辑器上菜单栏。 此菜单栏显示文本格式化的选项 |
placeholderText | string | Type "/" for commands... | 更改文本编辑器的初始占位符 |
editorContentStyle | React.CSSProperties | undefined | 您可以发送 CSS 对象,在编辑内容容器中添加样式。 如果您想要限制编辑器的高度, 则有用 . |
editorContentClassName | string | undefined | 同样的想法是: editorContentStyle 但有课. |
mainContainerStyle | React.CSSProperties | undefined | 您可以发送 CSS 对象来样式化主编辑器容器 |
mainContainerClassName | string | undefined | 同样的想法是: mainContainerStyle 但有课. |
onKeyDown | KeyboardEventHandler | undefined | 在编辑器内按下密钥时触发的回调函数 。 这使得您可以处理自定义键盘快捷键 。 例如,您可以使用此道具执行“ 发送消息” 功能, 当 Ctrl + Enter 已按住 . |
enableTableOfContents | boolean | false | 实验 为标题节点启用应用程序所有目录 API. |
onTableOfContentsChange | (items: ScribeTableOfContentsItem[], isCreate?: boolean) => void | undefined | 实验 当标题文本、结构或活动/滚动状态更改时,接收内容表项目. |
帮助函数
md2html
export declare function md2html(md: string): string;
将下标记转换为 html. 如果你正在发布基于AI的响应,或者在数据库中存储下标的内容,并想在文本编辑器上显示,这都是有益的. 此函数对内容进行消毒以防止XSS攻击.
可编辑的 Scribe 实例在您粘贴纯文本标记到编辑器时也会在内部使用此转换 .
用法示例数字 :
import { md2html, Scribe } from "@clevertask/scribe";
import { Flex, Heading } from "@radix-ui/themes";
import { Message, useChat } from "@ai-sdk/react";
const ChatMessages = () => {
const { messages } = useChat({
/* For more info, see https://sdk.vercel.ai/docs/reference/ai-sdk-ui/use-chat */
});
return messages.map((message) => (
<Flex key={message.id} direction="column" mb="4">
<Heading size="4">{`${message.role}: `}</Heading>
<Scribe editable={false} showBarMenu={false} content={md2html(message.content)} />
</Flex>
));
};
html2md
export declare function html2md(html: string): string;
将 html 转换为下标 。 如果您想要将文本保存为下标的文本格式, 将文本发送到 AI 模型中是有用的 。 此函数对内容进行消毒以防止XSS攻击.
用法示例数字 :
import { html2md, Scribe } from "@clevertask/scribe";
const md = html2md("<h1>Hello world</h1>"); // Output: # Hello world
说明: Scribe 组件已曝光一个名为属性
markdownContent当onContentChange已使用。 事实上markdownContent用于输出html2md函数.
convertLegacyMathDelimiters
export declare function convertLegacyMathDelimiters(input: string): string;
将遗留的数学分隔符转换为数学扩展需要的HTML节点. 当您从包含遗留数学的服务器接收 HTML 时,此功能是有用的 \(...\) 或者说 \[...\]。 。 。 .
这个帮助器主要用于使用遗留格式存储数学表达的更古老的斯克利贝版本的消费者升级. 其目的是要准确,但前一个格式模棱两可(没有明确) $$ 因此,转换是最佳的,并非在每一种情况下都得到保证.
支持的遗产划界人数字 :
\(...\)用于内置数学\[...\]用于块数学(...)财务报告和审定财务报表[...]当内容看起来像 LaTeX( 容器)\, (中文(简体) ),^,或_(中文(简体) )
用法示例数字 :
import { convertLegacyMathDelimiters, md2html, Scribe } from "@clevertask/scribe";
const html = md2html(convertLegacyMathDelimiters(rawMarkdown));
// OR
const html = convertLegacyMathDelimiters(htmlContent);
<Scribe editable={false} showBarMenu={false} content={html} />;
如果您已经从服务器收到 HTML, 请打电话 convertLegacyMathDelimiters 在渲染前直接使用 HTML .
路线图
我们一直在努力改进@clevertask/写作。 以下是我们计划执行的一些特征:
- 新建默认块/ 扩展 : 例如图像、视频、点名和表格块
- E2E测试:将确保这一构成部分如预期的那样运作.
我们对这些即将到来的特点感到兴奋,欢迎社区的任何反馈或贡献. 如果您有任何建议或想对其中的任何功能作出贡献,请打开一个问题或向我们的GitHub寄存器提交拉动请求.
释放进程
更改完成后, 公布为两个明确的 GitHub 动作 main数字 :
- 运行 创建发行版本 从
main并输入版本以发布. - 等它执行版本,创建标记,并创建"GitHub"发行.
- 运行 发布软件包 从永恒的
v<version>标记并选择latest或者说nextnpm标签. - 在更新消费者之前,请在npm注册中确认新版本.
许可证
麻省理工学院
贷项
这个项目建立在优秀的之上 块编辑器 (原始内容存档于2018-03-21). Resource by Sachin Chaurasiya. 我们真诚地感谢他们所做的工作。 < 3 个