一键把excalidraw的头脑风暴输出为线性文章

发散性思考与线性输出

我以前一直都是采用传统的笔记方法,从上往下写。

这种线性似乎是理所应当、浑然天成的,但是人在想问题的时候又喜欢在草稿上写写画画,这个时候思维的组织方式却是非线性的。

接近的概念就是头脑风暴之类的思考组织方式,无意掉书袋,因此本文不再就此概念做过多讨论,我们姑且定义两种方式,不再深究其描述是否准确:

  1. 从上往下的一维:线性
  2. 上下左右都有:发散性

这里就会引入一个问题,当你的大脑在发散性思考的时候,用线性的笔记辅助思考,就会出现脑子和手打架的窘境。

举例来说:我在写某个主题的时候,会突然联想到一个分支想法,它不适合放进当前的正文,但是与之又有关联。如果是传统的笔记方法,这里可以使用便签(callout),但这样终究不太适合内容组织。因此类似mindmap的工具都会提供二维的内容输出方式。

但是这会引入一个新问题——发散思考之后,如何输出高可读性的内容。

不知道大家有没有这样的体验:自己做的思维导图再烂也能看懂,而别人做的再好也看得很晕。

这就是非线性(发散性)内容的弊端,除非是自己生产(即已完成内化)的内容,否则非线性内容先天就更难理解。

因此我们需要找到一个允许我们发散性思考,但是又能快捷的输出线性内容的的方法。

线性输出脚本的前身

当我产生了这个需求的时候,我先是尝试用obsidian的引用功能来实现它,因为excalidraw本身是支持对外提供内容的嵌入的。但是很难做到方便快捷。

于是我在网上查询资料,了解到两位先驱者的探索:

学习了两位网友关于excalidraw笔记如何实现线性输出的思考,其中

  • Note必利阀制作了一个脚本,可以把excalidraw中选中的文本和图像按编辑的时间顺序输出为文字与图像的引用,最终复制到剪贴板,我们只需要把这个粘贴到想要用的地方就行
  • 熊猫别熬夜制作了一套脚本,要求使用者在excalidraw编写好符合一定格式的标题,然后通过脚本把标题与对应的内容引用出来

前者的优势是输出的内容为文字与图片链接,是可以被标准md识别的,但缺少了excalidraw的强大图形能力(因为它要求把freedraw转成图片,后续再编辑也是很麻烦的)。另外因为excalidraw的Frame和Group还不支持嵌套,所以如果有画中画这样的展现形式,则无法用后者的脚本实现。

后者直接把excalidraw中的图形引用过来,能更好的保留excalidraw的功能,不过这样也导致如果有发布文章的需求,后续可能需要再手动去把excalidraw引用转换为图片。

在学习两位的过程中,我厘清了 发散性思考线性输出 的概念,同时基于我自己的日记工作流,对熊猫别熬夜的脚本进行了修改,最终我的线性输出脚本诞生了。

线性输出脚本

脚本介绍

本脚本的全名应该叫 excalidraw线性输出到同名笔记,它的功能也很简单,一言以蔽之:通过识别规定格式的文本,把与文本组合的内容以excalidraw的嵌入链接形式输出到对应笔记的指定标题下。

如下图所示:
assets/Pasted image 20240907152957.png

本脚本通过识别形如 #1 标题 的文本,解析为 标题,并将该文本所属的组合(优先级分别为:Frame>Group>Element)引用链接插入到标题之后。

脚本的优点:
1. 保留了excalidraw的图形能力
2. 将内容输出到指定文件并生成逐级标题,让思考输出的内容可以与文件本身融合,大纲可识别
3. 支持自定义在哪个标题后插入,并且会根据设置标题动态调整生成标题的层级(比如设置在 #灵感 后插入,则从二级标题开始生成,如果设置为 ##灵感,则会从三级标题开始生成,确保生成内容为子内容)

脚本使用说明

下载

你可以在我的Github下载:dangehub/aqu_ob_share: Share my Obsidian techniques

或者在文末直接复制源代码,自己新建一个md文件粘贴进去就好。

安装使用

  1. 把脚本放到excalidraw的script目录下
  2. 前往excalidraw插件设置,在最后一项 已安装脚本设置 中修改 Custom Misc Header,设置为自己想要插入在哪个标题后,参考值 ## 1.3 杂记
  3. 点击脚本按钮or使用命令工具
  4. 1.excalidraw 的线性内容会被输出到 1 中的 ## 1.3 杂记 标题下

视频教程见:obsidian+excalidraw+线性输出脚本=快乐日记

附脚本源代码

“`

// 获取脚本设置

let settings = ea.getScriptSettings();

// 设置默认值(如果是首次运行)

if (!settings[“Custom Misc Header”]) {

settings = {

…settings,  // 保留现有设置

“Custom Misc Header”: {

value: “## 1.3 杂记”,

description: “自定义杂记标题,用于插入 Excalidraw 内容”

}

};

ea.setScriptSettings(settings);

}

// 使用设置中的自定义杂记标题

const customMiscHeader = settings[“Custom Misc Header”].value;

// 计算customMiscHeader中的 #数量

const customHeaderLevel = (customMiscHeader.match(/^#+/) || [”])[0].length;

// 获取笔记的基本路径和笔记名

const currentFile = app.workspace.getActiveFile();

if (!currentFile) {

new Notice(“❌ 无法获取当前文件”, 3000);

return;

}

// 获取excalidraw文件路径、文件名,准备生成对应笔记
const filePath = currentFile.path;

const fileName = currentFile.name;

const fileBaseName = fileName.replace(‘.excalidraw’, ”);

// 初始化变量

let frameIds = [];

let extrTexts = ”;

// 获取所有以’#’开头的文本元素(即标题)

let allEls = ea.getViewElements().filter(el => el.type === “text” && el.text.startsWith(‘#’));

// 对标题进行排序

allEls.sort((a, b) => {

let aMatch = a.text.match(/^#([\d.]+)/);

let bMatch = b.text.match(/^#([\d.]+)/);

if (!aMatch || !bMatch) return 0;

let aParts = aMatch[1].split(‘.’).map(Number);

let bParts = bMatch[1].split(‘.’).map(Number);

for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {

if (aParts[i] === undefined) return -1;

if (bParts[i] === undefined) return 1;

if (aParts[i] !== bParts[i]) return aParts[i] – bParts[i];

}

return 0;

});

for (let i of allEls) {

let elText = i.rawText.trim(); // 使用 rawText 而不是 text,以规避换行符问题

let elID = i.id;

let match = elText.match(/^#([\d.]+)\s+(.*)/);

if (!match) continue;

let numberPart = match[1];

let titlePart = match[2];

// 计算标题级别

let levels = numberPart.split(‘.’).length;
    let headLevel = Math.min(levels + customHeaderLevel, 6); // 根据customMiscHeader的级别调整

let heads = ‘#’.repeat(headLevel);

let titleText = “”;

let titleLink = “”;

let embedlinks = [];

let nums = 99;

// 处理excalidraw中的Frame、Group

if (i.frameId && !frameIds.includes(i.frameId)) {

elID = i.frameId;

frameIds.push(elID);

titleLink = ${fileName}#^frame=${elID};

for (let j of ea.getViewElements().filter(el => el.type === “embeddable”)) {

if (j.frameId == elID) {

embedlinks.push(\n!${j.link})

let objectFrame = ea.getViewElements().filter(el => el.frameId === elID);

nums = objectFrame.length;

}

}

} else if (i.groupIds) {

titleLink = ${fileName}#^group=${elID};

for (let j of ea.getViewElements().filter(el => el.type === “embeddable”)) {

if (j.groupIds.some(groupId => i.groupIds.includes(groupId))) {

embedlinks.push(\n!${j.link})

let objectFrame = ea.getViewElements().filter(el => el.groupIds.some(groupId => i.groupIds.includes(groupId)));

nums = objectFrame.length;

}

}

} else {

titleLink = ${fileName}#^${elID};

}

// 生成标题文本

if (embedlinks.length > 0) {

let extrEmbedlinks = embedlinks.join(‘\r\n’);

titleText = ${heads} ${titlePart}\n${extrEmbedlinks}\n;

if (nums > 3) {

titleText += ![[${titleLink}]]\n;

}

} else {

titleText = ${heads} ${titlePart}\n;

if (nums > 2) {

titleText += ![[${titleLink}]]\n;

}

}

extrTexts += titleText;

}

// 构建输出文件路径

let outputFileName = ${fileBaseName}.md;

let outputPath = filePath.replace(‘.excalidraw’, ”);

// 检查输出文件是否存在

let outputFile = app.vault.getAbstractFileByPath(outputPath);

if (!outputFile) {

new Notice(❌ 输出文件不存在:${outputPath}, 3000);

// 尝试创建文件

try {

await app.vault.create(outputPath, ”);

outputFile = app.vault.getAbstractFileByPath(outputPath);

new Notice(✅ 已创建新文件:${outputPath}, 2000);

} catch (error) {

new Notice(❌ 无法创建文件:${outputPath}, 3000);

return;

}

}

// 读取输出文件内容

let outputContent = await app.vault.read(outputFile);

// 创建唯一标识符

let excalidrawIdentifier = EXCALIDRAW_CONTENT_${fileName.replace(/[^a-zA-Z0-9]/g, "_")};

// 构建新的 Excalidraw 内容

let newExcalidrawContent = <!-- BEGIN ${excalidrawIdentifier} -->\n${extrTexts}\n<!-- END ${excalidrawIdentifier} -->;

// 检查是否已存在 Excalidraw 内容

let startMarker = <!-- BEGIN ${excalidrawIdentifier} -->;

let endMarker = <!-- END ${excalidrawIdentifier} -->;

let startIndex = outputContent.indexOf(startMarker);

let endIndex = outputContent.indexOf(endMarker);

// 辅助函数:获取标题级别

function getHeaderLevel(header) {

return header.match(/^#+/)[0].length;

}

// 辅助函数:查找下一个相同或更高级别的标题

function findNextHeader(content, startIndex, currentLevel) {

const headerRegex = /^#{1,6}\s/gm;

headerRegex.lastIndex = startIndex;

let match;

while ((match = headerRegex.exec(content)) !== null) {

if (getHeaderLevel(match[0]) <= currentLevel) {

return match.index;

}

}

return content.length;

}

if (startIndex !== -1 && endIndex !== -1) {

// 如果存在,更新现有内容

outputContent = outputContent.substring(0, startIndex) +

newExcalidrawContent +

outputContent.substring(endIndex + endMarker.length);

} else {

// 如果不存在,在自定义杂记标题后插入新内容

let miscIndex = outputContent.indexOf(customMiscHeader);

if (miscIndex !== -1) {

let currentHeaderLevel = getHeaderLevel(customMiscHeader);

// 找到下一个相同或更高级别的标题或文件末尾

let nextHeaderIndex = findNextHeader(outputContent, miscIndex + customMiscHeader.length, currentHeaderLevel);

// 在自定义杂记标题和下一个标题之间插入新内容

outputContent = outputContent.substring(0, nextHeaderIndex) +

“\n\n” + newExcalidrawContent + “\n\n” +

outputContent.substring(nextHeaderIndex);

} else {

// 如果没有找到自定义杂记标题,则在文件末尾添加

outputContent += \n\n${customMiscHeader}\n\n + newExcalidrawContent;

}

}

// 更新输出文件

await app.vault.modify(outputFile, outputContent);

new Notice(✅ Excalidraw 内容已更新到文件:${outputPath}, 2000);
“`

本文永久更新地址:

https://blogs.qudange.top/p/%e4%b8%80%e9%94%ae%e6%8a%8aexcalidraw%e7%9a%84%e5%a4%b4%e8%84%91%e9%a3%8e%e6%9a%b4%e8%be%93%e5%87%ba%e4%b8%ba%e7%ba%bf%e6%80%a7%e6%96%87%e7%ab%a0/

#excalidraw #[2] #脚本