HarmonyOS 排版便笺【便笺系列 06】:纯文本快照与编辑状态收尾【共创稿事节】
便笺能够输入、选词、改格式,也能从长按菜单执行相同命令。收尾时最需要澄清的却是“保存”两个字:结束输入不等于保存内容,读出一份字符串也不等于完成文件写入。本篇把当前编辑文档、纯文本快照和编辑状态明确分开,完成一条能够解释结果的导出前置路径,并把整个系列的内容契约收拢起来。

快照必须先说明保留哪些信息
本篇继续使用 NoteTypeset 与 API 12,保留第一篇的 NoteSeed.ets 和第五篇的 TextCommands.ets。正式正文只接收文本跨度,第四篇的 BuilderSpan 仍停留在独立实验页。输出由文字和换行组成,其格式仅包含纯文本;字体、颜色、段落对齐和内嵌组件不进入这份快照。
这份快照用于给用户确认即将交给其他模块的文字内容。它在点击生成时建立,后续继续编辑不会自动改写已经生成的字符串。这样的行为便于判断一次确认对应的是哪一份内容。若产品希望预览始终跟随输入,可以设计实时派生视图,但那是另一种语义,不能一边叫快照,一边让它在后台悄悄变化。
快照是否存在需要一个独立标志。空字符串可能表示用户已经删除全部内容,也可能表示还没有生成过快照;只检查字符串是否为空无法区分这两个状态。页面使用 hasSnapshot 表示是否生成,snapshot 保存结果。这样空文档能够被明确呈现,不会被误判为读取失败或尚未操作。
格式信息被舍弃是输出类型的明确选择,而不是读取函数意外丢失内容。用户看到的标题依然较大且加粗,快照中则只有对应文字和换行。若要求再次导入后恢复同样版式,需要另行定义包含字符与段落属性的文档协议,并验证支持的类型;不能把纯文本结果套上一个富文本名称就完成了升级。
从跨度内部有效范围取得文字
新增 common/PlainSnapshot.ets,代码如下。函数读取当前跨度,并按每个文本结果的 offsetInSpan 截取有效部分。它不使用文档全局起点去截取跨度内部字符串,也不把 getSpans 的对象直接交给业务保存。输出只包含普通字符串,组件对象与控制器引用不会离开当前页面上下文。
export function readPlain(controller: RichEditorController): string {
const parts: string[] = [];
for (const item of controller.getSpans()) {
const text = item as RichEditorTextSpanResult;
if (text.textStyle === undefined || text.valueResource !== undefined) {
throw new Error('正文包含不支持的内容类型');
}
const start = text.offsetInSpan[0];
const end = text.offsetInSpan[1];
if (!Number.isInteger(start) || !Number.isInteger(end) ||
start < 0 || end < start || end > text.value.length) {
throw new Error('跨度内部范围无效');
}
parts.push(text.value.substring(start, end));
}
return parts.join('');
}
即使本次读取的是全部内容,也保留对跨度内部范围的处理。这样函数的正确性不依赖“当前所有结果刚好覆盖完整字符串”这个偶然条件,未来增加范围读取时也不会忘记坐标转换。拼接时不额外插入换行,因为真实换行已经存在于文本内容中;按跨度数量补换行,会把样式拆分误写成段落结构。
函数先检查内容类型,再检查偏移范围。图片结果强制转换成文本类型后,不会因此真的变成文字;运行时缺少文本样式时仍应拒绝。符号跨度同样需要单独处理,不能把资源标识当成用户输入的字符。当前实现遇到不支持类型就返回错误路径,避免在看似成功的字符串中悄悄遗漏一块内容。
这里的类型检查仍然不能解决 BuilderSpan 的全部回读问题。第四篇已经确认,所用基线下它的信息不能由这些查询接口完整提供,因此正式正文从入口上禁止引入这种对象。限制写入范围和检查读取结果缺一不可:入口契约负责阻止不可表达内容进入,读取检查负责暴露意外出现的非文本结果。
局部读取的坐标知识来自第二篇。全局范围用来定位文档,offsetInSpan 用来定位返回的跨度内容。把两个值都命名为 start 很容易掩盖参照系差异,所以函数内部只处理一种坐标,并将控制器查询放在函数边界。后续如果扩展选择导出,应明确传入范围的类型和语义,不把界面像素坐标混进去。

图 2:初始页面显示尚未生成快照;编辑状态来自组件回调,生成快照与结束编辑分别对应两个按钮。
页面把读取、格式与编辑结束分开
替换 pages/Index.ets。代码继续保留第五篇的选择菜单和统一格式命令,新增生成快照与结束编辑两个动作。输入准入限制仍然保留;外部拖入使用 API 12 的 null 配置拒绝,不能把空数组当成等价设置。页面隐藏时结束当前编辑状态,文档内容与已经生成的快照则保留在当前页面实例中。
import { fillSeed } from '../common/NoteSeed';
import { formatSelection } from '../common/TextCommands';
import { readPlain } from '../common/PlainSnapshot';
@Entry
@Component
struct Index {
private controller: RichEditorController = new RichEditorController();
private ready: boolean = false;
@State private snapshot: string = '';
@State private hasSnapshot: boolean = false;
@State private editing: boolean = false;
@State private status: string = '等待快照';
aboutToDisappear(): void { this.ready = false; }
onPageHide(): void {
if (this.ready) { this.controller.stopEditing(); }
}
private capture(): void {
if (!this.ready) { return; }
try {
const plain = readPlain(this.controller);
this.snapshot = plain;
this.hasSnapshot = true;
this.status = '纯文本快照已生成';
} catch (error) {
this.status = (error as Error).message;
}
}
private execute(strong: boolean): void {
if (!this.ready) { return; }
this.status = formatSelection(this.controller, strong);
}
@Builder
formatMenu() {
Column({ space: 8 }) {
Button('加粗强调').width(160).onClick(() => { this.execute(true); })
Button('恢复常规').width(160).onClick(() => { this.execute(false); })
}.padding(12).backgroundColor(Color.White).borderRadius(12)
}
build() {
Column({ space: 14 }) {
Text('正文快照').fontSize(26).fontWeight(FontWeight.Bold)
.fontColor('#263246')
Text('当前内容与快照各自独立').fontSize(14).fontColor('#68758A')
RichEditor({ controller: this.controller })
.width('100%').height(190).padding(16)
.backgroundColor(Color.White).borderRadius(16)
.onReady(() => { this.ready = true; fillSeed(this.controller); })
.onEditingChange((value: boolean) => {
if (this.ready) { this.editing = value; }
})
.onWillChange((value: RichEditorChangeValue) => {
return value.replacedImageSpans.length === 0 &&
value.replacedSymbolSpans.length === 0;
})
.allowDrop(null)
.bindSelectionMenu(RichEditorSpanType.TEXT,
this.formatMenu, RichEditorResponseType.LONG_PRESS)
.bindSelectionMenu(RichEditorSpanType.TEXT,
this.formatMenu, RichEditorResponseType.RIGHT_CLICK)
Row({ space: 12 }) {
Button('生成快照').onClick(() => { this.capture(); })
Button('结束编辑').onClick(() => {
if (this.ready) { this.controller.stopEditing(); }
})
}
Row({ space: 12 }) {
Button('强调选区').onClick(() => { this.execute(true); })
Button('恢复常规').onClick(() => { this.execute(false); })
}
Text(this.status).fontSize(14).fontColor('#4F5E8C')
Text(this.editing ? '编辑状态:编辑中' : '编辑状态:未编辑')
.fontSize(14).fontColor('#68758A')
Text(this.hasSnapshot ? (this.snapshot.length > 0 ? this.snapshot : '空文档')
: '尚未生成快照').fontSize(16).fontColor('#263246')
.width('100%').height(100).padding(16)
.backgroundColor('#E8ECF4').borderRadius(12)
}.width('100%').height('100%').padding({ left: 24, right: 24, top: 40 })
.backgroundColor('#F6F4EF')
}
}
capture 先完成整份字符串读取,成功后才同时更新快照与存在标志。读取中途遇到不支持内容,旧快照继续保留,提示改成具体错误。这样用户不会看到一份已经被部分覆盖的结果,更不会因为某个跨度读取失败就丢失上一次确认过的文本。失败状态需要说明的是本次操作,没有资格重写历史结果。
结束编辑调用 stopEditing,快照仍由生成按钮单独更新。编辑状态由 onEditingChange 回调更新,页面不把按钮点击直接当成所有系统输入状态已经改变的证明。组件可以因为用户触摸、键盘或页面切换改变编辑状态,回调才是当前显示状态的依据,按钮只是发出一次控制请求。
onPageHide 与 aboutToDisappear 的责任不同。暂时离开页面时,可以结束输入而保留文档;组件真正销毁时,只关闭普通 ready 标志。销毁回调不再修改状态变量。当前页面没有后台任务或外部订阅,退出时只需收拢编辑入口;若需要持久化,应在明确的数据层中实现写入流程,并独立处理成功与失败。
页面展示的快照框使用固定高度,只是为了让示例的两段短内容容易核对。正式产品面对长文时,应提供滚动或独立预览区域,不能把视口裁切当成内容截断。这里保存的字符串不会因为框的可见高度变小而自动变短,界面能显示多少与数据包含多少仍然需要分别检查。
用内容差异验证快照语义
先点击生成快照,结果应包含标题、一个换行和正文。然后只给正文局部加粗,再重新生成快照,两次字符串应相同,因为输出契约不保留视觉样式。这个用例验证的是样式切分不会制造多余字符或换行,而不是验证富文本格式保存。用正确的比较对象,才能得到有意义的结果。
接着生成一份快照后,在编辑器里追加文字。下方快照应保持旧内容,直到再次点击生成。若每次输入都自动改写快照,说明实现实际上变成了实时预览;这种行为可以是产品选择,但需要更改命名与确认流程,不能保留快照承诺却执行另一种语义。
再删除全部正文后生成快照,页面应显示空文档,而非尚未生成。之后重新输入文字,结束编辑,观察内容仍然存在且旧快照不被自动替换。这个组合能同时检查空文档状态、编辑结束与读取时机,不需要借助文件系统或网络服务来解释页面内的行为。
还应输入多行文字并改变局部样式,让一个自然段被拆成多个样式跨度。快照中真实换行的位置应与输入一致,不因跨度变多出现额外空行。可以把结果复制到检查工具中比较字符串内容,或记录字符序列进行对照;不要只看预览框恰好折成几行就判断换行数量相同。

图 3:四个按钮分别对应快照读取、结束输入和两种格式设置;结果框由快照存在标志决定显示内容。
对于图片、符号与拖入内容,应检查准入规则确实阻止不支持对象进入正式正文。若未来增加新的插入按钮,不能绕过同一内容契约;程序化调用也需要由业务入口控制。读取函数的类型拒绝只是一道防线,不是“先允许任意对象进入、导出时再试试看”的理由。
由于快照函数是同步读取,当前示例没有跨越等待点,点击期间不会用一份旧跨度数组异步拼接后再覆盖结果。若后续加入文件写入或远程提交,应先固定这份字符串,再交给异步模块,并为成功、失败和重试分别保存状态。页面继续编辑的新内容不应改变已经开始提交的那一份文本。
收尾时保留清楚的工程责任
六篇中的公共关系现在可以稳定下来:种子函数负责可控恢复,控制器属于当前编辑区,格式命令负责局部属性,段落接口负责排版结构,混排实验负责能力评估,快照函数负责文字提取。任何一层新增功能,都应该说明自己修改哪份内容、接受什么类型以及如何报告结果,而不是把所有动作归到保存按钮下面。
工程不包含持久化、协作编辑或无损富文本交换。需要这些能力时,下一步应从文档协议与数据层开始:定义支持的跨度类型、段落属性、资源引用和版本迁移,再让界面成为它的编辑入口。把控制器返回对象原样长期保存,并不能自然得到一个稳定协议,也无法覆盖组件版本变化带来的差异。
这个系列的收尾标准是每个操作都可以被解释和复现:恢复为何替换内容,强调为何只改两项属性,段落居中为何影响整段,标签为何不能按普通文字回读,快照为何不跟随后续输入变化。边界清楚以后,便笺即使功能不多,也已经具备继续扩展所需的可信基础。
官方资料
更多推荐

所有评论(0)