HarmonyOS 字幕校对台连载04:逐句编辑与原文事务提交【共创稿事节】

字幕列表能定位和检查冲突以后,编辑一条字幕就不再是改一个文本框。结束时间改变,原文、时间索引、冲突组和重叠对都需要同步更新;如果其中一项计算失败,页面不能停在半新半旧的状态。草稿还可能来自旧版本文档,直接覆盖会把其他入口已经完成的修改丢掉。

字幕草稿到完整文档的事务提交

一、草稿保存身份和基准版本

继续使用SrtParser、CueIndex与ConflictGroups三个模块,沿用Cue.id作为编辑身份,毫秒区间和左闭右开规则不变。新增model/EditorStore.ets存放前三段代码,第四段替换pages/Index.ets。本文的事务指内存中文档状态的一次整体提交,文件落盘应在此基础上另行接入持久化流程。

草稿包含目标id、开始编辑时的文档版本、起止时间和正文。它独立于正式Cue,输入变化只更新草稿。正式快照则同时持有Cue集合、规范化原文、索引和冲突组,界面读取同一版本,避免每个区域各自解释“最新数据”。

import { Cue, parseSrt, formatTime, normalize } from './SrtParser';
import { IndexedCue, buildIndex } from './CueIndex';
import { ConflictGroup, buildGroups } from './ConflictGroups';

export class CueDraft {
  id: string;
  baseVersion: number;
  startMs: number;
  endMs: number;
  text: string;
  constructor(id: string, baseVersion: number, startMs: number,
    endMs: number, text: string) {
    this.id = id; this.baseVersion = baseVersion;
    this.startMs = startMs; this.endMs = endMs; this.text = text;
  }
}

export class EditSnapshot {
  version: number;
  cues: Cue[];
  source: string;
  index: IndexedCue[];
  groups: ConflictGroup[];
  constructor(version: number, cues: Cue[], source: string,
    index: IndexedCue[], groups: ConflictGroup[]) {
    this.version = version; this.cues = cues; this.source = source;
    this.index = index; this.groups = groups;
  }
}

export function cloneCues(cues: Cue[]): Cue[] {
  const copy: Cue[] = [];
  for (let i = 0; i < cues.length; i++) {
    const c = cues[i];
    copy.push(new Cue(c.id, c.number, c.startMs, c.endMs, c.text));
  }
  return copy;
}

export function renderSource(cues: Cue[]): string {
  const blocks: string[] = [];
  for (let i = 0; i < cues.length; i++) {
    const c = cues[i];
    blocks.push(`${c.number}\n${formatTime(c.startMs)} --> ` +
      `${formatTime(c.endMs)}\n${c.text}`);
  }
  return blocks.join('\n\n');
}

这里保留原序号和原文顺序,不因为某条字幕开始时间变化就把它移到另一个位置。索引负责按时间排序,文档负责保留用户的编辑上下文。生成原文使用规范化换行,不承诺逐字保留最初文件的空格和换行样式;业务字段保持一致才是本节的往返检查目标。

二、先推导完整下一版

deriveSnapshot先复制Cue,再生成原文并重新解析,确认原文能够无歧义地表达这些字段,随后建立索引与冲突组。正文中的空行可能被SRT解释成新的块,不能只检查字符串非空;往返后的数量、序号、时间和正文都要对应。

export function deriveSnapshot(cues: Cue[], version: number): EditSnapshot {
  if (!Number.isSafeInteger(version) || version < 1) {
    throw new Error('文档版本不合法');
  }
  const copy = cloneCues(cues);
  const source = renderSource(copy);
  const parsed = parseSrt(source);
  if (parsed.length !== copy.length) {
    throw new Error('正文包含无法按当前规则保存的分块');
  }
  for (let i = 0; i < copy.length; i++) {
    const a = copy[i];
    const b = parsed[i];
    if (a.number !== b.number || a.startMs !== b.startMs ||
        a.endMs !== b.endMs || a.text !== b.text) {
      throw new Error('原文往返字段不一致');
    }
  }
  const index = buildIndex(copy);
  const groups = buildGroups(copy);
  return new EditSnapshot(version, copy, source, index, groups);
}

往返解析得到的临时id不用于替换正式身份,因为id属于编辑模型,并没有独立写入SRT。正式快照仍保留copy中的原id;校验比较的是SRT实际承载的字段。后续支持插入、删除或重新编号时,也要把编辑身份与文件序号分开处理。

构建过程只使用局部变量,没有提前更新页面。原文解析、索引或分组任何一步失败,都直接抛出错误,调用者继续保留上一版正式快照。不要先更新Cue,再逐个尝试刷新其他派生结果,那样每增加一种诊断,就会增加一条需要手工回滚的路径。

三、版本检查在修改之前完成

EditorStore保存唯一的正式快照。提交先检查草稿的baseVersion,再按id寻找目标,复制集合后修改其中一条。全部下一版内容推导成功,才执行一次this.current赋值。无变化提交返回false,也不增加版本。

export class EditorStore {
  private current: EditSnapshot;
  constructor(source: string) {
    this.current = deriveSnapshot(parseSrt(source), 1);
  }
  read(): EditSnapshot {
    return deriveSnapshot(this.current.cues, this.current.version);
  }
  commit(draft: CueDraft): boolean {
    if (draft.baseVersion !== this.current.version) {
      throw new Error('文档已更新,请重新载入草稿');
    }
    const nextCues = cloneCues(this.current.cues);
    const index = nextCues.findIndex((cue: Cue) => cue.id === draft.id);
    if (index < 0) { throw new Error('目标字幕已不存在'); }
    const old = nextCues[index];
    const text = normalize(draft.text).trim();
    if (text.length === 0) { throw new Error('字幕正文不能为空'); }
    if (old.startMs === draft.startMs && old.endMs === draft.endMs &&
        old.text === text) {
      return false;
    }
    nextCues[index] = new Cue(old.id, old.number,
      draft.startMs, draft.endMs, text);
    const next = deriveSnapshot(nextCues, this.current.version + 1);
    this.current = next;
    return true;
  }
}

read返回独立快照,使页面或调试代码修改读到的对象时不会碰到正式状态。当前实现为清楚表达隔离关系,会重新建立派生数据;规模增大后可以使用不可变快照或按版本缓存,但必须继续限制外部对正式集合的修改入口。

旧版本草稿不自动覆盖,也不偷偷与新文本合并。发生冲突后可以让用户重新载入最新条目,再决定是否恢复自己的输入。取消草稿只需要丢弃本地字段并重新读取正式快照,不需要构造一个相反事务去修补模型。

四、输入框只维护草稿字段

以下页面继续使用前节四条字幕,固定编辑衔接句。将其结束时间由4000改成3500,并修改正文后提交,第二句与衔接句只在3500端点相接,不再形成重叠;第一句与衔接句仍重叠,因此结果从两对重叠变成一对。界面可同时核对版本与冲突摘要。

import { Cue } from '../model/SrtParser';
import { CueDraft, EditSnapshot, EditorStore } from '../model/EditorStore';

@Entry
@Component
struct Index {
  private store: EditorStore = new EditorStore(
    '1\n00:00:01,000 --> 00:00:03,000\n第一句字幕\n\n' +
    '2\n00:00:03,500 --> 00:00:05,000\n第二句字幕\n\n' +
    '3\n00:00:02,500 --> 00:00:04,000\n衔接字幕\n\n' +
    '4\n00:00:05,000 --> 00:00:06,500\n收尾字幕');
  private baseVersion: number = 1;
  @State version: number = 1;
  @State startText: string = '';
  @State endText: string = '';
  @State draftText: string = '';
  @State message: string = '';
  @State summary: string = '';

  aboutToAppear(): void { this.loadDraft(); }
  private loadDraft(): void {
    const view: EditSnapshot = this.store.read();
    const cue = view.cues.find((c: Cue) => c.id === 'cue-3');
    if (cue === undefined) { throw new Error('找不到衔接字幕'); }
    this.baseVersion = view.version;
    this.version = view.version;
    this.startText = cue.startMs.toString();
    this.endText = cue.endMs.toString();
    this.draftText = cue.text;
    let pairs: number = 0;
    for (let i = 0; i < view.groups.length; i++) {
      pairs += view.groups[i].pairs.length;
    }
    this.summary = `${view.groups.length} 个冲突组 · ${pairs} 对重叠`;
  }
  private save(): void {
    try {
      if (!/^\d+$/.test(this.startText.trim()) ||
          !/^\d+$/.test(this.endText.trim())) {
        throw new Error('时间须为非负整数毫秒');
      }
      const changed = this.store.commit(new CueDraft('cue-3', this.baseVersion,
        Number(this.startText.trim()), Number(this.endText.trim()), this.draftText));
      this.loadDraft();
      this.message = changed ? '已提交,索引与冲突结果同步更新' : '内容未变化';
    } catch (error) { this.message = (error as Error).message; }
  }
  build() {
    Scroll() {
      Column({ space: 16 }) {
        Text('字幕校对台').fontSize(26).fontWeight(FontWeight.Bold)
        Text('逐句编辑').fontColor('#6B8180')
        Text(`文档版本 ${this.version}`).fontSize(18)
        Text('开始 / ms').width('100%')
        TextInput({ text: this.startText }).type(InputType.Number)
          .onChange((v: string) => { this.startText = v; this.message = '草稿已修改'; })
        Text('结束 / ms').width('100%')
        TextInput({ text: this.endText }).type(InputType.Number)
          .onChange((v: string) => { this.endText = v; this.message = '草稿已修改'; })
        TextArea({ text: this.draftText }).height(160).width('100%')
          .onChange((v: string) => { this.draftText = v; this.message = '草稿已修改'; })
        Row({ space: 16 }) {
          Button('提交修改').backgroundColor('#176C60').onClick(() => this.save())
          Button('取消草稿').onClick(() => {
            this.loadDraft(); this.message = '草稿已取消';
          })
        }
        Text(this.message).fontColor('#176C60').width('100%')
        Text(this.summary).fontColor('#6B8180').width('100%')
      }.padding(16).width('100%')
    }.width('100%').height('100%').backgroundColor('#F0F9F7')
  }
}

开发环境中的完整下一版构建与原子替换

五、围绕失败路径检查一致性

先保存一次合法修改,再用相同baseVersion提交另一份旧草稿,应得到版本冲突,正式原文与全部派生结果保持不变。再分别输入空正文、结束早于开始、超出时间格式范围和带分块空行的文本,确认失败不会增加版本,也不会改变索引中的命中内容。

合法路径则需要同时检查原文重新解析后的字段、locate返回的字幕、冲突组成员与重叠对数量。只检查输入框已显示新文字不足以判断提交完整。特别是把4000改成3500的例子,能够检查端点规则是否在编辑、定位和冲突诊断三个位置保持一致。

手机界面提交衔接句后的版本与冲突摘要

六、避免把界面同步当作文档提交

TextArea的onChange应直接保存组件返回的新值,不根据旧字符串长度自行猜测删除了哪个字符。用户可能在任意光标位置修改文本,也可能粘贴多行内容。输入层保持原样接收,规范化和结构检查放到提交边界,才能减少光标跳动及草稿被过早重写的问题。

异步保存接入后,还应区分内存文档版本和持久化完成版本。界面可以在内存提交成功后更新结果,再显示文件保存状态;写入失败时保留当前待保存版本并允许重试,不能把旧文件重新解析后直接覆盖新编辑。两个阶段各自拥有明确状态,比把所有操作塞进一个布尔值更容易恢复。

七、明确草稿的生命周期

打开条目时读取正式快照,记录baseVersion并初始化输入框;输入期间保留草稿,提交成功后再用新的正式条目刷新基准。提交失败时保留用户输入,让用户能够修正具体字段。取消则重新读取最新正式条目,因此即使文档已被另一个入口更新,也不会把过期内容作为新的草稿起点。

界面上的文档版本与baseVersion含义接近,但职责不同。文档版本用于说明当前已经提交的数据,baseVersion用于判断这份草稿从哪里开始。若以后增加自动保存,不能在后台保存完成时随意修改草稿基准,否则用户尚未提交的输入可能被错误地认为已经基于最新文档。版本变更必须伴随明确的快照切换。

原文往返检查还提供了一个有用的诊断点:若Cue集合正确而重新解析的内容不一致,应先检查序列化分隔符、正文换行和空白规则,随后才排查索引。索引只是消费整数区间,不应负责修补文本格式。把错误留在发生的层级,能够避免为了让定位结果正常而悄悄改写用户正文。

对于只改文字而时间完全不变的操作,可以进一步复用时间索引的排序结果,但需要更新正文所属的数据版本。冲突关系虽然不变,卡片显示的内容仍必须来自新Cue集合。性能优化应建立在明确依赖关系上,不能因为区间没变化就把整个旧快照保留下来。

编辑事务把一次修改的影响范围固定下来:身份用于找到条目,版本用于拒绝过期草稿,完整推导用于确保原文与派生结果一致。后续加入大屏对照与键盘选择时,可以继续共用这个入口,而不必让每一种界面各自维护文档更新规则。

相关文档:TextArea内容变更与光标问题、数字输入框事件示例、ArkTS开发基础。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐