嵌入式系列插件重大更新:Excalidraw 代码片段功能上线(脚本 / 样式)

继发布 嵌入式系列插件第四弹:Excalidraw 插件 之后,经过几个版本迭代,「嵌入式系列」Excalidraw 插件功能逐步完整,我也收到了很多朋友的特殊功能需求。其实其中不少需求都是来源于原先的 Obsidian Excalidraw 用户。而 Obsidian Excalidraw 能够这么出名的一个原因是它提供了 Excalidraw 脚本功能,并且后续大量用户基于此创建了许多好用的脚本,比如我之前看到的先绘制一个粗略的思维导图然后一键格式化为非常规整的思维导图的视频,效果着实震惊。于是,我也为咱们思源笔记的 Excalidraw 插件加入了代码片段功能,将思源笔记本身的代码片段功能利用上,现在 Excalidraw 内部也可以用自定义代码片段了。

现在,SiYuan Excalidraw 也有了自己的 Excalidraw 脚本!!!!

image.png

代码片段使用效果

这一视频中展示了 CSS 和 JS 的用法,第一个 CSS 让右下角的帮助按钮不再显示,第二个 JS 增加了一个右键菜单项,能够将框选的元素自动排为一行。

[Excalidraw代码片段] 不显示右下角帮助按钮
.excalidraw .help-icon {
  display: none;
}
[Excalidraw代码片段] 将选中元素排列为一行
arrangeSelectedElementsInRow = (spacing = 10) => {
  const selectedIds = Object.keys(window.excalidrawAPI.getAppState().selectedElementIds);

  if (!selectedIds || selectedIds.length === 0) {
    console.log("⚠️ 请先选择至少一个元素");
    return;
  }

  // 获取选中的元素
  const elements = window.excalidrawAPI.getSceneElements();
  const selectedElements = elements.filter(el => selectedIds.includes(el.id));

  if (selectedElements.length === 0) {
    console.log("⚠️ 未找到选中的元素");
    return;
  }

  // 按原始 x 坐标排序(从左到右)
  const sorted = [...selectedElements].sort((a, b) => a.x - b.x);

  // 计算新位置:从最左侧开始排列
  let currentX = sorted[0].x; // 以第一个元素的 x 为起点
  const topY = Math.min(...sorted.map(el => el.y)); // 顶部对齐(取最小 y)

  const updates = sorted.map(el => {
    const newX = currentX;
    const newY = topY;
    currentX += el.width + spacing; // 紧密排列 + 间距

    return {
      id: el.id,
      x: newX,
      y: newY
    };
  });

  const updatedElements = elements.map(element => {
    if (selectedIds.includes(element.id)) {
      const updateData = updates.find(el => el.id === element.id);
      element.x = updateData.x;
      element.y = updateData.y;
    }
    return element;
  })

  // 批量更新元素位置
  window.excalidrawAPI.updateScene({
    elements: updatedElements
  });

  console.log(`✅ 已将 ${selectedElements.length} 个元素水平排列`);
};

const mutationObserver = new MutationObserver(mutations => {
  for (const mutation of mutations) {
    if (mutation.type === 'childList') {
      mutation.addedNodes.forEach(node => {
        if (node.nodeType === Node.ELEMENT_NODE) {
          const menuElement = node.querySelector(".popover .context-menu");
          if (menuElement) {
            const itemElement = document.createElement("li");
            itemElement.setAttribute("data-testid", "arrangeSelectedElementsInRow");
            itemElement.innerHTML = `
<button type="button" class="context-menu-item">
  <div class="context-menu-item__label">将选中的元素排为一行</div>
  <kbd class="context-menu-item__shortcut"></kbd>
</button>`;
            itemElement.addEventListener("click", () => {
              arrangeSelectedElementsInRow(10);
            })
            menuElement.querySelector(`[data-testid="wrapSelectionInFrame"]`)?.insertAdjacentElement('afterend', itemElement);
          }
        }
      });
    }
  }
});

mutationObserver.observe(document.body, {
  childList: true,
  subtree: true
});

代码片段功能的引入为 Excalidraw 带来了更多玩法的可能,理论上 Obsidian Excalidraw 里看到的各种用 Excalidraw 脚本实现的华丽的功能都可以迁移到思源中来了。

为了方便用户获取和分享好用的代码片段,本贴也将作为 Excalidraw 代码片段集市,汇集大家开发的实用功能,欢迎各位大佬实现功能后在思源中发帖分享,或在本贴评论区中回复,我将把大家的功能以链接的形式收录在本贴中。

开发指引

参考本帖子提供的 CSS 和 JS 代码片段,目前提供的唯一的 API 是 window.excalidrawAPI,有了它就可以操作整个 Excalidraw,对应的接口见 Excalidraw 官方文档

Excalidraw 代码片段集市


如有更多需求/建议欢迎在 GitHub 仓库中提 issue 或在本贴中回贴

如果你觉得有用,欢迎请我喝杯咖啡☕
  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    28446 引用 • 119768 回帖
  • Excalidraw
    6 引用 • 56 回帖
  • 插件
    116 引用 • 753 回帖 • 3 关注
6 操作
yuxinzhao 在 2025-12-01 00:56:39 更新了该帖
yuxinzhao 在 2025-11-27 10:24:40 更新了该帖
yuxinzhao 在 2025-11-27 09:37:22 更新了该帖
yuxinzhao 在 2025-11-27 09:26:06 更新了该帖 yuxinzhao 在 2025-11-25 01:27:51 更新了该帖 yuxinzhao 在 2025-11-25 01:26:27 更新了该帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • yuxinzhao

    选中元素 Tab 切换形状:

    // 图形切换顺序
    const SHAPE_CYCLE = ['rectangle', 'ellipse', 'diamond'];
    const SHAPE_NAMES = {
      rectangle: '矩形',
      ellipse: '椭圆',
      diamond: '菱形'
    };
    
    // 切换选中元素的图形类型
    const cycleSelectedElementShape = () => {
      const appState = window.excalidrawAPI.getAppState();
      const selectedIds = Object.keys(appState.selectedElementIds || {});
    
      // 必须只选中一个元素
      if (selectedIds.length !== 1) {
        return false;
      }
    
      const allElements = window.excalidrawAPI.getSceneElements();
      const targetId = selectedIds[0];
      const targetElement = allElements.find(el => el.id === targetId);
    
      if (!targetElement) return false;
    
      const currentType = targetElement.type;
      if (!SHAPE_CYCLE.includes(currentType)) {
        return false; // 不是可切换的图形
      }
    
      // 找到下一个类型(循环)
      const currentIndex = SHAPE_CYCLE.indexOf(currentType);
      const nextType = SHAPE_CYCLE[(currentIndex + 1) % SHAPE_CYCLE.length];
    
      // 创建新元素(保持位置、尺寸、样式)
      const newElement = {
        ...targetElement,
        type: nextType,
        version: (targetElement.version || 0) + 1, // 触发更新
        versionNonce: Math.floor(Math.random() * 1000) // 避免缓存
      };
    
      // 更新场景
      const updatedElements = allElements.map(el =>
        el.id === targetId ? newElement : el
      );
    
      window.excalidrawAPI.updateScene({ elements: updatedElements });
      console.log(`🔄 图形已切换为:${SHAPE_NAMES[nextType]}`);
      return true;
    };
    
    // 全局 Tab 键监听(仅在 Excalidraw 画布有焦点时响应)
    const handleKeyDown = (e) => {
      // 只处理 Tab 键,且未组合其他修饰键
      if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
        e.preventDefault(); // 阻止默认 Tab 行为(如焦点跳转)
        const handled = cycleSelectedElementShape();
        if (handled) {
          // 可选:阻止冒泡
          e.stopImmediatePropagation();
        }
      }
    };
    
    // 将监听器绑定到 Excalidraw 容器(更精准,避免全局污染)
    const attachTabListener = () => {
      const excalidrawContainer = document.querySelector('.excalidraw');
      if (excalidrawContainer && !excalidrawContainer.hasTabListener) {
        excalidrawContainer.addEventListener('keydown', handleKeyDown, true); // useCapture=true 更早拦截
        excalidrawContainer.hasTabListener = true;
        console.log('✅ Tab 图形切换监听器已启用');
      }
    };
    
    // 同时尝试立即绑定(如果已存在)
    attachTabListener();
    
    1 回复
  • 其他回帖
  • yuxinzhao

    选中元素网格布局:

    const arrangeSelectedElementsInGrid = (spacing = 10) => {
      const appState = window.excalidrawAPI.getAppState();
      const selectedIds = Object.keys(appState.selectedElementIds || {});
    
      if (!selectedIds || selectedIds.length === 0) {
        console.log("⚠️ 请先选择至少一个元素");
        return;
      }
    
      const allElements = window.excalidrawAPI.getSceneElements();
      const selectedElements = allElements.filter(el => selectedIds.includes(el.id));
    
      if (selectedElements.length === 0) {
        console.log("⚠️ 未找到选中的元素");
        return;
      }
    
      // === 1. 按 y 聚类成行 ===
      const sortedByY = [...selectedElements].sort((a, b) => a.y - b.y);
      const avgHeight = selectedElements.reduce((sum, el) => sum + el.height, 0) / selectedElements.length;
      const rowThreshold = Math.max(avgHeight * 0.6, 20); // 行容差
    
      const rows = [];
      let currentRow = [sortedByY[0]];
    
      for (let i = 1; i < sortedByY.length; i++) {
        const prev = currentRow[currentRow.length - 1];
        const curr = sortedByY[i];
        if (Math.abs(curr.y - prev.y) <= rowThreshold) {
          currentRow.push(curr);
        } else {
          rows.push(currentRow);
          currentRow = [curr];
        }
      }
      rows.push(currentRow);
    
      // 每行内部按 x 排序(从左到右)
      rows.forEach(row => row.sort((a, b) => a.x - b.x));
    
      // === 2. 计算每列最大宽度、每行最大高度 ===
      const maxColumns = Math.max(...rows.map(row => row.length));
      const columnWidths = new Array(maxColumns).fill(0);
      const rowHeights = rows.map(row =>
        Math.max(...row.map(el => el.height))
      );
    
      // 填充 columnWidths
      for (let colIndex = 0; colIndex < maxColumns; colIndex++) {
        for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
          const row = rows[rowIndex];
          if (colIndex < row.length) {
            columnWidths[colIndex] = Math.max(columnWidths[colIndex], row[colIndex].width);
          }
        }
      }
    
      // === 3. 重新定位:每个元素左上角 = 网格单元格左上角 ===
      const updates = new Map();
    
      // 起点:以整个选区最左上角元素的原始位置为基准(保持整体位置不变)
      const globalMinX = Math.min(...selectedElements.map(el => el.x));
      const globalMinY = Math.min(...selectedElements.map(el => el.y));
    
      let currentY = globalMinY;
    
      for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
        let currentX = globalMinX; // 每行都从全局最左开始
    
        for (let colIndex = 0; colIndex < rows[rowIndex].length; colIndex++) {
          const el = rows[rowIndex][colIndex];
          // ✅ 关键:元素左上角直接设为 (currentX, currentY)
          updates.set(el.id, { x: currentX, y: currentY });
    
          // 移动到下一列:加上该列统一宽度 + 间距
          currentX += columnWidths[colIndex] + spacing;
        }
    
        // 移动到下一行:加上该行统一高度 + 间距
        currentY += rowHeights[rowIndex] + spacing;
      }
    
      // === 4. 应用更新 ===
      const updatedElements = allElements.map(el => {
        if (updates.has(el.id)) {
          const { x, y } = updates.get(el.id);
          return { ...el, x, y };
        }
        return el;
      });
    
      window.excalidrawAPI.updateScene({ elements: updatedElements });
      console.log(`✅ 已将 ${selectedElements.length} 个元素按网格左上对齐`);
    };
    
    const mutationObserver = new MutationObserver(mutations => {
      for (const mutation of mutations) {
        if (mutation.type === 'childList') {
          mutation.addedNodes.forEach(node => {
            if (node.nodeType === Node.ELEMENT_NODE) {
              const menuElement = node.querySelector(".popover .context-menu");
              if (menuElement) {
                const itemElement = document.createElement("li");
                itemElement.setAttribute("data-testid", "arrangeSelectedElementsInRow");
                itemElement.innerHTML = `
    <button type="button" class="context-menu-item">
      <div class="context-menu-item__label">将选中的元素网格布局</div>
      <kbd class="context-menu-item__shortcut"></kbd>
    </button>`;
                itemElement.addEventListener("click", () => {
                  arrangeSelectedElementsInGrid(10);
                })
                menuElement.querySelector(`[data-testid="wrapSelectionInFrame"]`)?.insertAdjacentElement('afterend', itemElement);
              }
            }
          });
        }
      }
    });
    
    mutationObserver.observe(document.body, {
      childList: true,
      subtree: true
    });
    ```
    
    1 操作
    yuxinzhao 在 2025-11-27 09:35:59 更新了该回帖
  • wilsons 1 评论

    大佬牛逼,兼容 ob excalidraw 的代码吗?我之前 ob 的代码可以直接拿来用吗

    不能,ob 的代码提供的接口是它自定义的,那个时候 Excalidraw 官方好像还没出 excalidrawAPI 的接口,现在有官方的就还是用官方的,而且一些 ob 代码还用到了 ob quickadd 插件之类的功能,也是没法兼容的。我们开发思源自己的!
    yuxinzhao
  • YOUQY 4 评论

    感谢作者大大!插件和代码块都很好用!

    由“选中元素排为一行”这个功能想到的,不知道能不能用代码块实现下面这样的效果?

    PixPin20251127003609.gif

    具体来说是这样的,比如我有四张图片,我想让它们排列成 2*2 的样子,但是手动拖动无法精确控制间距和对齐。能否让选中的图片先在一个方向上对齐,然后再在另一个方向上进一步对齐?

    动图的效果是我在 PureRef 这个软件里实现的,先 Ctrl+↑ 顶部对齐,再 Ctrl+← 左对齐。在 Excalidraw 里面做不到这样的效果,因为先顶部对齐以后,如果再左对齐,图片就全重叠在一起了。

    因为这个原因,我在记录有很多图片的笔记的时候,还是选择用 PureRef 来替代 Excalidraw,不知道能不能通过代码块来优化这方面的体验?

    1 回复
    ps. 示例里图片贴在一起 是因为我在 PureRef 里把图片间距设为 0 了
    YOUQY
    @YOUQY 完全没问题呀,这和我这个排成一行的脚本很相似,你把我这个脚本丢给 ai 让它参考这个改成你的需求就行,ai 做得到的,其实这个排成一行的脚本大部分也是让 ai 生成的,所以在有参考的基础上实现你的需求完全没问题
    yuxinzhao
    @YOUQY 给你弄好了,我是把样例丢给 AI 然后说需求后让它自己改的,我一点代码都没动,门槛很低,也欢迎你尝试一下创造更多实用的代码片段。
    yuxinzhao
    @yuxinzhao 了解了,谢谢~
    YOUQY
  • 查看全部回帖