实现按住 Shift 点击链接时搜索并打开备注或别名中含有该链接地址的块。
需要在关于中获取 API token 填入,打开多个工作空间时可能无法正常使用。

/**
* Shift+点击链接检测脚本 - 思源笔记插件
* 实现按住 Shift 点击链接时,查询别名包含该链接的块
*/
class ShiftClickDetector {
constructor() {
this.isEnabled = true;
this.logger = {
info: (msg) => console.log(`[INFO] ${msg}`),
warn: (msg) => console.warn(`[WARN] ${msg}`),
error: (msg) => console.error(`[ERROR] ${msg}`)
};
// 思源 API 配置
this.apiEndpoint = 'http://127.0.0.1:6806';
this.apiToken = 'x'; // 需要从思源设置中获取授权码
// 面板引用
this.resultPanel = null;
this.init();
}
/**
* 初始化事件监听器
*/
init() {
// 注册全局点击事件监听器
document.addEventListener('click', this.handleClick.bind(this), true);
this.logger.info('Shift+点击检测器已启动');
}
/**
* 处理点击事件
* @param {MouseEvent} event - 鼠标点击事件
*/
handleClick(event) {
try {
// 检查是否按下了 Shift 键
if (!event.shiftKey) {
return;
}
// 解析链接信息
const linkMeta = this.parseHyperlinkMeta(event.target);
if (linkMeta.valid) {
this.logger.info('检测到 Shift+点击链接:', linkMeta);
// 阻止默认行为
event.preventDefault();
event.stopPropagation();
// 执行自定义操作
this.handleShiftClick(linkMeta, event);
}
} catch (error) {
this.logger.warn('处理点击事件时出错:', error);
}
}
/**
* 解析超链接元数据
* @param {HTMLElement} element - 被点击的元素
* @returns {Object} 链接元数据
*/
parseHyperlinkMeta(element) {
const meta = {
valid: false,
href: "",
title: "",
textContent: "",
element: element
};
// 检查是否为链接元素
if (element.tagName === 'A' && element.href) {
meta.valid = true;
meta.href = element.href;
meta.title = element.title || element.innerText || element.textContent;
meta.textContent = element.textContent || element.innerText || '';
}
// 检查是否为思源编辑器中的链接(span 元素)
else if (element.tagName === 'SPAN' && element.dataset.type === 'a') {
meta.valid = true;
meta.href = element.dataset.href || '';
meta.title = element.dataset.title || element.innerText || element.textContent;
meta.textContent = element.textContent || element.innerText || '';
}
return meta;
}
/**
* 处理 Shift+点击链接的逻辑
* @param {Object} linkMeta - 链接元数据
* @param {MouseEvent} event - 原始事件
*/
async handleShiftClick(linkMeta, event) {
this.logger.info(`处理 Shift+点击: ${linkMeta.href}`);
// 显示加载状态
this.showLoadingPanel();
try {
// 查询别名包含该链接的块,取第一个匹配的块
const block = await this.queryFirstBlockByAlias(linkMeta.href);
if (block && block.id) {
// 构建思源链接并跳转
const siyuanLink = `siyuan://blocks/${block.id}`;
this.logger.info(`准备跳转到: ${siyuanLink}`);
this.openSiyuanLink(siyuanLink);
// 关闭加载面板
this.closeResultPanel();
} else {
// 未找到匹配的块
this.closeResultPanel();
this.showNotification('未找到匹配的块', 'warn');
}
} catch (error) {
this.logger.error('查询失败:', error);
this.closeResultPanel();
this.showNotification(`查询失败: ${error.message}`, 'error');
}
}
/**
* 查询别名包含指定链接的第一个块
* @param {string} linkHref - 链接地址
* @returns {Promise<Object|null>} 第一个匹配的块,未找到返回 null
*/
async queryFirstBlockByAlias(linkHref) {
// 构建 SQL 查询语句,只取第一个结果
const sql = `SELECT * FROM blocks WHERE alias LIKE '%${linkHref}%' or memo LIKE '%${linkHref}%' LIMIT 1`;
this.logger.info(`执行 SQL 查询: ${sql}`);
try {
const response = await fetch(`${this.apiEndpoint}/api/query/sql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': this.getAuthorizationHeader()
},
body: JSON.stringify({
stmt: sql
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.code !== 0) {
throw new Error(result.msg || '查询失败');
}
const blocks = result.data || [];
if (blocks.length > 0) {
this.logger.info(`查询成功,找到匹配的块: ${blocks[0].id}`);
return blocks[0];
} else {
this.logger.info('未找到匹配的块');
return null;
}
} catch (error) {
this.logger.error('SQL 查询异常:', error);
throw error;
}
}
/**
* 打开思源链接
* @param {string} siyuanLink - 思源链接,格式如 siyuan://blocks/{blockId}
*/
openSiyuanLink(siyuanLink) {
try {
// 方式1: 使用 window.location
window.location.href = siyuanLink;
// 方式2: 创建隐藏的链接元素并点击(备用)
// const link = document.createElement('a');
// link.href = siyuanLink;
// link.style.display = 'none';
// document.body.appendChild(link);
// link.click();
// document.body.removeChild(link);
} catch (error) {
this.logger.error('打开思源链接失败:', error);
// 降级处理:复制链接到剪贴板
this.copyToClipboard(siyuanLink);
this.showNotification('已复制思源链接到剪贴板', 'info');
}
}
/**
* 复制文本到剪贴板(降级方案)
* @param {string} text - 要复制的文本
*/
async copyToClipboard(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
this.logger.info('已复制到剪贴板');
} else {
// 降级方案:使用旧版 Clipboard API
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
this.logger.info('已使用降级方案复制到剪贴板');
}
} catch (error) {
this.logger.error('复制到剪贴板失败:', error);
}
}
/**
* 获取 Authorization 请求头
* @returns {string} Authorization 头
*/
getAuthorizationHeader() {
// 尝试从 localStorage 或全局变量获取 token
// 如果 token 为空,返回默认值(某些环境可能不需要 token)
if (this.apiToken) {
return `Token ${this.apiToken}`;
}
// 尝试从全局变量获取
if (typeof window !== 'undefined' && window.token) {
return `Token ${window.token}`;
}
// 尝试从 localStorage 获取
try {
const token = localStorage.getItem('siyuan-token');
if (token) {
return `Token ${token}`;
}
} catch (e) {
this.logger.warn('无法访问 localStorage:', e);
}
return 'Token';
}
/**
* 显示加载状态面板
*/
showLoadingPanel() {
this.closeResultPanel();
const panel = document.createElement('div');
panel.id = 'siyuan-link-redirect-loading-panel';
panel.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
z-index: 10000;
padding: 20px 30px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
`;
panel.innerHTML = `
<p style="margin: 0; font-size: 14px;">正在跳转...</p>
`;
document.body.appendChild(panel);
}
/**
* 显示页面通知
* @param {string} message - 通知消息
* @param {string} type - 通知类型: info, warn, error
*/
showNotification(message, type = 'info') {
// 创建通知元素
const notification = document.createElement('div');
const bgColor = type === 'error' ? '#dc3545' : type === 'warn' ? '#ffc107' : '#007bff';
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${bgColor};
color: white;
padding: 12px 20px;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 10001;
font-family: Arial, sans-serif;
font-size: 14px;
max-width: 300px;
word-wrap: break-word;
`;
notification.textContent = message;
// 添加到页面
document.body.appendChild(notification);
// 3秒后自动移除
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 3000);
}
/**
* 关闭结果面板
*/
closeResultPanel() {
// 移除加载面板
const loadingPanel = document.getElementById('siyuan-link-redirect-loading-panel');
if (loadingPanel) {
loadingPanel.remove();
}
// 移除结果面板(如果有的话)
if (this.resultPanel) {
this.resultPanel.remove();
this.resultPanel = null;
}
}
/**
* 设置 API Token
* @param {string} token - API Token
*/
setApiToken(token) {
this.apiToken = token;
this.logger.info('API Token 已更新');
}
/**
* 启用/禁用检测器
* @param {boolean} enabled - 是否启用
*/
setEnabled(enabled) {
this.isEnabled = enabled;
this.logger.info(`检测器已${enabled ? '启用' : '禁用'}`);
}
/**
* 销毁检测器
*/
destroy() {
this.closeResultPanel();
document.removeEventListener('click', this.handleClick.bind(this), true);
this.logger.info('Shift+点击检测器已销毁');
}
}
// 必要的样式已经通过内联样式实现,这里不需要额外添加
// 创建检测器实例
const shiftClickDetector = new ShiftClickDetector();
// 导出供其他脚本使用
if (typeof module !== 'undefined' && module.exports) {
module.exports = ShiftClickDetector;
}
// 使用说明:
// 1. 按住 Shift 键点击链接,会自动查询别名包含该链接的第一个块
// 2. 找到匹配的块后,会自动打开 siyuan://blocks/{blockId} 链接进行跳转
// 3. 如果未找到匹配的块或出现错误,会显示通知提示
// 4. 可以通过以下方法设置 API Token(如果需要在代码外配置):
// shiftClickDetector.setApiToken('your-token-here');
// 5. 可以使用以下方法禁用/启用:
// shiftClickDetector.setEnabled(false);
// shiftClickDetector.setEnabled(true);
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于