feat: sidebar runtime extraction + history-safe commands + checklist updates
This commit is contained in:
@@ -199,10 +199,153 @@ function runSessionConflictAction(action, deps) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 接受磁盘版本:用磁盘快照替换当前 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<Object>
|
||||
* @param {Function} deps.fetchLatestSessionAggregate - 返回 Promise<Object>
|
||||
* @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<Object>
|
||||
* @param {Function} deps.fetchLatestSessionAggregate - Promise<Object>
|
||||
* @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
|
||||
runSessionConflictAction: runSessionConflictAction,
|
||||
acceptDiskVersion: acceptDiskVersion,
|
||||
keepCurrentEditorVersion: keepCurrentEditorVersion,
|
||||
writeMergedConflictResult: writeMergedConflictResult
|
||||
};
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// MNote filetree DND 运行时外置模块。
|
||||
// 承接 filetree dragstart/dragover/drop/dragend 的业务逻辑,
|
||||
// 不处理 page tree DND(留在 layout.rs)。
|
||||
// inline script 保留完整 fallback。
|
||||
|
||||
// ─── 常量 ───────────────────────────────────────────────
|
||||
|
||||
var FILETREE_DRAG_MIME = 'application/x-mnote-file-tree';
|
||||
|
||||
// ─── 状态 ───────────────────────────────────────────────
|
||||
|
||||
var draggingFileTreeRowIds = [];
|
||||
var activeFileTreeDropRow = null;
|
||||
|
||||
// ─── 纯 helper ─────────────────────────────────────────
|
||||
|
||||
function filetreeDragPayload(rowIds) {
|
||||
return JSON.stringify({
|
||||
type: 'mnote-file-tree-dnd',
|
||||
version: 1,
|
||||
rowIds: rowIds || draggingFileTreeRowIds,
|
||||
});
|
||||
}
|
||||
|
||||
function filetreeDropDetail(targetRow, fileTree, deps) {
|
||||
deps = deps || {};
|
||||
var resolveWorkspaceId = deps.resolveWorkspaceId || function() { return ''; };
|
||||
var fileTreeRowLocalUploadTargetRelativePath = deps.fileTreeRowLocalUploadTargetRelativePath || function() { return ''; };
|
||||
return {
|
||||
workspaceId: resolveWorkspaceId(targetRow || fileTree),
|
||||
targetRowId: targetRow ? targetRow.getAttribute('data-row-id') : null,
|
||||
targetRowKind: targetRow ? targetRow.getAttribute('data-row-kind') : 'root',
|
||||
documentId: targetRow
|
||||
? targetRow.getAttribute('data-document-id') || targetRow.getAttribute('data-doc-id')
|
||||
: null,
|
||||
assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null,
|
||||
targetRelativePath: targetRow ? fileTreeRowLocalUploadTargetRelativePath(targetRow) : '',
|
||||
uploadIntent: 'filetree.folder.drop',
|
||||
};
|
||||
}
|
||||
|
||||
function filetreeHasFiles(dataTransfer) {
|
||||
return dataTransfer && Array.prototype.indexOf.call(dataTransfer.types || [], 'Files') >= 0;
|
||||
}
|
||||
|
||||
function filetreeHasInternalDrag(dataTransfer) {
|
||||
return dataTransfer && Array.prototype.indexOf.call(dataTransfer.types || [], FILETREE_DRAG_MIME) >= 0;
|
||||
}
|
||||
|
||||
function parseFileTreeDragPayload(raw) {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed.rowIds)) return parsed.rowIds;
|
||||
} catch (_) {}
|
||||
return [];
|
||||
}
|
||||
|
||||
function clearFileTreeDropFeedback() {
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) {
|
||||
activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
|
||||
}
|
||||
activeFileTreeDropRow = null;
|
||||
}
|
||||
|
||||
function resetFileTreeDragState() {
|
||||
draggingFileTreeRowIds = [];
|
||||
clearFileTreeDropFeedback();
|
||||
}
|
||||
|
||||
// ─── 文件树 DND 开始 ───────────────────────────────────
|
||||
|
||||
function startFileTreeDrag(fileRow, deps) {
|
||||
deps = deps || {};
|
||||
var selectedSidebarFileTreeRowIdsForDrag = deps.selectedSidebarFileTreeRowIdsForDrag || function(r) {
|
||||
return [r.getAttribute('data-row-id') || ''].filter(Boolean);
|
||||
};
|
||||
draggingFileTreeRowIds = selectedSidebarFileTreeRowIdsForDrag(fileRow);
|
||||
return {
|
||||
payload: filetreeDragPayload(draggingFileTreeRowIds),
|
||||
effectAllowed: 'copyMove',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 文件树 DND 悬浮 ───────────────────────────────────
|
||||
|
||||
function handleFileTreeDragOver(event, deps) {
|
||||
deps = deps || {};
|
||||
var closestAction = deps.closestAction || function() { return null; };
|
||||
var fileTree = document.getElementById('sidebar-file-tree-root');
|
||||
if (!fileTree || !fileTree.contains(event.target)) return false;
|
||||
|
||||
var hasFiles = filetreeHasFiles(event.dataTransfer);
|
||||
var hasInternal = filetreeHasInternalDrag(event.dataTransfer);
|
||||
if (!hasFiles && !hasInternal && draggingFileTreeRowIds.length === 0) return false;
|
||||
|
||||
event.preventDefault();
|
||||
clearFileTreeDropFeedback();
|
||||
activeFileTreeDropRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]') || fileTree;
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) {
|
||||
activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
|
||||
}
|
||||
if (event.dataTransfer) {
|
||||
var copyModifier = event.altKey || event.ctrlKey || event.metaKey;
|
||||
event.dataTransfer.dropEffect = hasFiles || copyModifier ? 'copy' : 'move';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── 文件树 DND 释放 ───────────────────────────────────
|
||||
|
||||
function handleFileTreeDrop(event, deps) {
|
||||
deps = deps || {};
|
||||
var closestAction = deps.closestAction || function() { return null; };
|
||||
var resolveWorkspaceId = deps.resolveWorkspaceId || function() { return ''; };
|
||||
var fileTreeRowLocalUploadTargetRelativePath = deps.fileTreeRowLocalUploadTargetRelativePath
|
||||
|| function() { return ''; };
|
||||
var ensureFileTreeWritableTarget = deps.ensureFileTreeWritableTarget || function() { return Promise.resolve(true); };
|
||||
var dispatchSidebarEvent = deps.dispatchSidebarEvent || function() {};
|
||||
var fileTree = document.getElementById('sidebar-file-tree-root');
|
||||
if (!fileTree || !fileTree.contains(event.target)) return;
|
||||
|
||||
var targetRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]');
|
||||
var files = event.dataTransfer ? Array.from(event.dataTransfer.files || []) : [];
|
||||
var raw = event.dataTransfer
|
||||
? event.dataTransfer.getData(FILETREE_DRAG_MIME)
|
||||
|| event.dataTransfer.getData('application/x-mnote-file-tree')
|
||||
|| ''
|
||||
: '';
|
||||
var rowIds = draggingFileTreeRowIds.slice();
|
||||
if (raw) {
|
||||
rowIds = parseFileTreeDragPayload(raw);
|
||||
}
|
||||
if (!files.length && !rowIds.length) return;
|
||||
|
||||
event.preventDefault();
|
||||
var detail = filetreeDropDetail(targetRow, fileTree, {
|
||||
resolveWorkspaceId: resolveWorkspaceId,
|
||||
fileTreeRowLocalUploadTargetRelativePath: fileTreeRowLocalUploadTargetRelativePath,
|
||||
});
|
||||
clearFileTreeDropFeedback();
|
||||
if (files.length) {
|
||||
dispatchSidebarEvent('tree.filetree.external-drop', Object.assign({}, detail, { files: files }));
|
||||
} else {
|
||||
var copyModifier = event.altKey === true || event.ctrlKey === true || event.metaKey === true;
|
||||
void Promise.resolve(ensureFileTreeWritableTarget('drop', targetRow, rowIds, copyModifier))
|
||||
.then(function(writable) {
|
||||
if (!writable) return;
|
||||
dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, {
|
||||
rowIds: rowIds,
|
||||
copy: copyModifier,
|
||||
}));
|
||||
});
|
||||
}
|
||||
draggingFileTreeRowIds = [];
|
||||
}
|
||||
|
||||
// ─── 导出 ───────────────────────────────────────────────
|
||||
|
||||
window.__mnoteFileTreeDndRuntime = {
|
||||
FILETREE_DRAG_MIME: FILETREE_DRAG_MIME,
|
||||
draggingFileTreeRowIds: draggingFileTreeRowIds,
|
||||
activeFileTreeDropRow: activeFileTreeDropRow,
|
||||
filetreeDragPayload: filetreeDragPayload,
|
||||
filetreeDropDetail: filetreeDropDetail,
|
||||
filetreeHasFiles: filetreeHasFiles,
|
||||
filetreeHasInternalDrag: filetreeHasInternalDrag,
|
||||
parseFileTreeDragPayload: parseFileTreeDragPayload,
|
||||
clearFileTreeDropFeedback: clearFileTreeDropFeedback,
|
||||
resetFileTreeDragState: resetFileTreeDragState,
|
||||
startFileTreeDrag: startFileTreeDrag,
|
||||
handleFileTreeDragOver: handleFileTreeDragOver,
|
||||
handleFileTreeDrop: handleFileTreeDrop,
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
// MNote filetree keyboard 运行时外置模块。
|
||||
// 承接 filetree keydown 处理和 clipboard 状态管理。
|
||||
// inline script 保留完整 fallback。
|
||||
|
||||
// ─── Clipboard 状态 ─────────────────────────────────────
|
||||
|
||||
var sidebarFileTreeClipboard = null;
|
||||
|
||||
function getSidebarFileTreeClipboard() {
|
||||
return sidebarFileTreeClipboard;
|
||||
}
|
||||
|
||||
function setSidebarFileTreeClipboard(action, rowIds) {
|
||||
sidebarFileTreeClipboard = { action: action, rowIds: rowIds };
|
||||
document.documentElement.setAttribute('data-mnote-filetree-clipboard-action', action);
|
||||
return sidebarFileTreeClipboard;
|
||||
}
|
||||
|
||||
function clearSidebarFileTreeClipboard() {
|
||||
sidebarFileTreeClipboard = null;
|
||||
document.documentElement.removeAttribute('data-mnote-filetree-clipboard-action');
|
||||
}
|
||||
|
||||
// ─── Keyboard handler ───────────────────────────────────
|
||||
|
||||
function handleFileTreeKeyDown(event, deps) {
|
||||
deps = deps || {};
|
||||
var closestAction = deps.closestAction || function() { return null; };
|
||||
var beginFileTreeInlineRename = deps.beginFileTreeInlineRename || function() {};
|
||||
var selectedSidebarFileTreeRows = deps.selectedSidebarFileTreeRows || function() { return []; };
|
||||
var buildSidebarFileTreeContext = deps.buildSidebarFileTreeContext || function() { return {}; };
|
||||
var evaluateSidebarFileTreeWhen = deps.evaluateSidebarFileTreeWhen || function() { return false; };
|
||||
var deleteSelectedSidebarFileTreeRows = deps.deleteSelectedSidebarFileTreeRows || function() { return Promise.resolve(); };
|
||||
|
||||
var keyTarget = event.target;
|
||||
var fileTreeRootForKey = document.getElementById('sidebar-file-tree-root');
|
||||
var fileTreeRowForKey = closestAction(keyTarget, '.tree-row[data-shell-mode="filetree"]');
|
||||
if (!fileTreeRowForKey && fileTreeRootForKey && fileTreeRootForKey.contains(document.activeElement)) {
|
||||
fileTreeRowForKey = closestAction(document.activeElement, '.tree-row[data-shell-mode="filetree"]');
|
||||
}
|
||||
if (!fileTreeRowForKey) return false;
|
||||
|
||||
if (event.key === 'F2') {
|
||||
event.preventDefault();
|
||||
beginFileTreeInlineRename(fileTreeRowForKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key) {
|
||||
var shortcutKey = event.key.toLowerCase();
|
||||
if (shortcutKey === 'c' || shortcutKey === 'x') {
|
||||
var selectedRows = selectedSidebarFileTreeRows();
|
||||
var selectedRowIds = selectedRows.map(function(row) {
|
||||
return row.getAttribute('data-row-id') || '';
|
||||
}).filter(Boolean);
|
||||
if (selectedRowIds.length > 0) {
|
||||
event.preventDefault();
|
||||
setSidebarFileTreeClipboard(shortcutKey === 'x' ? 'cut' : 'copy', selectedRowIds);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (shortcutKey === 'v') {
|
||||
event.preventDefault();
|
||||
if (typeof deps.pasteSidebarFileTreeClipboard === 'function') {
|
||||
void deps.pasteSidebarFileTreeClipboard(fileTreeRowForKey);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
var delCtx = buildSidebarFileTreeContext('filetree');
|
||||
if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')) {
|
||||
return true;
|
||||
}
|
||||
void deleteSelectedSidebarFileTreeRows(fileTreeRowForKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── 导出 ───────────────────────────────────────────────
|
||||
|
||||
window.__mnoteFileTreeKeyboardRuntime = {
|
||||
sidebarFileTreeClipboard: sidebarFileTreeClipboard,
|
||||
getSidebarFileTreeClipboard: getSidebarFileTreeClipboard,
|
||||
setSidebarFileTreeClipboard: setSidebarFileTreeClipboard,
|
||||
clearSidebarFileTreeClipboard: clearSidebarFileTreeClipboard,
|
||||
handleFileTreeKeyDown: handleFileTreeKeyDown,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -114,6 +114,18 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/mnote-browser-runtime/filetree-context-menu-runtime.js",
|
||||
get(web_shell::filetree_context_menu_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/filetree-dnd-runtime.js",
|
||||
get(web_shell::filetree_dnd_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/filetree-keyboard-runtime.js",
|
||||
get(web_shell::filetree_keyboard_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
|
||||
get(web_shell::sidebar_tree_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/tree-live-controller.js",
|
||||
get(web_shell::tree_live_controller_runtime_asset),
|
||||
|
||||
@@ -2059,6 +2059,17 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
};
|
||||
|
||||
const acceptDiskVersion = async (session) => {
|
||||
var _RT_ = window.__mnoteDocumentConflictPanelRuntime;
|
||||
if (_RT_ && typeof _RT_.acceptDiskVersion === 'function') {
|
||||
return _RT_.acceptDiskVersion(session, {
|
||||
setSessionStatus: setSessionStatus,
|
||||
fetchLatestResourceSnapshot: fetchLatestResourceSnapshot,
|
||||
applyResourceSnapshotToSession: applyResourceSnapshotToSession,
|
||||
fetchLatestSessionAggregate: fetchLatestSessionAggregate,
|
||||
applyAggregateSnapshotToSession: applyAggregateSnapshotToSession,
|
||||
});
|
||||
}
|
||||
// inline fallback
|
||||
setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...');
|
||||
if (session.sessionKind === 'resource') {
|
||||
const latestResource = await fetchLatestResourceSnapshot(session);
|
||||
@@ -2070,6 +2081,23 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
};
|
||||
|
||||
const keepCurrentEditorVersion = async (session) => {
|
||||
var _RT_ = window.__mnoteDocumentConflictPanelRuntime;
|
||||
if (_RT_ && typeof _RT_.keepCurrentEditorVersion === 'function') {
|
||||
return _RT_.keepCurrentEditorVersion(session, {
|
||||
setSessionStatus: setSessionStatus,
|
||||
fetchLatestResourceSnapshot: fetchLatestResourceSnapshot,
|
||||
fetchLatestSessionAggregate: fetchLatestSessionAggregate,
|
||||
sessionViews: sessionViews,
|
||||
currentEditorText: currentEditorText,
|
||||
normalizePlainText: normalizePlainText,
|
||||
hydrateMindmapAttrsFromDom: hydrateMindmapAttrsFromDom,
|
||||
textToTiptapDocument: textToTiptapDocument,
|
||||
conflictDetectionKeyFromBody: conflictDetectionKeyFromBody,
|
||||
clearSessionConflictSurface: clearSessionConflictSurface,
|
||||
persistSession: persistSession,
|
||||
});
|
||||
}
|
||||
// inline fallback
|
||||
setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...');
|
||||
const latest = session.sessionKind === 'resource'
|
||||
? await fetchLatestResourceSnapshot(session)
|
||||
@@ -2101,6 +2129,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
};
|
||||
|
||||
const writeMergedConflictResult = async (session, panel, mergedText) => {
|
||||
var _RT_ = window.__mnoteDocumentConflictPanelRuntime;
|
||||
if (_RT_ && typeof _RT_.writeMergedConflictResult === 'function') {
|
||||
return _RT_.writeMergedConflictResult(session, panel, mergedText, {
|
||||
setSessionStatus: setSessionStatus,
|
||||
fetchLatestResourceSnapshot: fetchLatestResourceSnapshot,
|
||||
fetchLatestSessionAggregate: fetchLatestSessionAggregate,
|
||||
conflictDetectionKeyFromBody: conflictDetectionKeyFromBody,
|
||||
textToTiptapDocument: textToTiptapDocument,
|
||||
persistSession: persistSession,
|
||||
});
|
||||
}
|
||||
// inline fallback
|
||||
setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...');
|
||||
const latest = session.sessionKind === 'resource'
|
||||
? await fetchLatestResourceSnapshot(session)
|
||||
@@ -4462,6 +4502,48 @@ pub async fn local_upload_runtime_asset() -> Response {
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_tree_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-tree-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn filetree_keyboard_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/filetree-keyboard-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn filetree_dnd_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/filetree-dnd-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn filetree_context_menu_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/filetree-context-menu-runtime.js");
|
||||
Response::builder()
|
||||
@@ -5677,6 +5759,14 @@ mod tests {
|
||||
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
|
||||
.contains("runSessionConflictAction: runSessionConflictAction"));
|
||||
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function runSessionConflictAction"));
|
||||
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function acceptDiskVersion"));
|
||||
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function keepCurrentEditorVersion"));
|
||||
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function writeMergedConflictResult"));
|
||||
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("acceptDiskVersion: acceptDiskVersion"));
|
||||
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
|
||||
.contains("keepCurrentEditorVersion: keepCurrentEditorVersion"));
|
||||
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
|
||||
.contains("writeMergedConflictResult: writeMergedConflictResult"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -253,19 +253,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pointer_corridor_accepts_handle_lane_and_block_edge() {
|
||||
// All geometry values are stage-relative (substracted stage_left/top).
|
||||
// handle at absolute x≈108 → stage-rel handle_left≈8
|
||||
// block starts at absolute x=250 → stage-rel block_left=150
|
||||
let geometry = HandleCorridorGeometry {
|
||||
stage_left: 100.0,
|
||||
stage_top: 20.0,
|
||||
block_left: 140.0,
|
||||
block_left: 150.0,
|
||||
block_top: 40.0,
|
||||
block_bottom: 80.0,
|
||||
handle_left: 108.0,
|
||||
handle_right: 130.0,
|
||||
block_bottom: 60.0,
|
||||
handle_left: 8.0,
|
||||
handle_right: 30.0,
|
||||
};
|
||||
|
||||
// (118,80) → stage-rel (18,60): inside corridor ✓
|
||||
assert!(pointer_in_handle_corridor_geometry(118, 80, &geometry));
|
||||
// (250,80) → stage-rel (150,60): inside block-edge corridor ✓
|
||||
assert!(pointer_in_handle_corridor_geometry(250, 80, &geometry));
|
||||
// (90,80) → stage-rel (-10,60): x outside corridor (-10 < -4) ✓
|
||||
assert!(!pointer_in_handle_corridor_geometry(90, 80, &geometry));
|
||||
// (118,28) → stage-rel (18,8): y above block_top-18 (=22) ✓
|
||||
assert!(!pointer_in_handle_corridor_geometry(118, 28, &geometry));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 编辑器到宿主 shell 的 bridge events 常量与 payload 类型。
|
||||
//! dispatch 函数保留在 lib.rs 中(与信号/overlay 耦合度高)。
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) const RUNTIME_NAME: &str = "8123-leptos-tiptap-runtime";
|
||||
pub(crate) const RUNTIME_VERSION: &str = "1.1.0";
|
||||
pub(crate) const PROTOCOL: &str = "mnote.leptos_tiptap.bridge.v1";
|
||||
pub(crate) const BLOCK_DELTA_EVENT: &str = "mnote:editor:block-delta";
|
||||
pub(crate) const COMMAND_EVENT: &str = "mnote:leptos-tiptap-spike:command";
|
||||
pub(crate) const HEIGHT_EVENT: &str = "mnote:leptos-tiptap-spike:height";
|
||||
pub(crate) const EVENT_PREFIX: &str = "mnote:leptos-tiptap-spike";
|
||||
pub(crate) const READY_EVENT: &str = "mnote:leptos-tiptap-spike:ready";
|
||||
pub(crate) const CHANGE_EVENT: &str = "mnote:leptos-tiptap-spike:change";
|
||||
pub(crate) const STATE_EVENT: &str = "mnote:leptos-tiptap-spike:state";
|
||||
pub(crate) const STATUS_EVENT: &str = "mnote:leptos-tiptap-spike:status";
|
||||
pub(crate) const SELECTION_EVENT: &str = "mnote:leptos-tiptap-spike:selection";
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BridgeEnvelope<T>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
pub(crate) protocol: &'static str,
|
||||
pub(crate) runtime: &'static str,
|
||||
pub(crate) version: &'static str,
|
||||
pub(crate) source: &'static str,
|
||||
pub(crate) event: &'static str,
|
||||
pub(crate) payload: T,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BridgeSelectorsPayload {
|
||||
pub(crate) root: &'static str,
|
||||
pub(crate) stage: &'static str,
|
||||
pub(crate) editor: &'static str,
|
||||
pub(crate) toolbar: &'static str,
|
||||
pub(crate) slash_menu: &'static str,
|
||||
pub(crate) handle: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct HostStatusPayload {
|
||||
pub(crate) document_id: Option<String>,
|
||||
pub(crate) workspace_id: Option<String>,
|
||||
pub(crate) title: String,
|
||||
pub(crate) dirty_count: u32,
|
||||
pub(crate) selected_block_index: Option<usize>,
|
||||
pub(crate) current_block_id: Option<String>,
|
||||
pub(crate) editor_focused: bool,
|
||||
pub(crate) read_only: bool,
|
||||
pub(crate) slash_open: bool,
|
||||
pub(crate) toolbar_open: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ReadyPayload {
|
||||
pub(crate) runtime_name: &'static str,
|
||||
pub(crate) selectors: BridgeSelectorsPayload,
|
||||
pub(crate) supported_commands: Vec<&'static str>,
|
||||
pub(crate) supports_embedded_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct StatePayload {
|
||||
pub(crate) document_id: Option<String>,
|
||||
pub(crate) workspace_id: Option<String>,
|
||||
pub(crate) title: String,
|
||||
pub(crate) dirty_count: u32,
|
||||
pub(crate) selected_block_index: Option<usize>,
|
||||
pub(crate) editor_focused: bool,
|
||||
pub(crate) slash_open: bool,
|
||||
pub(crate) toolbar_open: bool,
|
||||
pub(crate) read_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ChangeMetaPayload {
|
||||
pub(crate) dirty_count: u32,
|
||||
pub(crate) editor_focused: bool,
|
||||
pub(crate) slash_open: bool,
|
||||
pub(crate) toolbar_open: bool,
|
||||
pub(crate) selected_block_index: Option<usize>,
|
||||
pub(crate) revision: Option<i64>,
|
||||
pub(crate) conflict_detection_key: Option<String>,
|
||||
pub(crate) read_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ChangePayload {
|
||||
pub(crate) document_id: Option<String>,
|
||||
pub(crate) workspace_id: Option<String>,
|
||||
pub(crate) title: String,
|
||||
pub(crate) content: Value,
|
||||
pub(crate) meta: ChangeMetaPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct HeightPayload {
|
||||
pub(crate) height: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct HostCommandEnvelope {
|
||||
pub(crate) protocol: Option<String>,
|
||||
pub(crate) runtime: Option<String>,
|
||||
pub(crate) version: Option<String>,
|
||||
pub(crate) source: Option<String>,
|
||||
pub(crate) event: Option<String>,
|
||||
pub(crate) payload: Option<HostCommandPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct RuntimePageOptions {
|
||||
pub(crate) wide_layout: Option<bool>,
|
||||
pub(crate) small_text: Option<bool>,
|
||||
pub(crate) layout_density: Option<String>,
|
||||
pub(crate) show_heading_numbers: Option<bool>,
|
||||
pub(crate) embed_default_block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct HostCommandPayload {
|
||||
pub(crate) command: Option<String>,
|
||||
pub(crate) document_id: Option<String>,
|
||||
pub(crate) workspace_id: Option<String>,
|
||||
pub(crate) title: Option<String>,
|
||||
pub(crate) content: Option<Value>,
|
||||
pub(crate) editable: Option<bool>,
|
||||
pub(crate) page_options: Option<RuntimePageOptions>,
|
||||
pub(crate) block_id: Option<String>,
|
||||
pub(crate) block_index: Option<usize>,
|
||||
pub(crate) text: Option<String>,
|
||||
pub(crate) reference_document_id: Option<String>,
|
||||
pub(crate) reference_block_id: Option<String>,
|
||||
pub(crate) current_block_id: Option<String>,
|
||||
pub(crate) selection: Option<Value>,
|
||||
pub(crate) revision: Option<i64>,
|
||||
pub(crate) conflict_detection_key: Option<String>,
|
||||
pub(crate) read_only: Option<bool>,
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use serde_json::json;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::editor_runtime::attachment_links;
|
||||
use crate::editor_runtime::block_hover_state::DropPlacement;
|
||||
|
||||
/// 通过 Tiptap `delete_selection()` 删除当前选中的本地附件链接,保留撤销/重做历史。
|
||||
///
|
||||
@@ -111,3 +112,105 @@ pub(crate) fn delete_top_level_block_with_history(
|
||||
.focus()
|
||||
.map_err(|err| format!("聚焦编辑器失败:{err}"))
|
||||
}
|
||||
|
||||
/// 通过 Tiptap transaction 移动顶层块,保留撤销/重做历史。
|
||||
///
|
||||
/// 先删除源块,再在调整后的目标位置插入。
|
||||
pub(crate) fn move_top_level_block_with_history(
|
||||
editor: &TiptapEditorHandle,
|
||||
source_index: usize,
|
||||
target_index: usize,
|
||||
placement: DropPlacement,
|
||||
) -> Result<(), String> {
|
||||
// 读取并克隆源块内容
|
||||
let document = editor
|
||||
.get_json()
|
||||
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
|
||||
let content = document
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| "文档content 数组缺失".to_string())?;
|
||||
if source_index >= content.len() || target_index >= content.len() {
|
||||
return Err("移动块索引超出范围".to_string());
|
||||
}
|
||||
let source_block = content[source_index].clone();
|
||||
|
||||
// 删除源块
|
||||
delete_top_level_block_with_history(editor, source_index)?;
|
||||
|
||||
// 重新计算目标位置(删除后索引可能偏移)
|
||||
let adjusted_target =
|
||||
if source_index < target_index {
|
||||
target_index - 1
|
||||
} else {
|
||||
target_index
|
||||
};
|
||||
let document = editor
|
||||
.get_json()
|
||||
.map_err(|err| format!("重新读取 JSON 失败:{err}"))?;
|
||||
let range = top_level_block_full_range(&document, adjusted_target)
|
||||
.ok_or_else(|| format!("无法计算第 {adjusted_target} 个块的插入位置"))?;
|
||||
|
||||
let insert_at = match placement {
|
||||
DropPlacement::Before => range.from,
|
||||
DropPlacement::After => range.to,
|
||||
};
|
||||
|
||||
editor
|
||||
.insert_content_at(
|
||||
TiptapRange {
|
||||
from: insert_at,
|
||||
to: insert_at,
|
||||
},
|
||||
TiptapContent::json(source_block),
|
||||
Some(TiptapInsertContentOptions {
|
||||
update_selection: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.map_err(|err| format!("移动块失败:{err}"))?;
|
||||
|
||||
editor
|
||||
.focus()
|
||||
.map_err(|err| format!("聚焦编辑器失败:{err}"))
|
||||
}
|
||||
|
||||
/// 通过 Tiptap transaction 复制顶层块并插入其后,保留撤销/重做历史。
|
||||
pub(crate) fn duplicate_top_level_block_with_history(
|
||||
editor: &TiptapEditorHandle,
|
||||
source_index: usize,
|
||||
) -> Result<(), String> {
|
||||
let document = editor
|
||||
.get_json()
|
||||
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
|
||||
let content = document
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| "文档content 数组缺失".to_string())?;
|
||||
if source_index >= content.len() {
|
||||
return Err("复制块索引超出范围".to_string());
|
||||
}
|
||||
let source_block = content[source_index].clone();
|
||||
|
||||
let range = top_level_block_full_range(&document, source_index)
|
||||
.ok_or_else(|| format!("无法计算第 {source_index} 个块的位置"))?;
|
||||
|
||||
// 插入到源块之后
|
||||
editor
|
||||
.insert_content_at(
|
||||
TiptapRange {
|
||||
from: range.to,
|
||||
to: range.to,
|
||||
},
|
||||
TiptapContent::json(source_block),
|
||||
Some(TiptapInsertContentOptions {
|
||||
update_selection: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.map_err(|err| format!("复制块失败:{err}"))?;
|
||||
|
||||
editor
|
||||
.focus()
|
||||
.map_err(|err| format!("聚焦编辑器失败:{err}"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,904 @@
|
||||
//! mindmap shell 的 node view / runtime mount / event bridge。
|
||||
//!
|
||||
//! 本模块承接 mindmap block 的完整 NodeView 实现:
|
||||
//! - 类型定义(MindmapShellOptions 等)
|
||||
//! - Rust Leptos shell 组件(MindmapShell)
|
||||
//! - 工具栏、侧栏、导航栏子组件
|
||||
//! - simplemindmap event bridge(CustomEvent 派发)
|
||||
//! - mount / unmount 注册表
|
||||
//!
|
||||
//! #[wasm_bindgen] 薄包装器保留在 lib.rs 中。
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::ev;
|
||||
use leptos::mount::{mount_to, UnmountHandle};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_wasm_bindgen;
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use web_sys::{CustomEvent, CustomEventInit, EventTarget, HtmlElement, HtmlInputElement};
|
||||
|
||||
use crate::next_mount_id;
|
||||
|
||||
thread_local! {
|
||||
static MOUNTED_MINDMAP_SHELLS: std::cell::RefCell<HashMap<u32, MountedMindmapShell>> = std::cell::RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
pub(crate) const MINDMAP_SHELL_ACTION_EVENT: &str = "mnote:mindmap-shell:action";
|
||||
pub(crate) const MINDMAP_SHELL_PANEL_EVENT: &str = "mnote:mindmap-shell:panel";
|
||||
pub(crate) const MINDMAP_SHELL_MINIMAP_EVENT: &str = "mnote:mindmap-shell:minimap";
|
||||
pub(crate) const MINDMAP_SHELL_ZOOM_EVENT: &str = "mnote:mindmap-shell:zoom";
|
||||
pub(crate) const MINDMAP_SHELL_TOOLBAR_OVERFLOW_EVENT: &str = "mnote:mindmap-shell:toolbar-overflow";
|
||||
pub(crate) struct MountedMindmapShell {
|
||||
mount_handle: Box<dyn Any>,
|
||||
}
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellAction {
|
||||
id: String,
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
long_label: Option<String>,
|
||||
icon: String,
|
||||
#[serde(default)]
|
||||
icon_key: Option<String>,
|
||||
#[serde(default)]
|
||||
priority: Option<u32>,
|
||||
#[serde(default)]
|
||||
overflow_group: Option<String>,
|
||||
#[serde(default)]
|
||||
cluster: Option<String>,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellToolbarGroup {
|
||||
id: String,
|
||||
label: String,
|
||||
actions: Vec<MindmapShellAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellSidebarPanel {
|
||||
id: String,
|
||||
kind: String,
|
||||
label: String,
|
||||
icon: String,
|
||||
active: bool,
|
||||
body_title: String,
|
||||
body_caption: String,
|
||||
#[serde(default)]
|
||||
options: Vec<MindmapShellSidebarOption>,
|
||||
#[serde(default)]
|
||||
body_items: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellSidebarOption {
|
||||
id: String,
|
||||
label: String,
|
||||
action_id: Option<String>,
|
||||
value: Value,
|
||||
control_type: String,
|
||||
#[serde(default)]
|
||||
preview: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
readonly: bool,
|
||||
compat_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellNavigator {
|
||||
word_count: u32,
|
||||
node_count: u32,
|
||||
zoom_percent: u32,
|
||||
readonly: bool,
|
||||
#[serde(default)]
|
||||
minimap_open: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellToolbarOverflowState {
|
||||
available_width: Option<f64>,
|
||||
#[serde(default)]
|
||||
visible_action_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
overflow_action_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
more_open: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellFullscreenState {
|
||||
mode: String,
|
||||
is_fullscreen: bool,
|
||||
target: String,
|
||||
#[serde(default = "default_true")]
|
||||
api_available: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellSidebarState {
|
||||
trigger_visible: bool,
|
||||
panel_open: bool,
|
||||
active_panel_id: Option<String>,
|
||||
drawer_width: u32,
|
||||
collapsed_by_toggle: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellNavigatorState {
|
||||
search_open: bool,
|
||||
minimap_open: bool,
|
||||
readonly: bool,
|
||||
zoom_percent: u32,
|
||||
mouse_behavior: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellInteractionState {
|
||||
chrome_visibility: String,
|
||||
toolbar_overflow: MindmapShellToolbarOverflowState,
|
||||
fullscreen: MindmapShellFullscreenState,
|
||||
sidebar: MindmapShellSidebarState,
|
||||
navigator: MindmapShellNavigatorState,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellOptions {
|
||||
mindmap_id: String,
|
||||
toolbar_groups: Vec<MindmapShellToolbarGroup>,
|
||||
sidebar_panels: Vec<MindmapShellSidebarPanel>,
|
||||
navigator: MindmapShellNavigator,
|
||||
shell: MindmapShellInteractionState,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellActionPayload<'a> {
|
||||
action_id: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
value: Option<&'a Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
compat_path: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
source: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellPanelPayload<'a> {
|
||||
event: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
panel_id: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellMinimapPayload {
|
||||
open: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellZoomPayload {
|
||||
percent: u32,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MindmapShellToolbarOverflowPayload {
|
||||
more_open: bool,
|
||||
}
|
||||
|
||||
fn dispatch_mindmap_shell_event<T>(target: &EventTarget, event_name: &'static str, payload: &T)
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let Ok(detail_value) = serde_wasm_bindgen::to_value(payload) else {
|
||||
return;
|
||||
};
|
||||
let init = CustomEventInit::new();
|
||||
init.set_detail(&detail_value);
|
||||
init.set_bubbles(true);
|
||||
if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) {
|
||||
let _ = target.dispatch_event(&event);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_mindmap_toolbar_action(
|
||||
action: MindmapShellAction,
|
||||
class_name: &'static str,
|
||||
source: &'static str,
|
||||
action_target: EventTarget,
|
||||
) -> impl IntoView {
|
||||
let action_id = action.id.clone();
|
||||
let action_label_title = action
|
||||
.long_label
|
||||
.clone()
|
||||
.filter(|label| !label.is_empty())
|
||||
.unwrap_or_else(|| action.label.clone());
|
||||
let action_label_aria = action_label_title.clone();
|
||||
let action_label_text = action.label.clone();
|
||||
let action_icon_text = action.icon.clone();
|
||||
let action_icon_key = action.icon_key.clone().unwrap_or_default();
|
||||
let action_overflow_group = action.overflow_group.clone().unwrap_or_default();
|
||||
let action_cluster = action.cluster.clone().unwrap_or_default();
|
||||
let action_priority = action.priority.unwrap_or(u32::MAX).to_string();
|
||||
let action_disabled = action.disabled;
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class=class_name
|
||||
data-testid=format!("mindmap-schema-toolbar-action-{}", action_id)
|
||||
data-mindmap-action-id=action.id.clone()
|
||||
data-icon-key=action_icon_key
|
||||
data-overflow-group=action_overflow_group
|
||||
data-toolbar-cluster=action_cluster
|
||||
data-priority=action_priority
|
||||
title=action_label_title
|
||||
aria-label=action_label_aria
|
||||
disabled=action_disabled
|
||||
on:click=move |_| {
|
||||
dispatch_mindmap_shell_event(
|
||||
&action_target,
|
||||
MINDMAP_SHELL_ACTION_EVENT,
|
||||
&MindmapShellActionPayload {
|
||||
action_id: &action_id,
|
||||
value: None,
|
||||
compat_path: None,
|
||||
source: Some(source),
|
||||
},
|
||||
);
|
||||
}
|
||||
>
|
||||
<span class="mnote-mindmap-tool-icon" aria-hidden="true">{action_icon_text}</span>
|
||||
<span class="mnote-mindmap-tool-label">{action_label_text}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_mindmap_sidebar_option(
|
||||
option: MindmapShellSidebarOption,
|
||||
action_target: EventTarget,
|
||||
) -> impl IntoView {
|
||||
let option_id = option.id.clone();
|
||||
let option_label = option.label.clone();
|
||||
let option_control_type = option.control_type.clone();
|
||||
let action_id = option.action_id.clone();
|
||||
let option_value = option.value.clone();
|
||||
let compat_path = option.compat_path.clone();
|
||||
let option_preview = option.preview.clone().unwrap_or_default();
|
||||
let option_description = option.description.clone().unwrap_or_default();
|
||||
let option_readonly = option.readonly;
|
||||
let option_action_id_attr = action_id.clone().unwrap_or_default();
|
||||
|
||||
if option_readonly || action_id.is_none() {
|
||||
return view! {
|
||||
<div
|
||||
class="mnote-mindmap-side-option mnote-mindmap-side-option-readonly"
|
||||
data-testid=format!("mindmap-schema-sidebar-option-{}", option_id)
|
||||
data-mindmap-sidebar-option-id=option.id
|
||||
data-control-type=option_control_type.clone()
|
||||
data-readonly="true"
|
||||
>
|
||||
<span class="mnote-mindmap-side-option-label">{option_label}</span>
|
||||
{if option_description.is_empty() {
|
||||
().into_any()
|
||||
} else {
|
||||
view! { <span class="mnote-mindmap-side-option-description">{option_description}</span> }.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
|
||||
let button_class = if option_control_type == "layoutCard" {
|
||||
"mnote-mindmap-side-option mnote-mindmap-side-option-layout-card"
|
||||
} else if option_control_type == "swatch" {
|
||||
"mnote-mindmap-side-option mnote-mindmap-side-option-swatch"
|
||||
} else if option_control_type == "treeItem" {
|
||||
"mnote-mindmap-side-option mnote-mindmap-side-option-tree-item"
|
||||
} else {
|
||||
"mnote-mindmap-side-option"
|
||||
};
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class=button_class
|
||||
data-testid=format!("mindmap-schema-sidebar-option-{}", option_id)
|
||||
data-mindmap-sidebar-option-id=option.id
|
||||
data-mindmap-action-id=option_action_id_attr
|
||||
data-control-type=option_control_type.clone()
|
||||
on:click=move |_| {
|
||||
if let Some(action_id) = action_id.as_deref() {
|
||||
dispatch_mindmap_shell_event(
|
||||
&action_target,
|
||||
MINDMAP_SHELL_ACTION_EVENT,
|
||||
&MindmapShellActionPayload {
|
||||
action_id,
|
||||
value: Some(&option_value),
|
||||
compat_path: compat_path.as_deref(),
|
||||
source: Some("sidebar"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
>
|
||||
{if option_control_type == "swatch" {
|
||||
view! {
|
||||
<span class="mnote-mindmap-side-option-swatch-chip" style=format!("background:{};", option_preview.clone())></span>
|
||||
}
|
||||
.into_any()
|
||||
} else if option_control_type == "layoutCard" {
|
||||
view! {
|
||||
<span class="mnote-mindmap-side-option-preview-card">{option_preview.clone()}</span>
|
||||
}
|
||||
.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
<span class="mnote-mindmap-side-option-label">{option_label}</span>
|
||||
{if option_description.is_empty() {
|
||||
().into_any()
|
||||
} else {
|
||||
view! { <span class="mnote-mindmap-side-option-description">{option_description}</span> }.into_any()
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub(crate) fn MindmapShell(options: MindmapShellOptions, event_target: EventTarget) -> impl IntoView {
|
||||
let toolbar_groups = options.toolbar_groups.clone();
|
||||
let sidebar_panels = options.sidebar_panels.clone();
|
||||
let navigator = options.navigator.clone();
|
||||
let shell = options.shell.clone();
|
||||
let minimap_open = shell.navigator.minimap_open;
|
||||
let search_open = shell.navigator.search_open;
|
||||
let sidebar_panel_open = shell.sidebar.panel_open;
|
||||
let sidebar_trigger_visible = shell.sidebar.trigger_visible;
|
||||
let active_panel_id = shell.sidebar.active_panel_id.clone();
|
||||
let sidebar_drawer_width = shell.sidebar.drawer_width.to_string();
|
||||
let chrome_visibility = shell.chrome_visibility.clone();
|
||||
let fullscreen_mode = shell.fullscreen.mode.clone();
|
||||
let fullscreen_target = shell.fullscreen.target.clone();
|
||||
let navigator_mouse_behavior = shell.navigator.mouse_behavior.clone();
|
||||
let toolbar_available_width = shell
|
||||
.toolbar_overflow
|
||||
.available_width
|
||||
.map(|width| width.round().to_string())
|
||||
.unwrap_or_else(|| "".to_string());
|
||||
let chrome_visible = shell.chrome_visibility == "visible";
|
||||
let toolbar_visible_actions = shell.toolbar_overflow.visible_action_ids.join(",");
|
||||
let toolbar_overflow_actions = shell.toolbar_overflow.overflow_action_ids.join(",");
|
||||
let sidebar_drawer_width_value = shell.sidebar.drawer_width;
|
||||
let navigator_right_offset = if chrome_visible && sidebar_panel_open && sidebar_trigger_visible
|
||||
{
|
||||
sidebar_drawer_width_value + 112
|
||||
} else if chrome_visible && sidebar_trigger_visible {
|
||||
108
|
||||
} else {
|
||||
28
|
||||
};
|
||||
let navigator_right_style = format!("right: {}px;", navigator_right_offset);
|
||||
let minimap_right_style = format!("right: {}px;", navigator_right_offset);
|
||||
let mut primary_toolbar_actions: Vec<MindmapShellAction> = Vec::new();
|
||||
let mut overflow_toolbar_actions: Vec<MindmapShellAction> = Vec::new();
|
||||
let mut file_toolbar_actions: Vec<MindmapShellAction> = Vec::new();
|
||||
|
||||
for group in toolbar_groups.into_iter() {
|
||||
match group.id.as_str() {
|
||||
"overflow" => overflow_toolbar_actions.extend(group.actions),
|
||||
"file" => file_toolbar_actions.extend(group.actions),
|
||||
_ => primary_toolbar_actions.extend(group.actions),
|
||||
}
|
||||
}
|
||||
primary_toolbar_actions.sort_by_key(|action| action.priority.unwrap_or(u32::MAX));
|
||||
overflow_toolbar_actions.sort_by_key(|action| action.priority.unwrap_or(u32::MAX));
|
||||
file_toolbar_actions.sort_by_key(|action| action.priority.unwrap_or(u32::MAX));
|
||||
let has_toolbar_overflow = !overflow_toolbar_actions.is_empty();
|
||||
let toolbar_more_open = shell.toolbar_overflow.more_open && has_toolbar_overflow;
|
||||
|
||||
let action_target = event_target.clone();
|
||||
let panel_target = event_target;
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="mnote-mindmap-rust-shell"
|
||||
data-testid="mindmap-rust-shell"
|
||||
data-ui-shell-source="leptos-rust-shell"
|
||||
data-mnote-mindmap-id=options.mindmap_id
|
||||
data-chrome-visibility=chrome_visibility
|
||||
data-toolbar-available-width=toolbar_available_width
|
||||
data-toolbar-visible-actions=toolbar_visible_actions
|
||||
data-toolbar-overflow-actions=toolbar_overflow_actions
|
||||
data-toolbar-more-open=toolbar_more_open.to_string()
|
||||
data-fullscreen-mode=fullscreen_mode
|
||||
data-fullscreen-active=shell.fullscreen.is_fullscreen.to_string()
|
||||
data-fullscreen-target=fullscreen_target
|
||||
data-fullscreen-api-available=shell.fullscreen.api_available.to_string()
|
||||
data-sidebar-trigger-visible=sidebar_trigger_visible.to_string()
|
||||
data-sidebar-panel-open=sidebar_panel_open.to_string()
|
||||
data-sidebar-active-panel=active_panel_id.unwrap_or_default()
|
||||
data-sidebar-drawer-width=sidebar_drawer_width
|
||||
data-sidebar-collapsed-by-toggle=shell.sidebar.collapsed_by_toggle.to_string()
|
||||
data-navigator-search-open=shell.navigator.search_open.to_string()
|
||||
data-navigator-minimap-open=shell.navigator.minimap_open.to_string()
|
||||
data-navigator-readonly=shell.navigator.readonly.to_string()
|
||||
data-navigator-zoom-percent=shell.navigator.zoom_percent.to_string()
|
||||
data-navigator-mouse-behavior=navigator_mouse_behavior
|
||||
>
|
||||
{if chrome_visible {
|
||||
view! { <div
|
||||
class="mnote-mindmap-command-toolbar mnote-mindmap-schema-toolbar"
|
||||
data-testid="mindmap-schema-toolbar"
|
||||
data-ui-source="leptos-rust-shell"
|
||||
data-schema-source="mindmapDefaultUiSchema"
|
||||
>
|
||||
<div class="mnote-mindmap-toolbar-primary" data-testid="mindmap-schema-toolbar-groups">
|
||||
<div class="mnote-mindmap-toolbar-cluster mnote-mindmap-toolbar-cluster-main" data-testid="mindmap-schema-toolbar-cluster-main">
|
||||
{primary_toolbar_actions
|
||||
.into_iter()
|
||||
.map(|action| render_mindmap_toolbar_action(action, "mnote-mindmap-tool", "toolbar", action_target.clone()))
|
||||
.collect_view()}
|
||||
</div>
|
||||
{if has_toolbar_overflow {
|
||||
let overflow_toggle_target = action_target.clone();
|
||||
let overflow_menu_actions = overflow_toolbar_actions.clone();
|
||||
view! {
|
||||
<div class="mnote-mindmap-toolbar-more" data-testid="mindmap-schema-toolbar-more">
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-mindmap-tool mnote-mindmap-toolbar-more-button"
|
||||
data-testid="mindmap-schema-toolbar-action-more"
|
||||
data-mindmap-action-id="more"
|
||||
data-open=toolbar_more_open.to_string()
|
||||
aria-haspopup="menu"
|
||||
aria-expanded=toolbar_more_open.to_string()
|
||||
title="更多"
|
||||
aria-label="更多"
|
||||
on:click=move |_| {
|
||||
dispatch_mindmap_shell_event(
|
||||
&overflow_toggle_target,
|
||||
MINDMAP_SHELL_TOOLBAR_OVERFLOW_EVENT,
|
||||
&MindmapShellToolbarOverflowPayload { more_open: !toolbar_more_open },
|
||||
);
|
||||
}
|
||||
>
|
||||
<span class="mnote-mindmap-tool-icon" aria-hidden="true">"⋯"</span>
|
||||
<span class="mnote-mindmap-tool-label">"更多"</span>
|
||||
</button>
|
||||
{if toolbar_more_open {
|
||||
view! {
|
||||
<div class="mnote-mindmap-toolbar-more-menu" data-testid="mindmap-schema-toolbar-more-menu" role="menu">
|
||||
{overflow_menu_actions
|
||||
.into_iter()
|
||||
.map(|action| render_mindmap_toolbar_action(action, "mnote-mindmap-tool mnote-mindmap-toolbar-more-item", "toolbar", action_target.clone()))
|
||||
.collect_view()}
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
<div class="mnote-mindmap-toolbar-cluster mnote-mindmap-toolbar-cluster-file" data-testid="mindmap-schema-toolbar-cluster-file">
|
||||
{file_toolbar_actions
|
||||
.into_iter()
|
||||
.map(|action| render_mindmap_toolbar_action(action, "mnote-mindmap-tool", "toolbar", action_target.clone()))
|
||||
.collect_view()}
|
||||
</div>
|
||||
</div>
|
||||
</div> }.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
{if chrome_visible {
|
||||
view! { <aside
|
||||
class="mnote-mindmap-side-panel mnote-mindmap-schema-sidebar"
|
||||
data-testid="mindmap-schema-sidebar"
|
||||
data-ui-source="leptos-rust-shell"
|
||||
data-schema-source="mindmapDefaultUiSchema"
|
||||
data-trigger-visible=sidebar_trigger_visible.to_string()
|
||||
data-panel-open=sidebar_panel_open.to_string()
|
||||
data-collapsed-by-toggle=shell.sidebar.collapsed_by_toggle.to_string()
|
||||
>
|
||||
{if sidebar_trigger_visible {
|
||||
view! {
|
||||
<div class="mnote-mindmap-side-rail" data-testid="mindmap-schema-sidebar-rail">
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-mindmap-side-rail-handle"
|
||||
data-testid="mindmap-schema-sidebar-hide-handle"
|
||||
aria-label="隐藏侧栏"
|
||||
title="隐藏侧栏"
|
||||
on:click={
|
||||
let panel_target = panel_target.clone();
|
||||
move |_| {
|
||||
dispatch_mindmap_shell_event(
|
||||
&panel_target,
|
||||
MINDMAP_SHELL_PANEL_EVENT,
|
||||
&MindmapShellPanelPayload {
|
||||
event: "hideTrigger",
|
||||
panel_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
>
|
||||
"›"
|
||||
</button>
|
||||
{sidebar_panels
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|panel| {
|
||||
let panel_id = panel.id.clone();
|
||||
let panel_kind = panel.kind.clone();
|
||||
let panel_target = panel_target.clone();
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-mindmap-side-tab"
|
||||
data-testid=format!("mindmap-schema-sidebar-tab-{}", panel_id)
|
||||
data-mindmap-sidebar-panel-id=panel.id.clone()
|
||||
data-sidebar-kind=panel_kind
|
||||
data-active=panel.active.to_string()
|
||||
on:click=move |_| {
|
||||
dispatch_mindmap_shell_event(
|
||||
&panel_target,
|
||||
MINDMAP_SHELL_PANEL_EVENT,
|
||||
&MindmapShellPanelPayload {
|
||||
event: "togglePanel",
|
||||
panel_id: Some(&panel_id),
|
||||
},
|
||||
);
|
||||
}
|
||||
>
|
||||
<span class="mnote-mindmap-side-icon">{panel.icon}</span>
|
||||
<span class="mnote-mindmap-side-tab-label">{panel.label}</span>
|
||||
</button>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-mindmap-side-restore-handle"
|
||||
data-testid="mindmap-schema-sidebar-restore-handle"
|
||||
aria-label="恢复侧栏"
|
||||
title="恢复侧栏"
|
||||
on:click={
|
||||
let panel_target = panel_target.clone();
|
||||
move |_| {
|
||||
dispatch_mindmap_shell_event(
|
||||
&panel_target,
|
||||
MINDMAP_SHELL_PANEL_EVENT,
|
||||
&MindmapShellPanelPayload {
|
||||
event: "restoreTrigger",
|
||||
panel_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
>
|
||||
"‹"
|
||||
</button>
|
||||
}
|
||||
.into_any()
|
||||
}}
|
||||
{if sidebar_panel_open {
|
||||
sidebar_panels
|
||||
.iter()
|
||||
.find(|panel| panel.active)
|
||||
.or_else(|| sidebar_panels.first())
|
||||
.cloned()
|
||||
.map(|panel| view! {
|
||||
<div
|
||||
class="mnote-mindmap-side-body mnote-mindmap-side-drawer"
|
||||
data-testid="mindmap-schema-sidebar-drawer"
|
||||
data-sidebar-panel-id=panel.id.clone()
|
||||
data-sidebar-kind=panel.kind.clone()
|
||||
style=format!("width: {}px;", sidebar_drawer_width_value)
|
||||
>
|
||||
<div class="mnote-mindmap-side-drawer-header">
|
||||
<div class="mnote-mindmap-side-drawer-heading">
|
||||
<strong data-testid="mindmap-schema-sidebar-title">{panel.body_title}</strong>
|
||||
<span>{panel.body_caption}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-mindmap-side-drawer-close"
|
||||
data-testid="mindmap-schema-sidebar-close"
|
||||
aria-label="关闭侧栏"
|
||||
title="关闭侧栏"
|
||||
on:click={
|
||||
let panel_target = panel_target.clone();
|
||||
move |_| {
|
||||
dispatch_mindmap_shell_event(
|
||||
&panel_target,
|
||||
MINDMAP_SHELL_PANEL_EVENT,
|
||||
&MindmapShellPanelPayload {
|
||||
event: "closeDrawer",
|
||||
panel_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
>
|
||||
"×"
|
||||
</button>
|
||||
</div>
|
||||
<div class="mnote-mindmap-side-options" data-testid=format!("mindmap-schema-sidebar-options-{}", panel.id)>
|
||||
{panel.options
|
||||
.into_iter()
|
||||
.map(|option| render_mindmap_sidebar_option(option, action_target.clone()))
|
||||
.collect_view()}
|
||||
</div>
|
||||
<ol class="mnote-mindmap-side-outline" data-testid=format!("mindmap-schema-sidebar-outline-{}", panel.id)>
|
||||
{panel.body_items
|
||||
.into_iter()
|
||||
.map(|item| view! { <li>{item}</li> })
|
||||
.collect_view()}
|
||||
</ol>
|
||||
</div>
|
||||
}.into_any())
|
||||
.unwrap_or_else(|| ().into_any())
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
</aside> }.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
<div
|
||||
class="mnote-mindmap-count"
|
||||
data-testid="mindmap-schema-count"
|
||||
data-ui-source="leptos-rust-shell"
|
||||
data-schema-source="mindmapDefaultUiSchema"
|
||||
>
|
||||
<span data-testid="mindmap-schema-navigator-stats">{format!("字数 {}", navigator.word_count)}</span>
|
||||
<span>{format!("节点 {}", navigator.node_count)}</span>
|
||||
</div>
|
||||
{if chrome_visible {
|
||||
view! { <div
|
||||
class="mnote-mindmap-bottom-bar mnote-mindmap-schema-navigator"
|
||||
data-testid="mindmap-schema-navigator"
|
||||
data-ui-source="leptos-rust-shell"
|
||||
data-schema-source="mindmapDefaultUiSchema"
|
||||
style=navigator_right_style
|
||||
>
|
||||
<div class="mnote-mindmap-schema-navigator-group">
|
||||
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-centerRoot" data-mindmap-action-id="centerRoot" title="回根节点" aria-label="回根节点" on:click={
|
||||
let action_target = action_target.clone();
|
||||
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
|
||||
action_id: "centerRoot",
|
||||
value: None,
|
||||
compat_path: None,
|
||||
source: None,
|
||||
})
|
||||
}><span class="material-symbols-outlined" data-icon="home" aria-hidden="true"></span></button>
|
||||
<div class="mnote-mindmap-schema-navigator-search-wrap">
|
||||
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-search" data-mindmap-action-id="search" data-active=search_open.to_string() title="搜索" aria-label="搜索" on:click={
|
||||
let action_target = action_target.clone();
|
||||
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
|
||||
action_id: "search",
|
||||
value: None,
|
||||
compat_path: None,
|
||||
source: None,
|
||||
})
|
||||
}><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span></button>
|
||||
{if search_open {
|
||||
view! {
|
||||
<input
|
||||
data-testid="mindmap-schema-navigator-search"
|
||||
data-mindmap-action-id="search"
|
||||
placeholder="搜索"
|
||||
aria-label="搜索"
|
||||
on:input={
|
||||
let action_target = action_target.clone();
|
||||
move |event| {
|
||||
let value = event_target_value(&event);
|
||||
let value = json!(value);
|
||||
dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
|
||||
action_id: "search",
|
||||
value: Some(&value),
|
||||
compat_path: None,
|
||||
source: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
on:keydown={
|
||||
let action_target = action_target.clone();
|
||||
move |event: ev::KeyboardEvent| {
|
||||
if event.key() == "Escape" {
|
||||
let value = json!(false);
|
||||
dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
|
||||
action_id: "search",
|
||||
value: Some(&value),
|
||||
compat_path: None,
|
||||
source: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
</div>
|
||||
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-minimap" data-active=minimap_open.to_string() title="小地图" aria-label="小地图" on:click={
|
||||
let action_target = action_target.clone();
|
||||
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_MINIMAP_EVENT, &MindmapShellMinimapPayload { open: !minimap_open })
|
||||
}><span class="material-symbols-outlined" data-icon="account_tree" aria-hidden="true"></span></button>
|
||||
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-settings" data-active=(sidebar_panel_open && sidebar_trigger_visible).to_string() title="设置" aria-label="设置" on:click={
|
||||
let action_target = action_target.clone();
|
||||
let active_panel_id = active_panel_id.clone();
|
||||
move |_| {
|
||||
let event = if sidebar_trigger_visible { "togglePanel" } else { "restoreTrigger" };
|
||||
dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_PANEL_EVENT, &MindmapShellPanelPayload {
|
||||
event,
|
||||
panel_id: active_panel_id.as_deref(),
|
||||
})
|
||||
}
|
||||
}><span class="mnote-mindmap-tool-icon" aria-hidden="true">"⚙"</span></button>
|
||||
</div>
|
||||
<div class="mnote-mindmap-schema-navigator-group">
|
||||
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-zoomOut" data-mindmap-action-id="zoomOut" title="缩小" aria-label="缩小" on:click={
|
||||
let action_target = action_target.clone();
|
||||
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
|
||||
action_id: "zoomOut",
|
||||
value: None,
|
||||
compat_path: None,
|
||||
source: None,
|
||||
})
|
||||
}><span class="mnote-mindmap-tool-icon" aria-hidden="true">"-"</span></button>
|
||||
<input
|
||||
class="mnote-mindmap-schema-navigator-zoom"
|
||||
data-testid="mindmap-schema-navigator-zoom-input"
|
||||
value=format!("{}%", navigator.zoom_percent)
|
||||
aria-label="缩放百分比"
|
||||
on:change={
|
||||
let action_target = action_target.clone();
|
||||
move |event| {
|
||||
let raw = event_target_value(&event);
|
||||
let normalized = raw.replace('%', "").trim().to_string();
|
||||
if let Ok(percent) = normalized.parse::<u32>() {
|
||||
dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ZOOM_EVENT, &MindmapShellZoomPayload { percent });
|
||||
} else if let Some(target) = event.target() {
|
||||
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||
input.set_value(&format!("{}%", navigator.zoom_percent));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
on:keydown=move |event: ev::KeyboardEvent| {
|
||||
if event.key() == "Escape" {
|
||||
if let Some(target) = event.target() {
|
||||
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||
input.set_value(&format!("{}%", navigator.zoom_percent));
|
||||
input.blur().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-zoomIn" data-mindmap-action-id="zoomIn" title="放大" aria-label="放大" on:click={
|
||||
let action_target = action_target.clone();
|
||||
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
|
||||
action_id: "zoomIn",
|
||||
value: None,
|
||||
compat_path: None,
|
||||
source: None,
|
||||
})
|
||||
}><span class="material-symbols-outlined" data-icon="add" aria-hidden="true"></span></button>
|
||||
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-fullscreenCanvas" data-mindmap-action-id="fullscreenCanvas" data-active=shell.fullscreen.is_fullscreen.to_string() data-disabled-reason=if shell.fullscreen.api_available { "" } else { "fullscreen-unavailable" } disabled=!shell.fullscreen.api_available title=if shell.fullscreen.is_fullscreen { "退出全屏" } else { "全屏查看" } aria-label=if shell.fullscreen.is_fullscreen { "退出全屏" } else { "全屏查看" } on:click={
|
||||
let action_target = action_target.clone();
|
||||
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
|
||||
action_id: if shell.fullscreen.is_fullscreen { "exitFullscreen" } else { "fullscreenCanvas" },
|
||||
value: None,
|
||||
compat_path: None,
|
||||
source: None,
|
||||
})
|
||||
}><span class="mnote-mindmap-tool-icon" aria-hidden="true">{if shell.fullscreen.is_fullscreen { "⤫" } else { "⛶" }}</span></button>
|
||||
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-readonly" data-mindmap-action-id="readonly" data-active=navigator.readonly.to_string() title=if navigator.readonly { "切回编辑" } else { "切换只读" } aria-label=if navigator.readonly { "切回编辑" } else { "切换只读" } on:click={
|
||||
let action_target = action_target;
|
||||
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
|
||||
action_id: "readonly",
|
||||
value: None,
|
||||
compat_path: None,
|
||||
source: None,
|
||||
})
|
||||
}><span class="mnote-mindmap-tool-icon" aria-hidden="true">{if navigator.readonly { "锁" } else { "开" }}</span></button>
|
||||
</div>
|
||||
</div> }.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
{if minimap_open {
|
||||
view! {
|
||||
<div
|
||||
class="mnote-mindmap-minimap"
|
||||
data-testid="mindmap-schema-minimap"
|
||||
data-ui-source="leptos-rust-shell"
|
||||
style=minimap_right_style
|
||||
>
|
||||
<div class="mnote-mindmap-minimap-map">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="mnote-mindmap-minimap-viewport" data-testid="mindmap-schema-minimap-viewport"></div>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn take_mindmap_shell_handle(id: u32) -> Option<MountedMindmapShell> {
|
||||
MOUNTED_MINDMAP_SHELLS.with(|registry| registry.borrow_mut().remove(&id))
|
||||
}
|
||||
|
||||
pub(crate) fn mount_mindmap_shell_impl(container: JsValue, options: JsValue) -> Result<u32, JsValue> {
|
||||
console_error_panic_hook::set_once();
|
||||
let options = serde_wasm_bindgen::from_value::<MindmapShellOptions>(options)?;
|
||||
let target = container
|
||||
.dyn_into::<HtmlElement>()
|
||||
.map_err(|_| JsValue::from_str("mindmap shell mount 目标必须是 HTML 元素"))?;
|
||||
let mount_id = next_mount_id();
|
||||
let event_target: EventTarget = target.clone().into();
|
||||
let handle = mount_to(target, move || {
|
||||
view! {
|
||||
<MindmapShell options=options event_target=event_target />
|
||||
}
|
||||
});
|
||||
MOUNTED_MINDMAP_SHELLS.with(|registry| {
|
||||
registry.borrow_mut().insert(
|
||||
mount_id,
|
||||
MountedMindmapShell {
|
||||
mount_handle: Box::new(handle),
|
||||
},
|
||||
);
|
||||
});
|
||||
Ok(mount_id)
|
||||
}
|
||||
|
||||
pub(crate) fn unmount_mindmap_shell_impl(mount_id: u32) -> Result<(), JsValue> {
|
||||
if take_mindmap_shell_handle(mount_id).is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(JsValue::from_str("找不到对应的 mindmap shell 挂载句柄"))
|
||||
}
|
||||
}
|
||||
@@ -292,6 +292,23 @@ pub(crate) fn toolbar_overlay_locked(
|
||||
turn_into_open || color_menu_open || more_menu_open
|
||||
}
|
||||
|
||||
/// 判断是否应自动关闭 toolbar overlay。
|
||||
/// 当编辑器失焦、无选区且无锁定 toolbar,或 slash/block menu 打开时返回 true。
|
||||
pub(crate) fn should_auto_close_toolbar_overlays(
|
||||
editor_focused: bool,
|
||||
text_selection_active: bool,
|
||||
turn_into_open: bool,
|
||||
color_menu_open: bool,
|
||||
more_menu_open: bool,
|
||||
slash_open: bool,
|
||||
block_menu_open: bool,
|
||||
) -> bool {
|
||||
let toolbar_locked = toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open);
|
||||
((!editor_focused || !text_selection_active) && !toolbar_locked)
|
||||
|| slash_open
|
||||
|| block_menu_open
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn sync_overlays_on_selection_change(
|
||||
turn_into_open: ReadSignal<bool>,
|
||||
|
||||
@@ -3389,10 +3389,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn slash_menu_css_uses_viewport_overlay_layer() {
|
||||
assert!(STYLE.contains(".slash-menu"));
|
||||
assert!(STYLE.contains("position: fixed;"));
|
||||
assert!(STYLE.contains("z-index: 130;"));
|
||||
assert!(!STYLE.contains("z-index: 11;"));
|
||||
assert!(SPIKE_STYLE.contains(".slash-menu"));
|
||||
assert!(SPIKE_STYLE.contains("position: fixed;"));
|
||||
assert!(SPIKE_STYLE.contains("z-index: 130;"));
|
||||
assert!(!SPIKE_STYLE.contains("z-index: 11;"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user