鸿蒙 PC Markdown 编辑器图片拖放:复制、移动与仅引用的文件语义

桌面用户把图片从文件管理器拖进 Markdown 编辑器时,视觉动作只有一次,背后的文件意图却可能完全不同。复制表示保留源文件并在资源目录创建副本;移动表示目标完整落盘后删除源文件;仅引用表示文件已经位于受管理资源目录,只插入链接而不产生第二份字节。如果编辑器把三种模式都做成“复制一下再插链接”,界面看似成功,用户的文件组织意图却被悄悄破坏。

OhMarkdown 在鸿蒙 PC 版本中为三种语义建立了真实文件闭环。公开仓库为 https://gitcode.com/VON-/codex_md_oh,图片导入第一纵切在 0a02ce3,原生 UDMF 拖放与三模式最终完成于 89a5e57。本文聚焦拖放事务、ArkUI 与 ArkWeb 边界、源删除回滚和仅引用目录约束。

模式是持久化设置而不是一次性猜测

应用设置面板提供 Copy、Move、Reference 三段式选择。用户明确选择语义,应用把 AssetDropMode 保存到 Preferences,并在重启后恢复:

export enum AssetDropMode {
  COPY = 'copy',
  MOVE = 'move',
  REFERENCE = 'reference'
}

private updateAssetDropMode(mode: AssetDropMode): void {
  this.assetDropMode = mode;
  const context = this.getHostContext();
  if (context) {
    saveAssetDropMode(context, mode).catch(() => {
      this.operationStatus = 'Unable to save image drop setting';
    });
  }
}

为什么不根据拖放来源自动猜?用户从桌面拖入可能想复制,也可能想整理到文档目录;同一文件位于资源目录时可能只想复用。文件管理器的拖放动作也不一定携带稳定修饰键信息。显式模式让结果可预测,状态栏还能用不同文字确认实际语义。

设置失败只影响下次启动,当前进程仍按已选模式工作并提示。未知持久值加载时降级到 Copy,因为复制不会删除源文件,是最安全的默认值。

ArkWeb 的浏览器拖放拿不到完整系统语义

普通 Web drop 事件常提供浏览器 File 对象,可以读取字节,却不保证暴露鸿蒙系统文件 URI。复制模式只需要字节,因此可以走 Web 识别和 Base64 导入;移动必须删除真实源文件,仅引用必须判断真实源路径是否在资源目录,单靠浏览器文件名无法安全完成。

项目没有伪造移动语义。Move 与 Reference 使用文档标签栏的 ArkUI 原生 UDMF 接收区,直接读取系统 UnifiedData 中的图片、文件或 file URI 记录。Copy 仍可在正文区域使用 Web 路径,保持自然定位。

这种双入口是平台能力边界的结果。为了界面统一而让 Web 猜测路径,会把“移动”退化成复制,也可能删除错误同名文件。交互上明确标签栏是原生文件接收区,比语义不真实更可靠。

UDMF 记录必须按类型白名单解析

原生拖放事件提取 URI 时只接受图像、文件和明确的文件 URI 类型。真实逻辑会检查 unifiedDataChannel.ImageunifiedDataChannel.File 以及有限 record type,最多收集 16 个不重复 URI。未知文本或自定义对象不会被解释成路径。

private extractNativeDroppedAssetUris(event: DragEvent): Array<string> {
  const sourceUris: Array<string> = [];
  const records = event.getData().getRecords();
  records.forEach((record: unifiedDataChannel.UnifiedRecord) => {
    let sourceUri = '';
    if (record instanceof unifiedDataChannel.Image) {
      sourceUri = record.imageUri;
    } else if (record instanceof unifiedDataChannel.File) {
      sourceUri = record.uri;
    }
    if (sourceUri.length > 0 && !sourceUris.includes(sourceUri) &&
      sourceUris.length < 16) {
      sourceUris.push(sourceUri);
    }
  });
  return sourceUris;
}

记录数量上限避免一次拖放创建无限任务。URI 只是后续打开的候选,服务还会限制长度、空字符、扩展名、大小,并使用 NOFOLLOW 打开。事件层识别与文件层验证不能互相替代。

日志只记录公开的 record type,不记录用户完整文件路径。调试拖放兼容性时需要知道系统给了什么类型,但不应把私人目录写入远程日志。

原生接收区先判断能否承诺处理

onNativeAssetDrop 在向系统返回成功前检查 URI、当前操作、外部冲突和父目录授权:

private onNativeAssetDrop(event: DragEvent): void {
  const sourceUris = this.extractNativeDroppedAssetUris(event);
  if (sourceUris.length === 0) {
    event.setResult(DragResult.DRAG_FAILED);
    this.operationStatus = 'Image drop failed: the system did not provide a readable file URI';
    return;
  }
  if (this.operationInProgress || this.externalConflictVisible) {
    event.setResult(DragResult.DRAG_FAILED);
    return;
  }
  if (!this.documentUri.startsWith('/') &&
    this.getActiveWorkspaceParentUri().length === 0) {
    event.setResult(DragResult.DRAG_FAILED);
    this.operationStatus = 'Open the document folder before importing an image';
    return;
  }
  event.dragBehavior = DragBehavior.COPY;
  event.setResult(DragResult.DRAG_SUCCESSFUL);
  this.importNativeDroppedAssets(sourceUris);
}

系统级 dragBehavior 使用 Copy 并不改变应用内部的 Move 语义。它避免系统在应用确认事务前自行删除源;真正的移动由服务在目标提交成功后显式删除。这样回滚掌握在应用手中。

异步导入在事件返回后继续,状态栏显示进度和结果。系统“接收成功”表示应用接受了任务,不等于每个文件最终落盘;因此应用必须为后续失败提供清晰反馈,不能只依赖系统拖放动画。

源文件读取以打开后的真实路径为准

UDMF URI 可能包含编码或服务映射。服务使用 fileIo.open(..., READ_ONLY | NOFOLLOW),从打开的文件对象获得 file.path 作为已解析路径,随后 stat 大小并完整读取:

async function readDroppedAssetBytes(sourceUri: string): Promise<DroppedAssetContent> {
  if (sourceUri.length === 0 || sourceUri.length > 2048 ||
    sourceUri.includes('\u0000')) {
    throw new Error('The dropped image URI is invalid.');
  }
  mimeTypeForFileName(getFileNameFromUri(sourceUri));
  const file = await fileIo.open(sourceUri,
    fileIo.OpenMode.READ_ONLY | fileIo.OpenMode.NOFOLLOW);
  try {
    const resolvedPath = file.path;
    const stat = await fileIo.stat(file.fd);
    if (stat.size <= 0 || stat.size > MAX_IMPORTED_ASSET_BYTES) {
      throw new Error('The image exceeds the 10 MB import limit.');
    }
    const content = new ArrayBuffer(stat.size);
    const bytesRead = await fileIo.read(file.fd, content, { length: stat.size });
    if (bytesRead !== stat.size) {
      throw new Error('The dropped image changed while it was being read.');
    }
    return { bytes: new Uint8Array(content), resolvedPath };
  } finally {
    await fileIo.close(file);
  }
}

移动删除使用 resolvedPath,而不是从显示 URI 手工解码拼路径。设备测试正是在这里发现系统记录形式与文件对象真实路径的差异。依赖实际打开结果让源删除与刚读取的同一对象关联。

读取长度必须等于 stat.size。若文件在拖放期间变化,服务拒绝继续,避免目标得到混合或截断内容。当前单文件上限仍是 10 MiB。

Copy 复用安全导入事务

复制模式将读取到的字节编码为 Base64,调用与剪贴板共享的 importAsset。该服务负责资源目录、文件名清洗、冲突编号、临时写入、fsync、提交和长度复核。只有成功后返回相对路径。

复用同一服务的价值是安全规则一致,而不是代码少。剪贴板和拖放都不能覆盖现有资源,都只支持位图白名单,都在文件完整提交后插链接。若分别实现,很容易让拖放绕过 10 MiB、名称或目录校验。

复制完成后源文件不做任何写操作。目标链接通过 Web 的 insertNativeDroppedAsset 作为 CodeMirror 事务插入,进入撤销历史。撤销链接不会自动删除目标资源,因为同一资源可能已被其他位置引用。

Move 是目标提交与源删除组成的事务

移动的正确顺序必须是:读取源、完整提交目标、删除源、插入 Markdown。源删除在目标提交前发生会有丢文件风险;链接在源删除前插入则可能在删除失败时留下语义不明的复制结果。

核心实现为:

const imported = await importAsset(documentUri, documentName, rule, {
  requestId: request.requestId,
  name: sourceName,
  mimeType,
  base64: BASE64_HELPER.encodeToStringSync(bytes),
  byteLength: bytes.length,
  source: 'drop'
}, authorizedParentUri);

if (request.mode !== AssetDropMode.MOVE) {
  return imported;
}

try {
  await fileIo.unlink(content.resolvedPath);
} catch (error) {
  if (await fileIo.access(targetUri)) {
    await fileIo.unlink(targetUri);
  }
  throw new Error(`The image was copied but the source could not be removed: ${String(error)}`);
}
return imported;

源删除失败时删除新目标并抛错,Markdown 不插入。应用不会悄悄把 Move 降级成 Copy,因为那违反用户明确选择。回滚目标也可能失败,此时错误信息需要保留,后续测试应检查是否出现副本。跨文件系统的真正原子移动通常不可得,当前实现是可补偿事务而不是虚构原子性。

若源文件已经位于目标受管理资源目录,Move 不应复制后删除同一个文件。服务先识别 existingReference,这种情况按已有引用返回,避免自我覆盖。

Reference 只允许受管理目录

仅引用模式不能接受任意绝对路径。Markdown 存一个系统路径会破坏可迁移性,也可能让预览 Bridge 以后读取授权范围外文件。服务要求源文件位于当前文档父目录下的 assets${documentName}.assets,而且路径恰好两段。

if (request.mode === AssetDropMode.REFERENCE ||
  (request.mode === AssetDropMode.MOVE && existingReference)) {
  if (!existingReference) {
    throw new Error(
      'Reference mode only accepts images already inside the document asset folders.'
    );
  }
  return {
    requestId: request.requestId,
    storedName: sourceName,
    relativePath: existingReference,
    byteLength: content.bytes.length
  };
}

Reference 仍会打开并读取源文件、验证类型和大小。这看似多余,实际上确认文件真实可读、不是符号链接、不是伪扩展空文件,也为返回字节长度提供事实。成功路径不创建、不删除任何文件,只插入已经存在的标准相对路径。

路径判断同时检查 URI 文本和打开后的真实路径,以适配系统文件服务映射。任一能证明文件位于授权资源目录即可,但最终相对路径仍受两段规则限制。

多文件拖放按顺序处理

原生层最多接收 16 个去重 URI,并在同一个操作锁内顺序导入。每个文件生成唯一请求 ID,服务成功后复核 sessionId 未变化,再调用 Web 插入。顺序处理降低资源命名竞态,也使插入顺序与系统记录一致。

for (let index = 0; index < sourceUris.length; index += 1) {
  const request: DroppedAssetRequest = {
    requestId: `asset-${Date.now()}-${++this.nativeAssetDropSequence}`,
    sourceUri: sourceUris[index],
    mode: this.assetDropMode
  };
  const imported = await importDroppedAsset(
    this.documentUri, this.documentName, this.assetDirectoryRule,
    request, parentUri
  );
  if (sessionId !== this.activeDocumentSessionId) {
    throw new Error('The document session changed before the dropped image could be inserted.');
  }
  await this.insertNativeDroppedAsset(imported);
}

当前批次不是全有或全无事务。前几个文件可能已成功,后一个失败后停止。这一点需要在 UI 和后续测试中明确,不能显示笼统“全部失败”。未来可加入逐项结果和继续处理策略,但跨多个源删除的全局回滚复杂度很高,应基于真实需求设计。

用户仍可在导入过程中编辑正文,但不能启动另一个文件操作。会话切换会终止后续插入,已提交的资源不会自动删除,以防已经插入或被引用。

Markdown 插入位置与原生接收区

Web 正文区域的 Copy 拖放可以通过坐标计算 posAtCoords,在落点插入。ArkUI 标签栏接收的 Move/Reference 没有 CodeMirror 坐标,因此使用当前选区。insertNativeDroppedAsset 对返回路径再次安全检查、编码 Markdown 路径、转义 alt,并一次事务插入。

这种交互差异需要界面提示,但不应该伪造坐标。未来若平台能在跨组件拖放中稳定提供屏幕坐标并转换到 ArkWeb,本地入口可以进一步统一。当前优先保证文件语义正确。

焦点在插入后回到编辑器,状态栏区分 copiedmovedreference inserted。用户可以立即继续输入,并通过 undo 撤销正文链接;文件副作用不会随普通文本 undo 自动逆转。

真实设置与设备证据

下图来自 MateBook Pro 2in1 模拟器,显示三种拖放模式设置:

在这里插入图片描述

Move 测试把 203,166 字节 JPEG 从普通文档目录移动到专属资源目录。设备检查确认源不存在、目标存在,正文出现相对链接:

在这里插入图片描述

Reference 随后拖入已有目标,只增加同一路径引用,资源目录文件数保持不变:

在这里插入图片描述

完整证据在 docs/test/ohmarkdown/2026-07-18-g3-04-image-assets/。这些检查使用专用测试文件,不触碰用户私人资料。

自动化与设备验证

ArkTS 单元测试覆盖模式解析、文件名与目录规则。ohosTest 创建真实源文件,验证 Copy 目标字节、Move 源删除、Reference 文件数不变。Playwright 覆盖 Web Copy 拖放、成功插入、失败不改正文和持久预览。模拟器人工路径从系统文件管理器跨窗口拖到原生接收区,补足 UDMF 和真实 URI。

G3-04 收口时 Playwright 28/28、ohosTest 6/6;统一后续基线 2ca99e929/297/7。最终三模式提交是 89a5e57。设备报告记录 HAP 大小和 SHA-256,但产物未签名,不能等同正式 Release。

仍待覆盖的压力项包括:16 文件混合成功失败、恰好 10 MiB、源删除权限变化、跨卷移动、回滚删除失败、拖放时切标签、重复快速拖放和真机文件管理器不同记录类型。

安全边界

源文件用 NOFOLLOW 打开,拒绝符号链接;类型只允许 PNG/JPEG/GIF/WebP;大小不超过 10 MiB;URI 长度与空字符受限;目标路径只能从授权父目录和安全单段名称构造;Reference 只能返回受管理两段相对路径。

ArkWeb 不获得系统 URI,Move 与 Reference 全部在 ArkTS 完成。应用不申请网络,CSP 阻止远程图片。目标提交后才删除源,删除失败回滚目标并拒绝插链接。外部文档冲突期间禁止导入,避免资源事务与正文冲突交织。

路径和 URI 不进入远程遥测。状态栏错误描述原因,但技术日志应继续避免完整私人路径。未来若支持更多 UDMF 类型,需要逐项定义信任与转换规则,不能对任意记录调用 String(value) 当路径。

为什么不做“智能自动模式”

根据源是否在工作区自动决定 Reference,看似省设置,却会让相同拖放在目录变化后产生不同结果;根据修饰键决定 Copy/Move 受系统和焦点影响;总是 Move 风险最大;总是 Copy 则制造重复文件。显式模式更适合专业编辑器,也便于批量操作前确认。

没有使用文件扩展名后直接 unlink(sourceUri)。系统 URI 可能不是可删除路径,且可能含编码。打开后真实路径与文件对象保证删除对象就是已读取对象。没有使用 rename 实现所有 Move,因为源与目标可能跨文件系统或 URI 服务,rename 不一定可用。

没有允许 Reference 指向 ../images 或绝对路径。Markdown 标准允许更广路径,但当前安全预览只管理两类资源目录。未来扩展必须同时更新路径规范、权限模型、预览读取和搜索排除,不宜只放宽一个正则。

性能与用户反馈

拖放当前把源读入内存,再经 Base64 复用导入服务,峰值可能包含原字节、Base64 和目标缓冲。10 MiB 限制控制上界,但真机仍需测量。Move 删除源通常很快,网络或外接存储会产生长尾,状态栏应保持明确进度。

顺序处理避免并发争抢目标名,也可能让多图批次耗时更长。未来可以把读取并行、提交串行,但必须控制内存和取消语义。当前阶段更重视每个文件结果可解释。

系统拖放动画成功后异步任务仍可能失败,因此应用内反馈不可省略。理想反馈应显示当前数量、模式和失败文件名,并提供重试;当前已有总体状态,逐项列表仍是后续增强。

验收清单与结论

三模式验收必须分别检查字节和文件数量。Copy:源存在、目标字节一致、链接指向新目标。Move:目标先成功、源后删除、删除失败目标回滚、正文不变。Reference:只接受受管理目录、目标不复制不删除、文件数不变、链接为现有路径。共同检查还包括 UDMF 类型、NOFOLLOW、10 MiB、重名、会话切换、冲突状态和持久设置。

OhMarkdown 当前实现没有用一个“拖放成功”掩盖三种不同文件后果。它让用户先选择语义,再用原生文件能力执行可验证事务,并把 Markdown 保持为标准相对链接。Move 的补偿回滚和 Reference 的目录约束尤其重要:它们让拖放既像桌面应用,也不牺牲本地文档的安全与可迁移性。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐