鸿蒙 PC Markdown 编辑器图片拖放:复制、移动与仅引用的文件语义
2026/7/23 14:34:31 网站建设 项目流程

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

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

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

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

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

exportenumAssetDropMode{COPY='copy',MOVE='move',REFERENCE='reference'}privateupdateAssetDropMode(mode:AssetDropMode):void{this.assetDropMode=mode;constcontext=this.getHostContext();if(context){saveAssetDropMode(context,mode).catch(()=>{this.operationStatus='Unable 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.ImageunifiedDataChannel.File以及有限 record type,最多收集 16 个不重复 URI。未知文本或自定义对象不会被解释成路径。

privateextractNativeDroppedAssetUris(event:DragEvent):Array<string>{constsourceUris:Array<string>=[];constrecords=event.getData().getRecords();records.forEach((record:unifiedDataChannel.UnifiedRecord)=>{letsourceUri='';if(recordinstanceofunifiedDataChannel.Image){sourceUri=record.imageUri;}elseif(recordinstanceofunifiedDataChannel.File){sourceUri=record.uri;}if(sourceUri.length>0&&!sourceUris.includes(sourceUri)&&sourceUris.length<16){sourceUris.push(sourceUri);}});returnsourceUris;}

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

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

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

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

privateonNativeAssetDrop(event:DragEvent):void{constsourceUris=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大小并完整读取:

asyncfunctionreadDroppedAssetBytes(sourceUri:string):Promise<DroppedAssetContent>{if(sourceUri.length===0||sourceUri.length>2048||sourceUri.includes('\u0000')){thrownewError('The dropped image URI is invalid.');}mimeTypeForFileName(getFileNameFromUri(sourceUri));constfile=awaitfileIo.open(sourceUri,fileIo.OpenMode.READ_ONLY|fileIo.OpenMode.NOFOLLOW);try{constresolvedPath=file.path;conststat=awaitfileIo.stat(file.fd);if(stat.size<=0||stat.size>MAX_IMPORTED_ASSET_BYTES){thrownewError('The image exceeds the 10 MB import limit.');}constcontent=newArrayBuffer(stat.size);constbytesRead=awaitfileIo.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。源删除在目标提交前发生会有丢文件风险;链接在源删除前插入则可能在删除失败时留下语义不明的复制结果。

核心实现为:

constimported=awaitimportAsset(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.mode===AssetDropMode.REFERENCE||(request.mode===AssetDropMode.MOVE&&existingReference)){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(letindex=0;index<sourceUris.length;index+=1){constrequest:DroppedAssetRequest={requestId:`asset-${Date.now()}-${++this.nativeAssetDropSequence}`,sourceUri:sourceUris[index],mode:this.assetDropMode};constimported=awaitimportDroppedAsset(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,本地入口可以进一步统一。当前优先保证文件语义正确。

焦点在插入后回到编辑器,状态栏区分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 收口时 Playwright28/28、ohosTest6/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 的目录约束尤其重要:它们让拖放既像桌面应用,也不牺牲本地文档的安全与可迁移性。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询