feat: sidebar runtime extraction + history-safe commands + checklist updates

This commit is contained in:
lix-2026
2026-05-25 17:36:17 +08:00
parent baae38786d
commit d84da5583b
17 changed files with 13166 additions and 11050 deletions
@@ -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
+12
View File
@@ -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 bridgeCustomEvent 派发)
//! - 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>,
+4 -4
View File
@@ -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;"));
}
}