
鸿蒙 PC Markdown 编辑器图片拖放复制、移动与仅引用的文件语义桌面用户把图片从文件管理器拖进 Markdown 编辑器时视觉动作只有一次背后的文件意图却可能完全不同。复制表示保留源文件并在资源目录创建副本移动表示目标完整落盘后删除源文件仅引用表示文件已经位于受管理资源目录只插入链接而不产生第二份字节。如果编辑器把三种模式都做成“复制一下再插链接”界面看似成功用户的文件组织意图却被悄悄破坏。OhMarkdown 在鸿蒙 PC 版本中为三种语义建立了真实文件闭环。公开仓库为 https://gitcode.com/VON-/codex_md_oh图片导入第一纵切在0a02ce3原生 UDMF 拖放与三模式最终完成于89a5e57。本文聚焦拖放事务、ArkUI 与 ArkWeb 边界、源删除回滚和仅引用目录约束。模式是持久化设置而不是一次性猜测应用设置面板提供 Copy、Move、Reference 三段式选择。用户明确选择语义应用把AssetDropMode保存到 Preferences并在重启后恢复exportenumAssetDropMode{COPYcopy,MOVEmove,REFERENCEreference}privateupdateAssetDropMode(mode:AssetDropMode):void{this.assetDropModemode;constcontextthis.getHostContext();if(context){saveAssetDropMode(context,mode).catch((){this.operationStatusUnable to save image drop setting;});}}为什么不根据拖放来源自动猜用户从桌面拖入可能想复制也可能想整理到文档目录同一文件位于资源目录时可能只想复用。文件管理器的拖放动作也不一定携带稳定修饰键信息。显式模式让结果可预测状态栏还能用不同文字确认实际语义。设置失败只影响下次启动当前进程仍按已选模式工作并提示。未知持久值加载时降级到 Copy因为复制不会删除源文件是最安全的默认值。ArkWeb 的浏览器拖放拿不到完整系统语义普通 Webdrop事件常提供浏览器File对象可以读取字节却不保证暴露鸿蒙系统文件 URI。复制模式只需要字节因此可以走 Web 识别和 Base64 导入移动必须删除真实源文件仅引用必须判断真实源路径是否在资源目录单靠浏览器文件名无法安全完成。项目没有伪造移动语义。Move 与 Reference 使用文档标签栏的 ArkUI 原生 UDMF 接收区直接读取系统UnifiedData中的图片、文件或 file URI 记录。Copy 仍可在正文区域使用 Web 路径保持自然定位。这种双入口是平台能力边界的结果。为了界面统一而让 Web 猜测路径会把“移动”退化成复制也可能删除错误同名文件。交互上明确标签栏是原生文件接收区比语义不真实更可靠。UDMF 记录必须按类型白名单解析原生拖放事件提取 URI 时只接受图像、文件和明确的文件 URI 类型。真实逻辑会检查unifiedDataChannel.Image、unifiedDataChannel.File以及有限 record type最多收集 16 个不重复 URI。未知文本或自定义对象不会被解释成路径。privateextractNativeDroppedAssetUris(event:DragEvent):Arraystring{constsourceUris:Arraystring[];constrecordsevent.getData().getRecords();records.forEach((record:unifiedDataChannel.UnifiedRecord){letsourceUri;if(recordinstanceofunifiedDataChannel.Image){sourceUrirecord.imageUri;}elseif(recordinstanceofunifiedDataChannel.File){sourceUrirecord.uri;}if(sourceUri.length0!sourceUris.includes(sourceUri)sourceUris.length16){sourceUris.push(sourceUri);}});returnsourceUris;}记录数量上限避免一次拖放创建无限任务。URI 只是后续打开的候选服务还会限制长度、空字符、扩展名、大小并使用NOFOLLOW打开。事件层识别与文件层验证不能互相替代。日志只记录公开的 record type不记录用户完整文件路径。调试拖放兼容性时需要知道系统给了什么类型但不应把私人目录写入远程日志。原生接收区先判断能否承诺处理onNativeAssetDrop在向系统返回成功前检查 URI、当前操作、外部冲突和父目录授权privateonNativeAssetDrop(event:DragEvent):void{constsourceUristhis.extractNativeDroppedAssetUris(event);if(sourceUris.length0){event.setResult(DragResult.DRAG_FAILED);this.operationStatusImage 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().length0){event.setResult(DragResult.DRAG_FAILED);this.operationStatusOpen the document folder before importing an image;return;}event.dragBehaviorDragBehavior.COPY;event.setResult(DragResult.DRAG_SUCCESSFUL);this.importNativeDroppedAssets(sourceUris);}系统级dragBehavior使用 Copy 并不改变应用内部的 Move 语义。它避免系统在应用确认事务前自行删除源真正的移动由服务在目标提交成功后显式删除。这样回滚掌握在应用手中。异步导入在事件返回后继续状态栏显示进度和结果。系统“接收成功”表示应用接受了任务不等于每个文件最终落盘因此应用必须为后续失败提供清晰反馈不能只依赖系统拖放动画。源文件读取以打开后的真实路径为准UDMF URI 可能包含编码或服务映射。服务使用fileIo.open(..., READ_ONLY | NOFOLLOW)从打开的文件对象获得file.path作为已解析路径随后stat大小并完整读取asyncfunctionreadDroppedAssetBytes(sourceUri:string):PromiseDroppedAssetContent{if(sourceUri.length0||sourceUri.length2048||sourceUri.includes(\u0000)){thrownewError(The dropped image URI is invalid.);}mimeTypeForFileName(getFileNameFromUri(sourceUri));constfileawaitfileIo.open(sourceUri,fileIo.OpenMode.READ_ONLY|fileIo.OpenMode.NOFOLLOW);try{constresolvedPathfile.path;conststatawaitfileIo.stat(file.fd);if(stat.size0||stat.sizeMAX_IMPORTED_ASSET_BYTES){thrownewError(The image exceeds the 10 MB import limit.);}constcontentnewArrayBuffer(stat.size);constbytesReadawaitfileIo.read(file.fd,content,{length:stat.size});if(bytesRead!stat.size){thrownewError(The dropped image changed while it was being read.);}return{bytes:newUint8Array(content),resolvedPath};}finally{awaitfileIo.close(file);}}移动删除使用resolvedPath而不是从显示 URI 手工解码拼路径。设备测试正是在这里发现系统记录形式与文件对象真实路径的差异。依赖实际打开结果让源删除与刚读取的同一对象关联。读取长度必须等于stat.size。若文件在拖放期间变化服务拒绝继续避免目标得到混合或截断内容。当前单文件上限仍是 10 MiB。Copy 复用安全导入事务复制模式将读取到的字节编码为 Base64调用与剪贴板共享的importAsset。该服务负责资源目录、文件名清洗、冲突编号、临时写入、fsync、提交和长度复核。只有成功后返回相对路径。复用同一服务的价值是安全规则一致而不是代码少。剪贴板和拖放都不能覆盖现有资源都只支持位图白名单都在文件完整提交后插链接。若分别实现很容易让拖放绕过 10 MiB、名称或目录校验。复制完成后源文件不做任何写操作。目标链接通过 Web 的insertNativeDroppedAsset作为 CodeMirror 事务插入进入撤销历史。撤销链接不会自动删除目标资源因为同一资源可能已被其他位置引用。Move 是目标提交与源删除组成的事务移动的正确顺序必须是读取源、完整提交目标、删除源、插入 Markdown。源删除在目标提交前发生会有丢文件风险链接在源删除前插入则可能在删除失败时留下语义不明的复制结果。核心实现为constimportedawaitimportAsset(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){returnimported;}try{awaitfileIo.unlink(content.resolvedPath);}catch(error){if(awaitfileIo.access(targetUri)){awaitfileIo.unlink(targetUri);}thrownewError(The image was copied but the source could not be removed:${String(error)});}returnimported;源删除失败时删除新目标并抛错Markdown 不插入。应用不会悄悄把 Move 降级成 Copy因为那违反用户明确选择。回滚目标也可能失败此时错误信息需要保留后续测试应检查是否出现副本。跨文件系统的真正原子移动通常不可得当前实现是可补偿事务而不是虚构原子性。若源文件已经位于目标受管理资源目录Move 不应复制后删除同一个文件。服务先识别 existingReference这种情况按已有引用返回避免自我覆盖。Reference 只允许受管理目录仅引用模式不能接受任意绝对路径。Markdown 存一个系统路径会破坏可迁移性也可能让预览 Bridge 以后读取授权范围外文件。服务要求源文件位于当前文档父目录下的assets或${documentName}.assets而且路径恰好两段。if(request.modeAssetDropMode.REFERENCE||(request.modeAssetDropMode.MOVEexistingReference)){if(!existingReference){thrownewError(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(letindex0;indexsourceUris.length;index1){constrequest:DroppedAssetRequest{requestId:asset-${Date.now()}-${this.nativeAssetDropSequence},sourceUri:sourceUris[index],mode:this.assetDropMode};constimportedawaitimportDroppedAsset(this.documentUri,this.documentName,this.assetDirectoryRule,request,parentUri);if(sessionId!this.activeDocumentSessionId){thrownewError(The document session changed before the dropped image could be inserted.);}awaitthis.insertNativeDroppedAsset(imported);}当前批次不是全有或全无事务。前几个文件可能已成功后一个失败后停止。这一点需要在 UI 和后续测试中明确不能显示笼统“全部失败”。未来可加入逐项结果和继续处理策略但跨多个源删除的全局回滚复杂度很高应基于真实需求设计。用户仍可在导入过程中编辑正文但不能启动另一个文件操作。会话切换会终止后续插入已提交的资源不会自动删除以防已经插入或被引用。Markdown 插入位置与原生接收区Web 正文区域的 Copy 拖放可以通过坐标计算posAtCoords在落点插入。ArkUI 标签栏接收的 Move/Reference 没有 CodeMirror 坐标因此使用当前选区。insertNativeDroppedAsset对返回路径再次安全检查、编码 Markdown 路径、转义 alt并一次事务插入。这种交互差异需要界面提示但不应该伪造坐标。未来若平台能在跨组件拖放中稳定提供屏幕坐标并转换到 ArkWeb本地入口可以进一步统一。当前优先保证文件语义正确。焦点在插入后回到编辑器状态栏区分copied、moved与reference 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 收口时 Playwright28/28、ohosTest6/6统一后续基线2ca99e9是29/29与7/7。最终三模式提交是89a5e57。设备报告记录 HAP 大小和 SHA-256但产物未签名不能等同正式 Release。仍待覆盖的压力项包括16 文件混合成功失败、恰好 10 MiB、源删除权限变化、跨卷移动、回滚删除失败、拖放时切标签、重复快速拖放和真机文件管理器不同记录类型。安全边界源文件用NOFOLLOW打开拒绝符号链接类型只允许 PNG/JPEG/GIF/WebP大小不超过 10 MiBURI 长度与空字符受限目标路径只能从授权父目录和安全单段名称构造Reference 只能返回受管理两段相对路径。ArkWeb 不获得系统 URIMove 与 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 的目录约束尤其重要它们让拖放既像桌面应用也不牺牲本地文档的安全与可迁移性。