// ========================================
// 模块:侧边栏备注功能
// ========================================
import { $, debounce, throttle } from './utils.js';
import { config } from './config.js';
let isEnabled = false, observers = {}, editorNode = null, dragTimeout = null, dragMutationObserver = null, connectionCleanup = null;
// 配置常量
const CONFIG = {
SIDEBAR_WIDTH: 230,
MARGIN: 10,
EDITING_MARGIN: 5,
MIN_INPUT_HEIGHT: 60,
CONNECTION_OPACITY: 0.5,
Z_INDEX_EDITING: 999
};
const autoResizeDiv = div => {
div.style.height = 'auto';
div.style.height = (div.scrollHeight + 1) + 'px';
};
// 确保光标正确定位到最后一个字符后
const setCursorPositionToEnd = el => {
const range = document.createRange();
const selection = window.getSelection();
// 清除现有选择
selection.removeAllRanges();
// 将光标定位到内容末尾
if (el.lastChild) {
range.setStartAfter(el.lastChild);
} else {
range.selectNodeContents(el);
range.collapse(false);
}
selection.addRange(range);
el.focus();
};
// 获取块节点
const getBlockNode = el => {
while (el && !el.dataset.nodeId) el = el.parentElement;
return el && el.closest('[data-type="NodeBlockQueryEmbed"]')?.dataset.nodeId ?
el.closest('[data-type="NodeBlockQueryEmbed"]') : el;
};
// 检查块类型是否有效(用于行内备注)
const isValidBlockTypeForInlineMemo = block => {
return block && ['NodeParagraph', 'NodeHeading'].includes(block.getAttribute('data-type'));
};
// 检查是否在嵌入块内
const isInsideEmbedBlock = blockEl => {
return blockEl?.closest('[data-type="NodeBlockQueryEmbed"]');
};
// 获取备注内容的HTML格式
const getMemoContentHtml = memoItem => {
return memoItem.querySelector('.memo-content-view')?.innerHTML.replace(/
/g, '\n') || '';
};
// 更新块元素的备注内容
const updateBlockMemoContent = (blockEl, oldContent, newContent) => {
const memoSpans = Array.from(blockEl.querySelectorAll('span[data-type*="inline-memo"]'));
memoSpans.forEach(span => {
if (span.getAttribute('data-inline-memo-content') === oldContent) {
span.setAttribute('data-inline-memo-content', newContent);
}
});
// 更新块内容
return updateBlock(blockEl);
};
// 删除块元素中的备注
const removeBlockMemoContent = (blockEl, memoContent) => {
const memoSpans = Array.from(blockEl.querySelectorAll('span[data-type*="inline-memo"]'));
memoSpans.forEach(span => {
const content = span.getAttribute('data-inline-memo-content');
// 只删除内容匹配的备注元素
if (content === memoContent) {
let types = (span.getAttribute("data-type") || "").split(" ").filter(t => t !== "inline-memo");
if (types.length) {
span.setAttribute("data-type", types.join(" "));
span.removeAttribute("data-inline-memo-content");
} else {
span.outerHTML = span.innerHTML;
}
}
});
// 更新块内容
return updateBlock(blockEl);
};
// 统一的块更新函数
const updateBlock = (blockEl) => {
return fetch('/api/block/updateBlock', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Token ${window.siyuan?.config?.api?.token ?? ''}`
},
body: JSON.stringify({
dataType: 'html',
data: blockEl.outerHTML,
id: blockEl.dataset.nodeId
})
});
};
// 高亮备注元素
const highlightMemoElements = (blockEl, memoContent) => {
const memoSpans = Array.from(blockEl.querySelectorAll('span[data-type*="inline-memo"]'));
// 高亮所有相同内容的备注元素
const targetSpans = memoSpans.filter(span =>
span.getAttribute('data-inline-memo-content') === memoContent
);
targetSpans.forEach(span => span.classList.add('memo-span-highlight'));
// 返回最后一个匹配的备注元素
return targetSpans[targetSpans.length - 1];
};
// 创建备忘录连接线
const createMemoConnection = (memoDiv, memoSpan) => {
removeMemoConnection();
if (!memoDiv || !memoSpan) return;
const container = document.createElement('div');
container.id = 'memo-connection-container';
container.style = 'position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:998;will-change:transform;';
container.innerHTML = `
`;
document.body.appendChild(container);
const path = container.querySelector('path');
const updatePath = () => {
const a = memoDiv.getBoundingClientRect(), b = memoSpan.getBoundingClientRect();
if (!a.width || !b.width) return;
const sx = b.right, sy = b.top + b.height / 2, ex = a.left - 6, ey = a.top + a.height / 2;
const o = Math.min(Math.abs(ex - sx) * 0.5, 200);
path.setAttribute('d', `M${sx} ${sy}C${sx + o} ${sy},${ex - o} ${ey},${ex} ${ey}`);
};
updatePath();
const onScroll = () => requestAnimationFrame(updatePath);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onScroll);
connectionCleanup = () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onScroll);
container.remove();
connectionCleanup = null;
};
};
const removeMemoConnection = () => connectionCleanup?.();
// 判断某块及其所有父块是否有折叠
const isAnyAncestorFolded = block => {
for (let current = block; current; current = current.parentElement) {
if (current.getAttribute?.('fold') === '1') return true;
}
return false;
};
// 拖拽监听
const observeDragTitle = () => {
if (dragMutationObserver) return;
const setupObserver = () => {
const dragEl = document.getElementById('drag');
if (!dragEl) {
dragTimeout = setTimeout(setupObserver, 1000);
return;
}
dragMutationObserver = new MutationObserver(refreshEditor);
dragMutationObserver.observe(dragEl, { attributes: true, attributeFilter: ['title'] });
};
setupObserver();
};
const unobserveDragTitle = () => {
dragMutationObserver?.disconnect();
dragMutationObserver = null;
dragTimeout && clearTimeout(dragTimeout);
dragTimeout = null;
};
// 刷新侧边栏备注位置
const refreshMemoOffset = (main, sidebar) => {
requestAnimationFrame(() => {
let lastBottom = 0;
Array.from(sidebar.querySelectorAll('.memo-item'))
.filter(item => item.style.display !== 'none')
.forEach(memoItem => {
const nodeId = memoItem.getAttribute('data-node-id');
const memoType = memoItem.getAttribute('data-memo-type');
const block = main.querySelector(`div[data-node-id="${nodeId}"]`);
if (!block) return;
// 行内备注只处理 NodeParagraph 和 NodeHeading 类型的节点
const isValidBlockType = memoType === 'block' || isValidBlockTypeForInlineMemo(block);
if (!isValidBlockType) return;
let targetTop = 0;
if (memoType === 'block') {
// 块备注:与块的顶部对齐
const blockRect = block.getBoundingClientRect();
const mainRect = main.getBoundingClientRect();
targetTop = blockRect.top - mainRect.top - (memoItem.offsetHeight / 2);
} else {
// 行内备注:定位到第一个相关元素的位置
const blockRect = block.getBoundingClientRect();
const mainRect = main.getBoundingClientRect();
// 获取所有行内备注元素
const memoSpans = Array.from(block.querySelectorAll('span[data-type*="inline-memo"]'));
// 使用innerHTML而不是textContent来正确处理换行符
const memoContent = getMemoContentHtml(memoItem);
// 找到第一个包含相同备注内容的元素
const targetSpan = memoSpans.find(span =>
span.getAttribute('data-inline-memo-content') === memoContent
);
targetTop = targetSpan ?
targetSpan.getBoundingClientRect().top - mainRect.top +
(targetSpan.getBoundingClientRect().height / 2) - (memoItem.offsetHeight / 2) :
blockRect.top - mainRect.top + blockRect.height / 2 - memoItem.offsetHeight / 2;
}
// 重叠检查 - 更智能的间距处理
const isEditing = memoItem.classList.contains('editing');
// 对于编辑中的项,使用更精确的位置计算
if (isEditing) {
// 确保编辑项不会与前一项重叠,但也不要过度拉开距离
targetTop = Math.max(targetTop, lastBottom + CONFIG.EDITING_MARGIN); // 编辑时使用较小的间距
} else {
// 对于非编辑项,保持正常的间距
targetTop = Math.max(targetTop, lastBottom + CONFIG.MARGIN);
}
memoItem.style.position = 'absolute';
memoItem.style.top = `${targetTop}px`;
memoItem.style.transition = 'top 0.3s cubic-bezier(0.4,0,0.2,1),transform 0.3s cubic-bezier(0.4,0,0.2,1)';
lastBottom = targetTop + memoItem.offsetHeight;
});
});
};
// 添加侧边栏
const addSideBar = main => {
const sidebar = main.parentElement.querySelector('#protyle-sidebar');
const title = main.parentElement.querySelector('div.protyle-title');
if (!sidebar && title) {
const newSidebar = document.createElement('div');
newSidebar.id = 'protyle-sidebar';
title.insertAdjacentElement('beforeend', newSidebar);
newSidebar.style.cssText = `position:absolute;right:-${CONFIG.SIDEBAR_WIDTH}px;width:${CONFIG.SIDEBAR_WIDTH}px;`;
main.style.minWidth = '90%';
return newSidebar;
}
return sidebar;
};
// 处理备注编辑和保存
const handleMemoEdit = (memoDiv, el, main, sidebar) => {
const memoType = memoDiv.getAttribute('data-memo-type');
const isBlockMemo = memoType === 'block';
// 获取旧值
const old = isBlockMemo ?
(el.getAttribute('memo') || '') :
(memoDiv.querySelector('.memo-content-view')?.innerHTML.replace(/
/g, '\n') || '');
const input = document.createElement('div');
input.className = 'memo-edit-input';
input.contentEditable = 'true';
input.innerHTML = old.replace(/\n/g, '
');
input.setAttribute('placeholder', '输入备注内容...');
input.style.cssText = `width:100%;min-height:${CONFIG.MIN_INPUT_HEIGHT}px;padding:6px;border:1px solid var(--b3-theme-primary);border-radius:8px;font-size:0.8em;resize:vertical;box-sizing:border-box;overflow:auto;outline:none;white-space:pre-wrap;word-break:break-all;overflow-y:hidden;`;
input.addEventListener('input', () => autoResizeDiv(input));
const save = () => {
// 保留原始HTML格式用于显示,同时提取纯文本用于比较
const val = input.innerHTML.replace(/
/gi, '\n').replace(/<\/p>/gi, '').replace(/ /g, ' ').replace(/\n\s*\n\s*\n+/g, '\n\n').trim(); const htmlVal = input.innerHTML; if (val !== old) { if (isBlockMemo) { // 块备注更新逻辑 el.setAttribute('memo', val); updateBlock(el); } else { // 行内备注更新逻辑 const nodeId = memoDiv.getAttribute('data-node-id'); const blockEl = main.querySelector(`div[data-node-id="${nodeId}"]`); if (blockEl) { // 更新块备注内容 updateBlockMemoContent(blockEl, old, val); } } } const newDiv = document.createElement('div'); newDiv.className = 'memo-content-view'; newDiv.style.cssText = 'font-size:0.8em;margin-bottom:4px;cursor:pointer;'; // 显示值设置 - 正确处理换行符 const displayVal = htmlVal || '点击编辑备注...'; newDiv.innerHTML = displayVal; newDiv.onclick = (e) => { // 检查是否已经在编辑状态 if (memoDiv.classList.contains('editing')) return; const { input: newInput, save: newSave } = handleMemoEdit(memoDiv, el, main, sidebar); newDiv.replaceWith(newInput); memoDiv.classList.add('editing'); memoDiv.style.zIndex = `${CONFIG.Z_INDEX_EDITING}`; newInput.focus(); setCursorPositionToEnd(newInput); requestAnimationFrame(() => { autoResizeDiv(newInput); refreshMemoOffset(main, sidebar); }); }; input.replaceWith(newDiv); memoDiv.classList.remove('editing'); memoDiv.style.zIndex = ''; refreshMemoOffset(main, sidebar); }; // 设置输入框事件 input.onblur = () => setTimeout(() => { if (document.activeElement !== input) save(); }, 100); input.onkeydown = e => { if ((e.key === 'Enter' && e.ctrlKey) || e.key === 'Escape') { e.preventDefault(); save(); } else if (e.key === 'a' && e.ctrlKey) { e.preventDefault(); const range = document.createRange(); range.selectNodeContents(input); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); } }; input.addEventListener('paste', e => { e.preventDefault(); const text = e.clipboardData?.getData('text'); text && document.execCommand('insertText', false, text); }, true); autoResizeDiv(input); return { input, save }; }; // 刷新侧边栏备注 const refreshSideBarMemos = (main, sidebar) => { // 清除之前的内容 sidebar.innerHTML = ''; const frag = document.createDocumentFragment(); const isReadonly = main.getAttribute?.('data-readonly') === 'true'; let visibleMemoCount = 0; // 使用Set来避免重复的块ID const processedBlocks = new Set(); // 提前检查当前启用的模式 const isBlockMemoMode = document.documentElement.hasAttribute('savor-sidebar-block-memo'); // 获取所有块 const allBlocks = Array.from(main.querySelectorAll('[data-node-id]')); // 创建块顺序映射 const blockOrderMap = new Map(); allBlocks.forEach((block, index) => { blockOrderMap.set(block.dataset.nodeId, index); }); const allMemos = []; allBlocks.forEach(block => { const blockId = block.dataset.nodeId; // 避免重复处理 if (processedBlocks.has(blockId)) return; processedBlocks.add(blockId); // 添加块备注 if (block.hasAttribute('memo')) { allMemos.push({ element: block, type: 'block', content: block.getAttribute('memo') || '', text: ' ', blockId: blockId, elements: [], index: -1, blockOrder: blockOrderMap.get(blockId) }); } // 收集行内备注 if (!isBlockMemoMode) { // 行内备注只处理 NodeParagraph 和 NodeHeading 类型的节点 const isValidBlockType = isValidBlockTypeForInlineMemo(block); if (isValidBlockType) { // 收集块内的行内备注 const inlineMemos = block.querySelectorAll('span[data-type*="inline-memo"]'); const memoGroups = []; for (let i = 0; i < inlineMemos.length; i++) { const el = inlineMemos[i]; const memoContent = el.getAttribute('data-inline-memo-content') || ''; const memoText = el.textContent || ''; // 检查是否与前一个元素相邻且内容相同 let isAdjacentAndSame = false; if (memoGroups.length > 0) { const lastGroup = memoGroups[memoGroups.length - 1]; const lastElement = lastGroup.elements[lastGroup.elements.length - 1].element; // 检查是否相邻 let nextSibling = lastElement.nextSibling; while (nextSibling && nextSibling.nodeType === Node.TEXT_NODE && nextSibling.textContent.trim() === '') { nextSibling = nextSibling.nextSibling; } if (nextSibling === el && lastGroup.content === memoContent) { isAdjacentAndSame = true; } } if (isAdjacentAndSame) { // 添加到当前组 const currentGroup = memoGroups[memoGroups.length - 1]; currentGroup.elements.push({ element: el, index: i }); // 避免重复文本 if (!currentGroup.texts.includes(memoText)) { currentGroup.texts.push(memoText); } } else { // 创建新组 memoGroups.push({ elements: [{ element: el, index: i }], texts: [memoText], content: memoContent }); } } // 处理每个分组 memoGroups.forEach(group => { const memoBlock = getBlockNode(group.elements[0].element); const memoBlockId = memoBlock?.dataset.nodeId || blockId; const combinedText = group.texts.join(''); allMemos.push({ elements: group.elements, element: group.elements[0].element, type: 'inline', content: group.content, text: combinedText, blockId: blockId, blockOrder: blockOrderMap.get(memoBlockId) }); }); } } }); // 按块顺序排序 allMemos.sort((a, b) => a.blockOrder - b.blockOrder); // 如果没有备注,直接返回 if (allMemos.length === 0) { sidebar.removeAttribute('data-memo-count'); // 修复:即使没有备注也要确保清除 Sv-memo 类 const protyleContent = main.closest('.protyle')?.querySelector('.protyle-content'); if (protyleContent) { protyleContent.classList.remove('Sv-memo'); } return; } // 计算块折叠状态 const blockFoldStates = new Map(); allMemos.forEach(memoData => { if (!blockFoldStates.has(memoData.blockId)) { const block = main.querySelector(`div[data-node-id="${memoData.blockId}"]`); blockFoldStates.set(memoData.blockId, block && isAnyAncestorFolded(block)); } }); // 显示备注 allMemos.forEach(memoData => { const block = main.querySelector(`div[data-node-id="${memoData.blockId}"]`); // 如果块不存在,跳过 if (!block) return; const isBlockFolded = blockFoldStates.get(memoData.blockId) || false; const { content: memo, text } = memoData; const memoDiv = document.createElement('div'); memoDiv.className = 'memo-item'; memoDiv.setAttribute('data-node-id', memoData.blockId); memoDiv.setAttribute('data-memo-index', memoData.type === 'inline' ? (memoData.elements?.[0]?.index || -1) : -1); memoDiv.setAttribute('data-memo-type', memoData.type); // 存储元素数量 memoData.type === 'inline' && memoDiv.setAttribute('data-memo-element-count', memoData.elements?.length || 1); memoDiv.style.cssText = 'margin:0px 0px 8px 16px;padding:8px;border-radius:8px;position:relative;width:220px;box-shadow:rgba(0, 0, 0, 0.03) 0px 12px 20px, var(--b3-border-color) 0px 0px 0px 1px inset;'; // 显示/隐藏逻辑 memoDiv.style.display = isBlockFolded ? 'none' : ''; !isBlockFolded && visibleMemoCount++; const formattedDisplayText = text; const memoContentStyle = isReadonly ? 'cursor:auto;' : 'cursor:pointer;'; memoDiv.innerHTML = `