// MNote document conflict panel 运行时外置模块。 // 第一刀:纯 DOM 清理 helper clearSessionConflictSurface。 // 本文件以 type="module" 加载,执行时机可能晚于 inline script。 // web_shell.rs 的 inline clearSessionConflictSurface 会优先委托到 // window.__mnoteDocumentConflictPanelRuntime; // 模块尚未加载或加载失败时,inline script 保留原地实现兜底。 /** * 清除指定 session 所有视图中冲突面板 DOM。 * 只做 DOM 清理,不处理冲突 merge/accept/keep 业务逻辑。 * @param {Object} session - document session 对象 * @param {Object} deps - 依赖注入,从 inline 上下文传入 * @param {Function} deps.sessionViews - session.views 迭代器 */ function clearSessionConflictSurface(session, deps) { var views = deps && typeof deps.sessionViews === 'function' ? deps.sessionViews(session) : []; views.forEach(function(view) { var host = conflictPanelHost(view && view.runtimeDescriptor && view.runtimeDescriptor.root); if (!(host instanceof HTMLElement)) return; host.querySelectorAll('[data-testid="mnote-editor-conflict-panel"]').forEach(function(node) { node.remove(); }); }); } function conflictPanelHost(root) { if (!(root instanceof Element)) return null; return root.closest('.document-pane') || root; } function mountSessionConflictPanel(root, panel) { var host = conflictPanelHost(root); if (!(host instanceof HTMLElement) || !(panel instanceof HTMLElement)) return false; var header = host.querySelector('.document-shell-header'); if (header && header.parentNode) { header.parentNode.insertBefore(panel, header.nextSibling); } else { host.prepend(panel); } return true; } function createSessionConflictPanel(session, message, deps) { deps = deps || {}; var externalConflictMessage = String(deps.externalConflictMessage || '').trim() || '文件已在外部修改'; var panel = document.createElement('section'); panel.className = 'mnote-editor-conflict-panel'; panel.setAttribute('data-testid', 'mnote-editor-conflict-panel'); panel.setAttribute('role', 'status'); panel.setAttribute('aria-live', 'polite'); var heading = document.createElement('h2'); heading.textContent = '文件冲突'; var text = document.createElement('p'); text.textContent = message || externalConflictMessage; var meta = document.createElement('div'); meta.className = 'mnote-conflict-meta'; var fileLabel = session && session.sessionKind === 'resource' ? String(session.rootUri || '') + '/' + String(session.resourcePath || session.documentId || '') : String(session && (session.rootUri || session.documentId) || ''); var sourceLabel = typeof deps.conflictSourceLabel === 'function' ? deps.conflictSourceLabel(session) : ''; meta.textContent = '文件:' + fileLabel + ' · 来源:' + (sourceLabel || '本地文件变更'); var actions = document.createElement('div'); actions.className = 'mnote-conflict-actions'; var acceptDisk = document.createElement('button'); acceptDisk.type = 'button'; acceptDisk.textContent = '接受磁盘版本'; acceptDisk.setAttribute('data-testid', 'mnote-conflict-accept-disk'); var keepCurrent = document.createElement('button'); keepCurrent.type = 'button'; keepCurrent.textContent = '保留当前编辑器版本'; keepCurrent.setAttribute('data-testid', 'mnote-conflict-keep-current'); var openDiff = document.createElement('button'); openDiff.type = 'button'; openDiff.textContent = '打开 diff'; openDiff.setAttribute('data-testid', 'mnote-conflict-open-diff'); actions.append(acceptDisk, keepCurrent, openDiff); var diffPanel = document.createElement('div'); diffPanel.className = 'mnote-conflict-diff-panel'; diffPanel.setAttribute('data-testid', 'mnote-conflict-diff-panel'); diffPanel.hidden = true; panel.append(heading, text, meta, actions, diffPanel); acceptDisk.addEventListener('click', function() { if (typeof deps.onAcceptDisk === 'function') deps.onAcceptDisk(session, panel); }); keepCurrent.addEventListener('click', function() { if (typeof deps.onKeepCurrent === 'function') deps.onKeepCurrent(session, panel); }); openDiff.addEventListener('click', function() { if (typeof deps.onOpenDiff === 'function') deps.onOpenDiff(session, panel); }); return panel; } /** * 在已有 diffPanel 中填充 current/disk/merge DOM 并绑定回调。 * 不处理 fetch、persist、status 等业务逻辑。 * @param {HTMLElement} panel - 已清空的 diffPanel 元素 * @param {Object} context * @param {string} context.currentText - 当前编辑器的文本(含占位文案) * @param {string} context.diskText - 磁盘版本的文本(含占位文案) * @param {string} [context.currentMergeText] - 写入合并区域的当前编辑器原始文本 * @param {string} [context.diskMergeText] - 写入合并区域的磁盘版本原始文本 * @param {string} [context.mergeDefaultText] - 合并区域默认文本 * @param {Object} deps * @param {Function} [deps.onUseCurrent] - 可选:返回当前版本写入合并区域的文本 * @param {Function} [deps.onUseDisk] - 可选:返回磁盘版本写入合并区域的文本 * @param {Function} deps.onSaveMerge - 保存合并结果回调,接收合并文本字符串 */ function populateSessionConflictDiffPanel(panel, context, deps) { context = context || {}; deps = deps || {}; var currentText = context.currentText || ''; var diskText = context.diskText || ''; var currentMergeText = Object.prototype.hasOwnProperty.call(context, 'currentMergeText') ? String(context.currentMergeText || '') : currentText; var diskMergeText = Object.prototype.hasOwnProperty.call(context, 'diskMergeText') ? String(context.diskMergeText || '') : diskText; var mergeDefaultText = context.mergeDefaultText || currentText || diskText || ''; var current = document.createElement('pre'); current.setAttribute('data-testid', 'mnote-conflict-current-text'); current.textContent = currentText; var disk = document.createElement('pre'); disk.setAttribute('data-testid', 'mnote-conflict-disk-text'); disk.textContent = diskText; var currentTitle = document.createElement('h3'); currentTitle.textContent = '当前编辑器版本'; var diskTitle = document.createElement('h3'); diskTitle.textContent = '磁盘版本'; var currentBox = document.createElement('section'); currentBox.append(currentTitle, current); var diskBox = document.createElement('section'); diskBox.append(diskTitle, disk); var mergeTitle = document.createElement('h3'); mergeTitle.textContent = '合并结果'; var mergeText = document.createElement('textarea'); mergeText.setAttribute('data-testid', 'mnote-conflict-merge-text'); mergeText.value = mergeDefaultText; var mergeActions = document.createElement('div'); mergeActions.className = 'mnote-conflict-actions'; var useCurrent = document.createElement('button'); useCurrent.type = 'button'; useCurrent.textContent = '使用当前版本'; useCurrent.setAttribute('data-testid', 'mnote-conflict-merge-use-current'); var useDisk = document.createElement('button'); useDisk.type = 'button'; useDisk.textContent = '使用磁盘版本'; useDisk.setAttribute('data-testid', 'mnote-conflict-merge-use-disk'); var saveMerge = document.createElement('button'); saveMerge.type = 'button'; saveMerge.textContent = '写回合并结果'; saveMerge.setAttribute('data-testid', 'mnote-conflict-merge-save'); mergeActions.append(useCurrent, useDisk, saveMerge); var mergeBox = document.createElement('section'); mergeBox.className = 'mnote-conflict-merge-box'; mergeBox.append(mergeTitle, mergeText, mergeActions); panel.append(currentBox, diskBox, mergeBox); useCurrent.addEventListener('click', function() { var nextText = typeof deps.onUseCurrent === 'function' ? deps.onUseCurrent(context) : currentMergeText; mergeText.value = typeof nextText === 'string' ? nextText : currentMergeText; }); useDisk.addEventListener('click', function() { var nextText = typeof deps.onUseDisk === 'function' ? deps.onUseDisk(context) : diskMergeText; mergeText.value = typeof nextText === 'string' ? nextText : diskMergeText; }); saveMerge.addEventListener('click', function() { if (typeof deps.onSaveMerge === 'function') { deps.onSaveMerge(mergeText.value); } }); } function runSessionConflictAction(action, deps) { deps = deps || {}; if (typeof action !== 'function') return; try { var result = action(); if (result && typeof result.then === 'function') { result.catch(function(error) { if (typeof deps.onError === 'function') deps.onError(error); }); } } catch (error) { if (typeof deps.onError === 'function') deps.onError(error); } } /** * 接受磁盘版本:用磁盘快照替换当前 session 内容。 * 依赖通过 deps 注入,确保 runtime 不依赖 inline 作用域闭包。 * @param {Object} session * @param {Object} deps * @param {Function} deps.setSessionStatus * @param {Function} deps.fetchLatestResourceSnapshot * @param {Function} deps.applyResourceSnapshotToSession * @param {Function} deps.fetchLatestSessionAggregate * @param {Function} deps.applyAggregateSnapshotToSession */ function acceptDiskVersion(session, deps) { deps = deps || {}; var setSessionStatus = deps.setSessionStatus || function() {}; var fetchLatestResourceSnapshot = deps.fetchLatestResourceSnapshot || function() { return Promise.resolve(null); }; var applyResourceSnapshotToSession = deps.applyResourceSnapshotToSession || function() {}; var fetchLatestSessionAggregate = deps.fetchLatestSessionAggregate || function() { return Promise.resolve(null); }; var applyAggregateSnapshotToSession = deps.applyAggregateSnapshotToSession || function() {}; setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...'); if (session.sessionKind === 'resource') { return fetchLatestResourceSnapshot(session).then(function(latestResource) { applyResourceSnapshotToSession(session, latestResource, 'mnote-web-resource-conflict-accept-disk'); }); } return fetchLatestSessionAggregate(session).then(function(latest) { applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk'); }); } /** * 保留当前编辑器版本:读取 editor 实时文本,更新 conflictDetectionKey, * 清空冲突标记,持久化后关闭冲突面板。 * 依赖通过 deps 注入。 * @param {Object} session * @param {Object} deps * @param {Function} deps.setSessionStatus * @param {Function} deps.fetchLatestResourceSnapshot - 返回 Promise * @param {Function} deps.fetchLatestSessionAggregate - 返回 Promise * @param {Function} deps.sessionViews - session → views[] * @param {Function} deps.currentEditorText - view → string * @param {Function} deps.normalizePlainText - string → string * @param {Function} deps.hydrateMindmapAttrsFromDom - (doc, root) → doc * @param {Function} deps.textToTiptapDocument - string → doc * @param {Function} deps.conflictDetectionKeyFromBody - body → string * @param {Function} deps.clearSessionConflictSurface - session → void * @param {Function} deps.persistSession - session → Promise */ function keepCurrentEditorVersion(session, deps) { deps = deps || {}; var setSessionStatus = deps.setSessionStatus || function() {}; var fetchLatestResourceSnapshot = deps.fetchLatestResourceSnapshot || function() { return Promise.resolve(null); }; var fetchLatestSessionAggregate = deps.fetchLatestSessionAggregate || function() { return Promise.resolve(null); }; var sessionViews = deps.sessionViews || function() { return []; }; var currentEditorText = deps.currentEditorText || function() { return ''; }; var normalizePlainText = deps.normalizePlainText || function(s) { return s; }; var hydrateMindmapAttrsFromDom = deps.hydrateMindmapAttrsFromDom || function(doc) { return doc; }; var textToTiptapDocument = deps.textToTiptapDocument || function(s) { return {type: 'doc', content: [{type: 'paragraph', content: [{type: 'text', text: s}]}]}; }; var conflictDetectionKeyFromBody = deps.conflictDetectionKeyFromBody || function() { return ''; }; var clearSessionConflictSurface = deps.clearSessionConflictSurface || function() {}; var persistSession = deps.persistSession || function() { return Promise.resolve(); }; setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...'); var latestPromise = session.sessionKind === 'resource' ? fetchLatestResourceSnapshot(session) : fetchLatestSessionAggregate(session); return latestPromise.then(function(latest) { var hydrateView = sessionViews(session).find(function(item) { return item.mountId != null; }) || sessionViews(session)[0]; if (hydrateView) { var liveText = normalizePlainText(currentEditorText(hydrateView)); if (liveText) { session.currentTiptapDocument = hydrateMindmapAttrsFromDom( textToTiptapDocument(liveText), hydrateView.runtimeDescriptor && hydrateView.runtimeDescriptor.root, ); } session.currentSerialized = JSON.stringify(session.currentTiptapDocument); } var nextKey = session.sessionKind === 'resource' ? String((latest && (latest.fileVersion || latest.conflictDetectionKey) || '')).trim() : conflictDetectionKeyFromBody((latest && latest.body) || {}); if (nextKey) { session.conflictDetectionKey = nextKey; session.lastExternalConflictDetectionKey = nextKey; } session.hasExternalConflict = false; session.externalChangePending = false; session.saving = false; session.dirty = true; clearSessionConflictSurface(session); return persistSession(session); }); } /** * 写回合并结果:将 merge textarea 中的文本写入 session, * 更新 conflictDetectionKey,清除冲突标记,持久化后移除面板。 * @param {Object} session * @param {HTMLElement|null} panel - 待移除的面板 DOM * @param {string} mergedText * @param {Object} deps * @param {Function} deps.setSessionStatus * @param {Function} deps.fetchLatestResourceSnapshot - Promise * @param {Function} deps.fetchLatestSessionAggregate - Promise * @param {Function} deps.conflictDetectionKeyFromBody - body → string * @param {Function} deps.textToTiptapDocument - string → doc * @param {Function} deps.persistSession - session → Promise */ function writeMergedConflictResult(session, panel, mergedText, deps) { deps = deps || {}; var setSessionStatus = deps.setSessionStatus || function() {}; var fetchLatestResourceSnapshot = deps.fetchLatestResourceSnapshot || function() { return Promise.resolve(null); }; var fetchLatestSessionAggregate = deps.fetchLatestSessionAggregate || function() { return Promise.resolve(null); }; var conflictDetectionKeyFromBody = deps.conflictDetectionKeyFromBody || function() { return ''; }; var textToTiptapDocument = deps.textToTiptapDocument || function(s) { return {type: 'doc', content: [{type: 'paragraph', content: [{type: 'text', text: s}]}]}; }; var persistSession = deps.persistSession || function() { return Promise.resolve(); }; setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...'); var latestPromise = session.sessionKind === 'resource' ? fetchLatestResourceSnapshot(session) : fetchLatestSessionAggregate(session); return latestPromise.then(function(latest) { var nextKey = session.sessionKind === 'resource' ? String((latest && (latest.fileVersion || latest.conflictDetectionKey) || '')).trim() : conflictDetectionKeyFromBody((latest && latest.body) || {}); if (nextKey) { session.conflictDetectionKey = nextKey; session.lastExternalConflictDetectionKey = nextKey; } session.currentTiptapDocument = textToTiptapDocument(String(mergedText || '')); session.currentSerialized = JSON.stringify(session.currentTiptapDocument); session.hasExternalConflict = false; session.externalChangePending = false; session.saving = false; session.dirty = true; if (panel && typeof panel.remove === 'function') panel.remove(); return persistSession(session); }); } window.__mnoteDocumentConflictPanelRuntime = { clearSessionConflictSurface: clearSessionConflictSurface, createSessionConflictPanel: createSessionConflictPanel, mountSessionConflictPanel: mountSessionConflictPanel, populateSessionConflictDiffPanel: populateSessionConflictDiffPanel, runSessionConflictAction: runSessionConflictAction, acceptDiskVersion: acceptDiskVersion, keepCurrentEditorVersion: keepCurrentEditorVersion, writeMergedConflictResult: writeMergedConflictResult };