Skip to content
cuibolanPublic
forked from SGorgeous/Grapheon

About

A mind map tools

Resources

Stars

0 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

GRAPHEON

极简 · Undertale UI 风格的节点与连线编辑器。

双击 index.html 用浏览器打开即用 —— 零构建、零依赖、不联网。


风格

纯黑底 + 白色方框 + 红色像素心光标,选中项变黄并冒出一颗红心, 底部是带打字机效果的 * 对白框。只有黑、白、黄、红四种颜色。

字体:GNU Unifont

全站(含 canvas 与 DOM)都用系统里装的 Unifont,字体栈 Unifont, "GNU Unifont", "Courier New", Consolas, "Microsoft YaHei", monospace—— 没装也能跑,只是失去点阵质感。

Unifont 是按 16px 点阵网格设计的(实测 16px 时 ASCII 恰好 8px/字、CJK 16px/字), 所以字号必须取 16 的整数倍:正文 16,根节点 / 面板标题 / 导出落款 32。 它只有 Regular 一个字重,所以全局不使用 bold(合成粗体会糊),层级改用「字号 + 颜色」表达。


核心理念:没有模式,只有节点和连线

早期版本分「思维导图 / 流程图」两种模式,靠模式决定走线和自动排版。 现在模式取消了,改成:

  • 每条连线自己带一套样式(箭头 / 线型 / 走线),互不影响;
  • 节点位置完全手动;想排整齐就选中几个,用「对齐与分布」(见下)。

连线类型

选中一条连线(点它),然后在样式面板里改。面板从三个入口打开: 按 E / 右键 →「连线样式…」,或者右键菜单里的「箭头 / 线型 / 走线」子菜单直接选。

维度 取值
箭头 无箭头 · 单向箭头 · 双向箭头
线型 实线 · 虚线
走线 正交折线 · 曲线
标签 连线上的文字(如「是 / 否」)

三个维度互相独立,所以「虚线的双向箭头 + 曲线」这种组合直接就支持。 新连线默认是 单向箭头 / 实线 / 正交折线。

拖端点改接

选中连线后,两端会出现方形手柄。拖住任意一端就能把它从当前节点上摘下来, 拖到别的节点上松手即改接;松开在空白处 = 取消。

拖动过程中会按这条线自己的走线方式实时预览(松手后长什么样,拖的时候就长什么样), 目标节点描边、吸附锚点用小方块标出来。接到自己另一端(自环)、 或者接到已经存在同样连线的节点对,都会被拒绝并给出提示。改接可以 Ctrl+Z 撤销。

拖端点改接

选中连线后,两端会出现方形手柄。拖住任意一端就能把它从当前节点上摘下来, 拖到别的节点上松手即改接;松开在空白处 = 取消。拖动过程中按这条线自己的走线方式实时预览, 目标节点描边、吸附锚点用小方块标出来。接到自己另一端(自环)、 或接到已存在同样连线的节点对,都会被拒绝。改接可以 Ctrl+Z 撤销。

端点吸附:默认自动,可以钉死

默认每一端都是 自动:路由按两个节点的相对位置挑最合适的一对锚点,节点怎么移动它都自己适配。

想固定某一条边从哪个方向进出,就在节点上右键 →「连线端点吸附…」: 面板会列出这个节点上的每条连线(先列出边、再列入边),每条都能选 自动 / 上 / 右 / 下 / 左。选了方向那一端就钉死在指定边上,不再随相对位置变化。

自由调整节点尺寸

选中节点后右下角有缩放手柄,拖它就改尺寸。文字会按新宽度重新折行;尺寸有下限, 不会缩没。右键节点 →「恢复自适应尺寸」可以改回随文字自适应。 排版时会按你设定的尺寸来算,不会覆盖它。

自由调整连线走向(拐点)

选中一条连线后,直接拖线身就能拉出一个拐点,把线拽到任意位置。 拐点方块可以继续拖;双击拐点或把它拉回直线上(自动判定)就删掉; 右键连线的「拐点」子菜单里还有「在此添加拐点」和「清除全部拐点」。

拐点决定经过哪里,而 走线 决定拐角长什么样: 正交 = 硬折,曲线 = 过这些点的平滑样条。

折叠

Space 或右键「折叠子树」会把整棵子孙真的藏起来(不绘制、点不到、框选和全选也跳过), 折向它们的连线一起收起。父节点边上会出现一个写着数量的角标,点它就展开。 折叠期间拖动父节点,展开时整棵子树会按同样的位移跟着走,相对形状不变。 导出时「全部节点」也只看得到可见的部分(所见即所得);分组框也会一起导出(范围里有它的成员就画)。

分组也能折叠:选中分组按 Space(或右键「折叠分组」)。组内所有后代(节点和分组)藏起来, 分组框自己留着,标题右边的角标写着藏了多少个节点,点角标展开; 鼠标悬在分组标题上时角标会先露出来,提示这里能点。

还有一条幽灵框抑制:一个没被折叠的分组,如果它的内容被别处的折叠全藏起来了, 那它自己也藏起来 —— 否则折叠一个节点会在画布上留下一个空框。 空分组不受这条影响(那是你手动画的框,得留着)。套娃时会一层层往外收。

节点外观:每个节点都能不一样

选中节点后按 E(或右键 →「节点样式…」)打开样式面板:

项目 可选值
字号 自动(正文 16 / 根节点 32)· 16 · 24 · 32 · 48
字体 默认 Unifont · 等宽 · 黑体 · 衬线
字色 默认 + 9 个预设色
外框色 默认 + 9 个预设色
尺寸 拖节点右下角手柄自由改;面板里可一键恢复自适应

所有项目都能选「默认 / 自动」,意思是跟随主题 —— 没手动改过的节点,换主题时会一起变; 改过的保持你自己的选择。改字号 / 字体会重新折行、重算尺寸;排版时也按你设的尺寸算。

Unifont 是 16px 点阵字体,16 / 32 / 48 是点阵精确的;24 能用但边缘会略微柔化。

嵌入文档:插入 Grapheon

把一整份 Grapheon 文档当成一个节点塞进来:右键空白处 →「新建 ▶ 嵌入 Grapheon…」, 选一个 .json。节点里会画一张内部缩略图(节点 + 连线 + 名字),顶上一条名称带, 左边标着「封闭」。

它是封闭的 —— 这是这个功能的核心约束,三处都拦着:

位置 行为
linkNodes() 直接拒绝,并提示「嵌入节点是封闭的,连不了线」
linkTargetAt() 拖动连线时它不当落点
hitPort() / 绘制 选中它也不给四个连接端点,连端口都不画(不画一个点不动的假端口)

历史存档里如果混进了连向嵌入节点的边,反序列化时直接丢掉。

进去编辑改的是副本

双击嵌入节点进去编辑(右键 →「进入编辑」也行)。进去之后:

  • 顶栏出现「返回」按钮,右下角提示栏写着「正在编辑嵌入副本 · Esc 或顶栏『返回』回到外层」
  • 你可以随便改:加节点、连线、分组、排版,跟平时一模一样
  • 按 Esc 或点「返回」出来,里面的改动会写回父文档里那个节点的副本
  • 原文件一个字节都不会动 —— 导入的时候就是复制进来的,从来没有回写外部文件的路径

进出用的是文档栈,所以嵌入里再嵌一套也没问题。进出会恢复视图、选中状态和撤销栈; 退出时会额外入一次历史(因为父文档确实被改了)。

在嵌入文档里不写自动存档 —— 否则会把外层的存档冲成内层内容,万一崩了外层就找不回来了。 内层的改动由 exitEmbed() 写回父文档,那时才存档。

四条入口都能加图,最后都汇到同一个函数,行为一致:

入口 说明
直接把图片文件拖进窗口 落在鼠标松开的位置
右键空白处 →「新建 ▶ 图片…」 落在右键的位置
顶栏「图片」按钮 落在视口正中
Ctrl+V 粘贴 从剪贴板里的图,落在视口正中

一张图片节点的样子:

┌────────────────────────────┐
│                      名称   │  ← 右上角,双击这里改名
├────────────────────────────┤
│           图 片             │
├────────────────────────────┤
│ 描述文字,自动折行           │  ← 双击这块改描述
└────────────────────────────┘
  • 双击右上角改名,双击图片下方写描述(描述允许换行)。两块位置不同,编辑的是两个独立字段。
  • 拖右下角改宽度,图片按原始比例等比缩放,高度自己跟着走。
  • 右键图片节点还有「换一张图片…」「编辑描述…」「编辑名称…」。
  • 图片节点就是普通节点:能连线、能进分组、能被折叠、能被程序节点作用。

图片是内嵌的,不是外链

导入时先等比缩到最长边 900px 以内,再转成 data URL 存进节点(node.image)。 PNG 结果超过约 700KB 就改用 JPEG 0.82 压一道。所以存档是自包含的 —— 换台机器打开也在。

代价是体积:一张图内嵌后常见几百 KB,而 localStorage 只有 5MB 左右。 存很多大图时建议用「保存」导出成 JSON 文件,别只靠自动存档。

反序列化会校验 image 必须是 data:image/ 开头,外链和 javascript: 之类一律丢掉。 导出 PNG 前会先等所有图片解码完,免得导出的是「图片加载中…」占位。

分组

选中两个以上节点 → Ctrl+G(或右键)成组。

分组框尺寸自己存着,你拉的尺寸是下限:拉多大都行,但拉不到比成员还小; 成员顶出去时框会自动长大包住它,成员回来了框也不会缩回去。

  • 连接关系完全不变:分组只是个容器,成员之间、成员对外的连线一条都不动。
  • 新建时会给一个刚好装下成员的框;之后拖右下角手柄随便改大小。 拉太小会被夹回成员的外接框,所以「框装不下成员」这个状态不会出现。
  • 只长不缩:往左上扩的时候框的原点会一起挪,右下角不会漂。
  • 排版(Ctrl+L)之后框会自动跟上:排版是显式的全局重排,成员都被摆到新位置了, 所以每个非空框会重新贴合到自己的成员(这一步允许收缩,否则会留下一个盖住半个画布的大框)。 空框不动 —— 那是你手动拉的尺寸,没有成员可参照。 「只长不缩」是拖拽时的规矩(局部操作);排版是整体重排,规矩不同。
  • 拖进 / 拖出自动收纳:把节点拖进框里(中心落在框内)就自动成为成员, 拖出去自动移出。框的大小不变,成员关系只跟着「拖动节点」这个动作走 —— 排版、改框大小都不会误伤成员关系。
  • 拖标题栏 / 边框整体搬动这个框和组内所有节点,相对位置不变。
  • 也可以右键空白处 →「在此新建空分组框」,先画框再往里拖东西;空框不会自动消失。
  • 双击标题栏(或 F2)改分组名;右键的「颜色」子菜单直接选色,还能把框收缩到刚好包住成员。
  • 外框有上下左右四个端点,和节点完全一样:可以从分组拉线到节点, 也可以把别的线连到分组上(此时分组整体充当一个端点,走的就是分组自己的四条边)。
  • 端点吸附面板和节点共用同一个(ui/endbox.js),右键菜单里也是同一段代码 (pushCommonItems()),所以「重命名 / 连线端点吸附…」两边行为一致。
  • 分组可以套娃:把分组拖进另一个分组就成了嵌套(也可以右键 →「把选中的(节点 / 分组)加入」)。 父框会自动长大包住子框;拖父框时,子框和里面所有节点一起走。 环会被自动断掉(不能把分组放进自己或自己的后代里),断哪条边是确定的。
  • 绘制和命中都按嵌套层深排序:祖先先画、子分组叠在上面;点击时最内层先接住。
  • 分组框可以多选,也能和节点混选:Shift 点标题栏追加 / 取消,Shift 拖拽框选会把 整个框都被框住的分组一起选上。拖动时选中的分组和节点一起走;Del 一次解散全部选中的分组; Ctrl+G 把选中的节点和分组一起组成新分组(已经是子分组成员的节点不会重复收进来)。
  • 多选时只高亮,不给端点和缩放柄(那些是单目标操作,糊成一片没法用); 「整个选择就一个分组」时才出端点和缩放柄。
  • 点框内部只会选中里面的节点,只有标题栏和边框那一条能抓分组,不会挡住成员。
  • 选中分组按 Del(或右键「解散分组」)= 解散容器,成员节点和连线全部保留。

变量系统

四件东西拼起来:变量定义节点、文本里的 {name} 引用、运算节点、函数分组。

变量定义节点

┌──────────────────────────────┐
│ 描述文字(左上角)             │
│ ┌──────────┐ ┌─────────────┐ │
│ │ 变量名    │ │ 变量值       │ │  ← 两个输入框,双击分别编辑
│ └──────────┘ └─────────────┘ │
│ 全局 · 数字                   │  ← 作用域 / 值类型
└──────────────────────────────┘
作用域 谁能用 要不要连线
全局 同作用域内任何地方(含孤立节点) 不要
局内 它的下游,或它指到的那个分组内部 要

值类型分数字和字符串:数字参与 + - * /,字符串只有 +(拼接)。

文本里引用:{name}

普通节点(以及变量节点的描述)的文本里写 {name} 就会被替换成变量的值。

  • 想打出字面的 {name},前面加个反斜杠:\{name} → 显示 {name}
  • 变量自己的「值」也能引用别的变量(见下)
  • 变量不存在 → 显示 [未定义]
  • 替换是派生的:n.text 永远是你写的原文,只是显示和量宽用替换后的结果。 所以删掉变量定义节点,引用它的地方会立刻回到 [未定义],撤销栈也干干净净。

运算节点

┌──────────────────────────────┐
│ 描述文字(左上角)             │
│        ┌────┐ ┌────────────┐ │
│        │ +  │ │ 5          │ │  ← 双击算符框轮换 + - * /
│        └────┘ └────────────┘ │
└──────────────────────────────┘

变量定义 → 运算节点 → 消费者:消费者看到的不再是变量的原值,而是沿路运算过的值。 多个运算节点可以叠加((2+3)*4),而不在那条路上的分支不受影响。

这一点早先写错过:求值时用一个共享累加器走 BFS,结果一条分支上的运算泄漏到了兄弟分支。 现在每条路径各带各的值,有专门的断言盯着。

变量的「值」也能引用变量

变量定义节点右侧那个值框,填的同样可以是一个引用 —— 它就变成「由别的变量算出来的变量」:

宽   = 12
高   = 8
面积 = {宽} × {高} = 96      ← 值框里写的是表达式,显示的是算出来的结果
  • 可以链式引用(A = {B}、B = {C}),改最上游那个,整条链跟着变
  • 值框里显示的永远是解析后的结果;双击进去编辑时看到的是原文(你写的表达式)
  • 勾选 / 滑条 / 开关走同一条通道:滑条的值也能填 {变量},会先插值再夹上下限、对齐步长
  • 循环引用(甲 = {乙} 且 乙 = {甲},或者自己引用自己)不会爆栈, 会显示 [循环] —— 靠一个「正在求值的变量」集合挡着
  • 作用域规则和别处完全一致:全局零连线可取、局内要下游、函数分组内外隔离

局内:范围有限的两种形态

「局内」是一把并集的锁 —— 满足下面任意一条就可见:

路径 说明
下游 沿连线能走到的节点(间接下游也算)
指向分组 把这个变量节点连到一个分组,那个分组全组有效(套娃里的成员也算)

两条路都不沾的节点就看不到它。

早先「组内」是单独一种作用域。但它和「局内」本来就是「范围有限」的两种形态, 分成两个只是让人多做一道选择题 —— 现在并成一个了。 老存档里的 scope:'group' 会在 normalizeVarDef 里自动并过来,不用手工改。

全局变量:定义完就能用,不用连线

这是全局和另外两种作用域最本质的区别。 一个全局变量节点定义好之后, 同作用域里的任何地方都能直接写 {名字} 取到它 —— 不需要连线、不需要拖到附近、 不需要任何准备工作。

「同作用域」指的是:顶层文档里定义的,整个顶层都能用;函数分组里定义的, 整个函数分组里都能用。普通分组不隔断(它只是个容器),函数分组才隔断 (那是之前定下的隔离规则)。

一份断言把「所有能写字的地方」逐个点了一遍,全部零连线:

位置 例子
节点正文 看板 {总数}
图片的描述 版本 {总数}
连线标签 标签 {总数}
分组标题 组 {总数}
组件属性(文字 / 数字 / 颜色) 角标 {总数}、描边粗细 {总数}
优先级 {总数}
程序节点的数值 位移 {总数}、字号 {总数}
输出节点按名字取 名字填 总数 就取得到

改一次变量的值,上面所有地方一起跟着变(同样是那条断言在盯)。

三种特殊变量控件

变量定义节点可以挂三种控件(右键「控件 ▶」,或节点面板里的「控件」一行)。 它们共用变量节点的名字、作用域、优先级、{名字} 引用 —— 只是值的来源不同。

勾选节点:选项随便加(右键「编辑选项…」,逗号分隔),点方框勾 / 取消。 输出是选中的那一串,比如 牛肉, 辣椒。

滑条节点:上下限 + 步长可设(面板里三个数字框,或右键)。拖圆点实时改值, 值自动夹在上下限里并对齐到步长。引用它的地方跟着变。

开关节点:放在连接中间。接通时值正常往下流;断开时这条连接在逻辑上断开 —— 下游拿到的就是 [未定义],局内作用域的下游判定也一样不算数。

这里有个坑值得记一笔:求值器「够不着目标就退回变量自己的值」这个兜底, 会把开关的效果吃掉 —— 值明明被挡了,却因为兜底而照样显示出来。 所以加了 VAR_BLOCKED 这个哨兵值,把「够不着」和「被挡住」分开: 够不着才兜底,被挡住就是真的没有值。

任何可编辑文字都能引用变量

不只是节点正文 —— 图片描述、连线标签、分组标题里写 {名字} 一样会被替换 (函数分组的 ƒ 前缀也照样加)。四处都走 refreshVarText() 算出来的派生表, 裸字段永远是你写的原文。

输出节点

新节点类型,只有一个字段:变量名。它自己不带值 —— 值从入边流进来; 没入边就按名字去同作用域里找一个变量定义。

作用域由放在哪儿决定,不用手选:

放在哪 代表什么
顶层文档 整份文档的输出值;这份文档被嵌到别处时,外层可以取
函数分组内 那个函数分组的输出值
嵌入文档内 那个嵌入文档的输出值

每个作用域只有一个输出节点生效(按文档顺序取第一个),其余画面上标「未生效」。

跨层引用用 {嵌入节点名.输出名}:

子图  ← 一个嵌入了别的 .json 的节点
答案 {子图.answer}    ← 取那份文档里名为 answer 的输出节点的值

函数分组

函数分组的语义是这样闭环的:

  • 输入:组内的变量定义节点
  • 运算:组内的运算节点
  • 输出:组内的输出节点 —— functionResult() 就是找它

外面一个变量节点指向这个函数分组,拿到的就是它的输出值。 组内没有输出节点就是空(显示 [未定义]),不会退回变量自己填的值。

函数分组内外完全隔离

一个变量节点的「全局」指的是它所在的函数分组内部的全局,「局内」指的是 组内的下游。跨过分组边界互相都看不见 —— 组内看不到外面的全局变量, 外面也看不到组内的。

右键分组 →「设为函数分组」,标题前面会出现 ƒ。

一个变量节点指向函数分组时,它的值就变成组内算出来的值 —— 相当于自定义的运算节点。

函数分组的运算只看组内:只有「两端都在组内」的边才参与计算。 外部节点接到组内节点上的边,一律不算。

优先级

节点 默认优先级
变量定义节点 1000(最高)
运算节点 100
其它 0

变量定义优先级最高,所以它先算出来给别人用。程序节点的优先级可以手动设 (右键 →「优先级」或节点面板),决定算符叠加的先后;同优先级时按文档里的连线顺序。

程序化节点

一种可视化编程节点:连到普通节点上,就改变它的外观 / 数值 / 位置,而且可以累加。

右键空白处 →「新建 ▶ 程序节点」,或者右键任意节点 →「转成程序节点」。 画出来是双线外框 + 左边一个 ▶,一眼看得出来。

作用 项目 说明
外观 字号 / 字色 / 外框色 / 字体 字号可「累加 +8」也可「覆盖 = 32」
形状 矩形 / 圆角 / 菱形 / 椭圆 覆盖
位置 横向偏移 / 纵向偏移 累加
数值 数值 可累加(+=)也可覆盖(=)

累加规则:沿着「从程序节点出发、指向目标」的那条线施加算符。一个目标被多个程序节点指到时, 按边在文档里的先后顺序依次叠加 —— +8 再来个 +4 就是 +12;set 类算符后写的覆盖先写的。 (把两条线的顺序倒过来,结果就会不同,这是有意设计的。)

可以作用在分组上:线连到分组而不是某个节点时,算符落到组内每一个节点上(套娃会一路挖到底)。

程序节点之间可以链式累加:数值 算符作用在另一个程序节点上时,改的是它的操作数。 于是 (数值+=3) → (数值+=4) → 目标 会让目标拿到 4 + 3 = 7。链条会迭代到不动点, 遇到环最多跑 6 遍就停 —— 有界,不会死循环。

被作用过的目标节点:右上角一个黄点,数值型的还会在右下角显示累计结果(= 12)。

实现上最要紧的一条:效果是「派生」的

算符绝不写回节点本身,只写进一份派生的效果表:

refreshEffects();          // reindex 末尾跑一次,按边序把所有算符叠出来
effColor(n) / effFsPx(n) / effShape(n) / effValue(n)   // 读有效值
nodeBox(n)                 // 有效盒子:x/y 带上位移,shape 带上形状

渲染、命中测试、连线锚点、分组外接框、包围盒一律读有效值,节点裸字段永远是你自己设的那份。

这么做换来两个直接好处:

  1. 撤销栈干净 —— 撤销的永远是「用户做了什么」,而不是算符算出来的中间状态;
  2. 删掉程序节点,目标立刻复原 —— 因为目标从来就没被改过。

顶栏

撤销 重做 | 新建 打开 保存 导出 | 插入 视图 | 设置 ? 刷新
  • 插入:节点 / 图片 / 嵌入 Grapheon / 变量定义 / 运算 / 输出 / 勾选 / 滑条 / 开关, 所有「加一个 X」都收在这里
  • 视图:居中 / 排版 / 居中并排版(原来那两个按钮并进来了)
  • 设置:主题 / 背景 / 默认节点外观 / 新连线样式 / 快捷键(见下)
  • 刷新:重新加载页面(会先问一句,免得丢掉没保存的改动)

主题与背景

主题除了调色板,还带三样「性格」:grid(背景样式)、heart(画不画那颗决心红心)、 star(对话栏和标题前那个星号)。

主题 背景 红心 星号 光标
棋盘(默认) 棋盘格 无 无 小十字准心
Undertale 横纵网格 有 有 决心红心

背景可以在设置里单独覆盖:跟随主题 / 横纵网格 / 棋盘 / 点阵 / 无。

樱花主题(马卡龙粉)

#fff6f9 浅粉底 + #9c5a75 深玫瑰字 + #ff8fb1 马卡龙粉强调色。 浅底上正文必须压得住,所以 white 用的是深玫瑰而不是白。

背景是飘落的樱花(effect:'sakura'),所以网格关掉了 —— 两者叠在一起太花。

花瓣画在主画布里,位置在「底色 + 网格」之后、「图层」之前。这样做有两个好处:

  1. 它天然就是背景 —— 节点会盖住它,不用调 z-index
  2. 不会跑进 PNG 导出 —— 导出走的是 buildExportCanvas 自己的画布, 压根不调 drawSakura()(有断言盯着这条)

动画是一个独立的 requestAnimationFrame 循环,只在主题声明了 effect 时才转: 切走就停,用户也能在设置里关掉(省电),还会尊重系统的 prefers-reduced-motion。窗口大小变了会重新铺一遍花瓣。

光标

自定义光标是唯一的光标(body.dsh-ready 把系统光标设成了 cursor:none)。

  • Undertale:决心红心
  • 棋盘(默认):小十字准心;按住鼠标任意键,中心出现一个实心圆点(黄色),松开就没

⚠ 踩过的坑:棋盘主题当初把 #heart 整个藏掉,而它就是光标 —— 结果系统光标和自定义光标同时消失,鼠标在画面上彻底不见了。 现在两套光标分别放在 #heart 和 #cross 里,placeCursor() 每次都 先全关再开一个,并且有断言盯着「任何主题下都正好有一个光标可见」。

「不要红心」是一颗都不留:红心在这个项目里一共三处 —— 节点旁边的选中标记(canvas 里画)、 跟着鼠标的光标(#heart)、菜单 / 选项 / 按钮前面那个小标记(.hrt 和 .ud-btn::before 的 CSS mask)。前一处由 themeHeart() 控制,后两处靠 body.no-heart 一次清掉。

这里踩了个 CSS 特异性的坑:清遮罩的规则写成 body.no-heart .hrt(0,2,1), 而原规则是 #set .opt .hrt(1,2,0)—— id 赢,所以必须 !important。

加一套新主题只要往 THEMES 里加一项。

设置

设置面板(顶栏「设置」,Esc 关)里有:

  • 主题:棋盘 / Undertale
  • 背景:跟随主题 / 横纵网格 / 棋盘 / 点阵 / 无
  • 默认节点外观:字体 / 字号滑条(0 = 自动,拖动时数字实时跟着变,松手才落盘)/ 从文件加载字体
  • 新连线:箭头样式
  • 快捷键:列出当前全部绑定,一键恢复默认

改完立刻生效,全部存 localStorage,没有「确定 / 取消」。

背景样式是从主题里拆出来单独存的(grapheon.grid.v1), 这样「跟随主题」和「我就要点阵」两种诉求都能满足。

双击 / 三击分组

手势 做什么
单击分组标题 选中外框(然后就能拖、能整体搬)
双击分组标题 / 框边 选中组内所有节点,不含外框;套娃里的成员也算
三击 选中外框
  • 「组内节点」是递归的 —— 你双击一个组,想要的是「这个组里所有的东西」, 不是「直接挂在这一层的那几个」
  • 藏起来的成员会被跳过(和全选保持一致),提示栏里会报跳过了几个
  • 命中范围是整个分组:标题栏、外框边、框内部都算 —— 但打在成员节点/连线上的不算(否则双击组内节点会被当成双击分组,节点就编辑不了)
  • 例外:空框(里面一个节点都没有)让给「新建节点」—— 那种情况下「全选组内节点」等于什么都没选,还是建个节点实在
  • 想在有成员的框里加节点:右键 → 新建 ▶ 节点(落在右键的位置), 或者用顶栏「插入」
  • 改名从双击挪走了:现在走右键菜单或 F2(选中外框之后按)

实现上用的是 click + ev.detail 而不是 dblclick:dblclick 只管「第二下」, 数不到第三下。click 的 detail 就是连击次数,2 和 3 都拿得到。

对齐与分布

顶栏「视图 → 对齐与分布」,或者选中两个以上时在节点右键菜单里也有。

分组 项 做什么
对齐 左对齐 / 水平居中 / 右对齐 左边缘 / 中心 / 右边缘 对齐
对齐 顶对齐 / 垂直居中 / 底对齐 上边缘 / 中心 / 下边缘 对齐
分布 横向等距分布 / 竖向等距分布 边到边的间距相等(至少三个)

几条设计上的取舍:

  • 基准是整个选择的外接矩形 —— 这样结果和眼睛看到的一致, 不会因为「以第一个为准」还是「以最后一个为准」而猜错
  • 分布按中心排序,所以宽窄不一的时候视觉顺序也是对的; 首尾不动,中间的重新摊匀
  • 选中的分组当成一个整体搬,它内部的节点会被跳过 —— 否则同一个节点会被搬两次
  • 故意不跑防重叠:用户要的就是把它们排成一条线,弹开就白排了。有断言盯着这条

原来顶栏那个「排版」按钮撤掉了(树形自动重排)。 tidyLayout() 这个函数留着 —— 载入经典示例时还在用它, 但界面上不再暴露:自动重排会把用户手动摆的位置一把冲掉, 而「对齐与分布」是手动的、可预期的、随选随用。

防止节点重叠

默认开启:拖一个节点压到别人身上,被压的会自动弹开(沿重叠更小的那个轴推, 位移最小),被拖的那个不让路,所以手感是跟手的。弹开之后留 16px 的缝。

关掉之后可以随便叠。开关在右键空白处 / 右键节点的菜单里,也有一项 「弹开所有重叠的节点」可以手动清一次(这一项无视开关)。

设置存在 localStorage 的 grapheon.overlap.v1。

只挂在交互路径上(拖拽结束、缩放结束、手动弹开),不挂在 addNodeAt 上。 程序化铺文档时需要能精确指定坐标,自动弹开会把布局搞乱。

排版

原来这里的「排版」按钮撤掉了(树形自动重排会把手动摆位一把冲掉)。 现在用视图 → 对齐与分布:手动的、可预期的、随选随用。 tidyLayout() 函数还留着 —— 载入经典示例时在用它。


方案 / 待办

还没做、但方案已经定下来的东西放这儿:

项 内容 在哪
变量节点多变量 一个变量节点放多个变量,每个一个盒子。核心是把 def 从「节点」升级成「节点 + 下标」,牵动 findVarDefIn / varVisibleIn / defValueIn / evalFromIn / refAnchorOf 方案
循环节点 输入 A/B 是两个节点,A→B 之间的子图当循环体跑 N 次 方案
程序组多输出 组上挂一张端点表,每个输出端点绑一个组内变量 方案 §5
程序组输出端点能不能绑「组内输出节点」 §11 的第 4 个待定问题,还没回答 方案 §11
非数值参数引用变量 现在滑条上下限、算符操作数能写 {变量};颜色、字号、倒角这些还不能 —
链接引用本地 / 网络数据 本地文件走素材库那套 Store;网络取值要先拉进缓存再求值(求值链是同步的,不能改成 async) —
拖端点方块落在节点内部 现在是「把端点挪过去」而不是「连线」,这个边界没确认过是否符合直觉 —
组件拖拽排序 / 组件间依赖 — —
打包成宿主 native 那个存储后端要真打包了才有意义 —
.css 主题文件 现在主题是 core/theme.js 里的 JS 注册表 —

操作

按键 作用
方向键 / WASD 在该方向生成新节点并自动连好线(自动避开同一排的节点)
Tab 添加子节点
Enter 添加兄弟节点
Ctrl + 方向键 只跳转选择,不新建
E 样式面板:选中节点开节点样式,选中连线开连线样式
Ctrl+G 把选中的多个节点加入分组
拖节点进/出分组框 自动收纳 / 自动移出(归属最内层)
拖分组进分组 套娃
Shift 点分组标题 分组多选(可以和节点混选)
拖分组框右下角 调整框的大小(下限是成员外接框)
拖连线端点 改接到别的节点
拖已选中的连线 拉出拐点,自由改走向
拖拐点方块 / 双击拐点 移动 / 删除拐点
拖节点右下角手柄 自由调整节点尺寸
节点右键「连线端点吸附…」 把某一端钉在上/右/下/左任意一条边
点节点上的数字角标 展开被折叠的子树
选中分组按 Space 折叠 / 展开整个分组(角标在标题右边)
拖图片进窗口 / 右键「新建 ▶ 图片…」 加一个图片节点
右键「新建 ▶ 嵌入 Grapheon…」 把一整份 .json 当成一个封闭节点嵌进来
双击嵌入节点 / 按 Esc 进去编辑副本 / 返回外层
双击图片右上角 / 图片下方 改名称 / 写描述
Shift + 拖拽空白 框选
拖拽空白 / 中键拖 平移画布;滚轮缩放
双击空白 / 节点 / 连线 新建自由节点 / 改名 / 改标签
Shift + 点击两个节点 快速建立连线
F2 重命名(Shift+Enter 换行)
Delete 删除选中的节点(连同子孙)或选中的连线
Space 折叠 / 展开子树
Ctrl+Z / Ctrl+Shift+Z 撤销 / 重做
Ctrl+S / Ctrl+O 保存 JSON / 打开 JSON(也支持拖文件进来)
Ctrl+E 打开导出面板
Ctrl+L 按树形排版
Ctrl+A 全选节点
H / ? 操作指南

导出

顶栏「导出」或 Ctrl+E:

  • 导出范围:全部节点 / 仅选中的节点 / 选中的子树(按当前选择动态启用)。 底部实时显示「将导出 N 个节点 · M 条连线 · W × H 像素」。 连线只保留两端都在导出范围内的,不会拖出线头。
  • 标题:填了就以 32px 白字画在图片左上角,过长自动截断加省略号。
  • 文件名:默认 grapheon-YYYY-MM-DD,自动清理 Windows 非法字符。

文件名 / 标题 / 上次的范围都会记住。输出固定 2× 分辨率,带白框、左下角水印、右下角日期。

新建

顶栏「新建」是菜单:空白文件(一个中心节点)/ 示例文档。会覆盖当前内容与自动存档。


目录结构

index.html                 只有 DOM 结构与引用,不含任何逻辑
styles/
  theme-undertale.css      主题变量 + 全部样式
src/
  core/    纯逻辑,不碰 DOM
    util.js                常量、调色板、可选字体/颜色表、面板通用控件、统一下载通道
    theme.js               主题注册表(DOM 与 canvas 共用一份调色板)
    text.js                文本度量、中英混排换行、节点尺寸(按各自字体字号)
    state.js               文档模型、id 分配、父子索引、分组、程序效果(派生)、序列化
    vars.js                变量系统:插值、作用域、运算节点、函数分组、优先级
    history.js             撤销 / 重做栈、localStorage 自动保存
    layout.js              树形排版(一次性);relayout() 只重算尺寸
    routing.js             连线几何:四向锚点、正交折线、贝塞尔、拐点样条、端点吸附
  view/
    stage.js               canvas 元素与 2D 上下文、DPR / 视口尺寸
    render.js              绘制:网格、连线(按各自样式)、节点、端口、拖端点预览
    camera.js              世界坐标 ↔ 屏幕坐标、居中适配、以光标为中心缩放
    hit.js                 命中测试:节点 / 端口 / 连线 / 手柄 / 拐点 / 缩放柄 / 角标 / 分组
  interact/
    editing.js             节点文本与连线标签的行内编辑
    commands.js            结构操作 + 连线样式操作
    pointer.js             鼠标状态机:框选、平移、拖拽、缩放、拉新线、改接、拉拐点、搬分组
    keys.js                快捷键绑定表 + 动作注册表
  ui/
    dialogue.js            底部打字机对白栏
    menu.js                通用弹出菜单(支持多级子菜单;右键菜单、「新建」菜单都复用它)
    help.js                操作指南浮层
    exporter.js            导出面板
    edgebox.js             连线样式面板(箭头/线型/走线/标签)
    endbox.js              连线端点面板(钉死某一端接在哪条边)
    nodebox.js             节点面板(程序算符 + 字号 / 字体 / 字色 / 外框色 / 尺寸)
    cursor.js              红心光标跟随
    toolbar.js             顶栏按钮绑定(装配层,必须在最后)
  app/
    document.js            模式无关的文档操作:新建 / 打开 / 保存 / 拖入
    demo.js                首次打开时的示例文档
    main.js                启动引导与主循环
tests/
  regression.js            2038 项断言
  run.mjs                  在真实 Edge 里跑断言:node tests/run.mjs

为什么是普通脚本而不是 ES module

模块之间共享同一个全局作用域,所以双击 file:// 直接打开就能跑。 ES module 在 file:// 下会被 CORS 拦掉,想保留「双击即用」就必须放弃 import。

⚠ 三个必须记住的约束

1. 加载顺序 = 依赖顺序。 函数声明只在自己那个脚本里提升。所以 onclick = saveFile 这种「加载期就把函数当值取出来」的写法,要求 saveFile 所在模块 必须先加载;否则那一行会抛 ReferenceError,而且该脚本后面的所有代码一起不执行。

这个坑真踩过:ui/toolbar.js 曾排在 app/document.js 前面,结果保存/导出/居中/帮助 四个按钮一起失效。现在 toolbar.js 排在最后、且一律用箭头函数包一层(点击时才解析), 测试里也有专项断言(A02/A03)盯着每个按钮。

2. 下载只有一条路径。 保存 JSON 和导出 PNG 都走 core/util.js 的 downloadBlob()。

3. 别缓存节点引用。 deserialize() 会整体重建 doc.nodes,之前拿到的对象就过期了; 要拿当前文档里的节点用 byId() / 现查。测试里踩过这个(root 变量拿到旧对象)。


数据格式

localStorage 和导出的 JSON 都是 v:2:

{ "v": 2, "nid": 120,
  "groups": [{ "id":"g1", "title":"前置处理", "members":["n1","n2","g2"], "color":null,`n              "collapsed":false, "x":0, "y":0, "w":320, "h":220 }],
  "nodes": [{ "id":"n1", "text":"甲", "x":0, "y":0, "shape":"rect",
              "collapsed":false, "fixedW":null, "fixedH":null,
              "font":null, "fsPx":null, "color":null, "border":null, "kind":"node" }],
  "edges": [{ "id":"e1", "s":"n1", "t":"n2", "label":"是",
              "arrow":"both", "dash":true, "route":"curve",
              "aSide":null, "bSide":null,
              "waypoints":[{ "x":400, "y":120 }] }] }
字段 含义
fixedW / fixedH 手动拖过的尺寸;null = 随文字自适应
aSide / bSide 端点钉在哪条边上(r/l/t/b);null = 自动吸附
waypoints 手动拐点;null = 按走线方式自动路由
collapsed 节点:折叠子树 / 分组:折叠组内后代(子孙会被隐藏)
isFunction 分组:是不是函数分组
font / fsPx 字体 id / 字号;null = 跟随主题
color / border 字色 / 外框色;null = 跟随主题
kind node / program / image / embed / var(变量定义)/ op(运算)/ out(输出)
outDef 输出节点:{ name }
varDef 变量定义节点:{ name, value, type, scope }
opDef 运算节点:{ op, operands:[...], type }(operands 的长度由算符的 arity 决定)
priority 手动设的优先级;null = 用 kind 的默认值
embed 嵌入的整份文档 { doc: <一份完整的 v2 文档> }
image / imgW / imgH 内嵌的 data URL / 图片原始宽高(比例靠它保持)
desc 图片节点的描述文字
value / program 数值型算符累加的结果 / 程序节点的算符

v:1(有 mode: "mind"|"flow"、没有连线样式)的老存档也能直接打开: 读取时会把它一次性翻译成每条线的 route(mind → 曲线、flow → 正交), 其余样式补默认值,打开后长相不变。详见 deserialize()。


扩展

加一个主题

在 src/core/theme.js 的 THEMES 里加一项(canvas 的键就是画布用的颜色), 调 applyTheme('xxx') 即可 —— 它同时改画布调色板 C 和 CSS 变量并存盘。

调色板只有这一处定义:CSS 变量名与 THEME_VARS 一一对应,DOM 和 canvas 永远一致。 按钮悬停的像素心、鼠标红心、画布上选中节点的红心都是 --r 驱动的遮罩/填充,会一起变色。

改快捷键

GP.keys.bind('ctrl+d', 'node.delete');   // 改绑(同一个动作只保留一个键,旧键自动释放)
GP.keys.unbind('w');                     // 解绑
GP.keys.reset();                         // 恢复默认
GP.keys.rows();                          // 列出所有绑定(带中文标签,可直接喂给设置面板)

默认值在 src/interact/keys.js 的 defaultBindings()。加新动作:往 ACTIONS 里加 { label, group, run, overlay? };run() 返回 false 表示「当前不适用」, 会交回给兜底逻辑(e 键就是这么做的:选中连线时开样式面板,否则起手改名)。

加一条连线的样式维度

  1. core/state.js:在 EDGE_DEFAULTS / normalizeEdge() 里加字段和合法取值;
  2. view/render.js:drawEdge() 里按新字段改绘制;
  3. ui/edgebox.js:加一组 buildOpts(...) 即可(选项组是数据驱动的)。

加新菜单

src/ui/menu.js 的 showMenu(x, y, items) 是通用的。items 里每一项是:

写法 含义
'hr' 分隔线
[标签, 快捷键提示, 回调] 普通项;回调传 null 就是灰掉的不可点项
[标签, 提示, null, 子项数组] 带子菜单,鼠标移上去往右展开(右边放不下自动翻到左边)

子菜单可以嵌套任意层。菜单项太多会看不清,所以按功能分层:

菜单 子菜单
节点 形状 / 分组
连线 箭头 / 线型 / 走线 / 拐点
分组 颜色
空白处 新建

子菜单里的当前值前面带 ●,点一下直接切过去(不是「点一次循环一个」)。 radioSub() 和 colorSub() 就是生成这两种单选子菜单的小工具。

加新面板

面板统一贴在右下角、下方提示框的正上方。位置由 CSS 里的 --dlg-h 决定, 而 --dlg-h 是 ui/dialogue.js 用 ResizeObserver 量出来的提示框高度 (提示文字会换行,高度不固定,所以不能写死)。

新面板只要写 position:fixed 的样式并把 id 加进那组选择器就行, 不用自己算位置。


测试

node tests/run.mjs          # 在真实 Edge 里跑 2038 项断言;退出码 0 = 全过
node tests/run.mjs --keep   # 保留临时页面,方便手动打开看控制台

覆盖范围:架构(模块确实分开了、GP 扩展点存在、加载期无未捕获错误、模式按钮已移除)、 顶栏装配(9 个按钮都绑了处理函数、每个点下去都真的有效果、保存走统一下载通道)、 字体与主题、核心结构(排版无重叠、坐标换算、序列化往返、老 v1 存档的样式翻译)、 连线类型(默认值、按各自 route 渲染、箭头端点几何、样式面板的打开/同步/切换/关闭、 E 键的双重语义、右键菜单项)、拖端点改接(手柄命中、改接成功、落空取消、拒绝自环、 拒绝重复连线、可撤销)、 折叠子树(真隐藏、角标可点开、框选/全选/导出都跳过、展开时子树跟着父节点位移、 连隐藏子孙一起删、状态可存)、 端点钉位(默认全自动、右键面板、指定后几何真接在那条边、不随相对位置改变、可恢复自动、可存)、 节点自由缩放(拖手柄、有下限、恢复自适应、排版按固定尺寸算且不重叠、可存)、 连线拐点(拖线身生成、拖拐点、拉直自动收掉、双击删除、右键加/清、曲线走样条、可存、可命中)、 节点外观(默认全跟随主题、面板选项齐备、改字号/字体重算尺寸、颜色真的用上、 一键恢复默认、非法值被规整、可存)、 嵌入文档(建节点、封闭(三处拦截 + 反序列化丢边)、进出编辑、改的是副本而父文档结构不变、 嵌套嵌入、视图/选中/历史栈恢复、退出入历史、嵌入期间不写自动存档、手动尺寸、存读往返、 空文档与残缺数据不崩、参与选中/分组/折叠但不能连线)、 图片节点(建节点、尺寸=名称带+等比图+描述、窄图不拉宽、超宽有限宽、 拖手柄等比缩放、三块命中、双击分别改名称/描述、描述换行、连线与程序作用、存读往返、 外链与非 data:image 一律过滤、非法 kind 规整、参与选中/分组/折叠、导出前等解码、 完整导入链路(canvas→blob→File→导入)、非图片被挡、换图保留名称描述)、 分组多选(Shift 追加/取消、拖动一起走、Del 批量解散、Ctrl+G 收分组、多选不给手柄、 混选节点、框选整个框、元信息、全选含分组、单选/清空的状态转换)、 新建连线的默认类型(内置默认 / 改完新建的边跟着变 / linkNodes 那条路也一样 / 存读往返)、 表格每行每列各一对加减号(列的下方 / 行的右侧;加号在右侧・下方插入,减号删当前; 行列里有数据时先提醒一次,再点一下才真删;只在选中/悬停时出现;至少留一行一列)、 普通/图片/表格节点不画端点(模型还在,所以照样连得上线 —— 只是不摆那堆小方块了)、 程序节点能自由改尺寸(var / op / out / table 都认 ixedH 了、内部行/框跟着摊开拉伸、并非只认 ixedW)、 端点吸附面板列真实端点(不再是无脑四条固定边 —— 选了没有的边当然没反应)、 改接之后清掉旧端点钉法(否则线会拿旧端点 id 去新节点上乱找)、 广播节点(右上角一个 wifi 符号、把输入值变成全局变量、只能设名字、远处没连线的节点就能引用、上游一改跟着变、 值框不可编辑而名字框可编辑、读档保住 varDef)、 运算符节点(两个输入端点各对一个操作数、按端点 ID 升序、第二路接上就顶掉格子值、 老存档(没钉端点)行为和以前逐字一致、�rity+1 个输入端点)、 条件节点(一个输入一个输出、输入为 1 → 输出所填的值,否则 → 输出「无」、 它不是透传上游而是输出自己的值、按名字引用拿到的也是这个、 两种路都要输入为 1、2 / 空格 ' 1 ' 的判定、老通路节点就地转换)、 走线避让(障碍正挡中间时能绕过去、没障碍时逐点不变、端点不受影响、障碍挪走就回到直连、 拐点边不走避让、穿盒子判定、障碍上限、分组框不算障碍、示例里穿节点的条数大幅下降)、 边两端必须落在真实端点上(老的四条边中点彻底不用了、没记端点就按「哪个端口朝着对方」自动挑、 �Side 改成在真实端点里挑那一边的那个、边上没有该方向的端点就退回自动)、 落点端点也钉死(拖到哪个端点就连哪个、两条边各钉各的、终点分别落在各自端点上、方向跟着对齐、 存读往返保住落点)、 锚点方向 d 必须是数组(anchorsFor 用 [dx,dy]、往下游塞 {x,y} 会让坐标变 NaN 线就画不出来; 专门断言 d 是数组 / 分量有限 / 不是零向量 / 几何里没有 NaN / 画布上真的看得到线)、 锚点不污染盒子缓存(nodeBox 返回的是 idx 里的缓存对象,往上写标记会让所有锚点退化成同一个点、线画不出来; 专门断言缓存没被污染 / 没钉端点的边长度正常 / 反复算结果稳定 / 每条边都算得出几何)、 边钉死在端点上(线从端点的真实位置出来、不是那条边的中点、端点再挪线跟着走、 同边两个端点各钉各的、aPort/bPort 存读往返、没记端点的老边照旧自动挑)、 端点搬到哪就从哪能拉线(hitPort 改按实际端点表命中、旧位置不再命中、多端点各自认得清、 端到端从搬过之后的位置真能连上)、 端点编辑弹窗(双击方块弹出两个输入框、ID 撞车时整条不生效)、 从端点连线(没选中时鼠标停在谁身上就能从谁的端点拉线、落点放宽到节点边外一点、 分组选的端点优先于悬停)、 端点可拖 + 双击改 ID/标签(端点旁一个实心方块当把手、走完整事件流验按下→挪→松手真换边、 撤销能回退、圆点仍归拉线、靠哪条边挂哪条边、沿边滑位置、 点标签才是拖端点、点圆点还是拉线(不抢连线手势)、双击一句话同时改 ID 和标签、 撞车时整条不生效不许改一半)、 B 期端点模型(默认端点位置和老的四处中点完全一致、id 纯数字正整数不重复、 撞了明确拒绝且不偷改、标签能改、加删端点后同路边均匀铺开、按 id 排稳定、 id 决定汇合顺序、悬停/选中才显示标签、多选都显示、画得出来、存读往返、 非法表自动规整不让文档打不开)、 A 期求值基础:多输入汇合(节点变换只有 applyNodeOut 一条路、往回求上游值、 和现有前向求值逐站对齐、菱形两路输入各自可求、无来源返回 null、 关着的通路返回 BLOCKED、绕环不栈溢出、穿过程序组也对)、 N 键隐藏 / 恢复界面(默认不隐藏、隐藏后 #ui 真的看不见、提示条反过来可见、 快捷键表里登记了、再按一次恢复)、 连线流动动画(开关、时钟按 dt 累加、主题能定制也能显式关掉、 折线按弧长取点、走真实 draw() 路径每条边都调到、关掉一个点都不画、选中的边不画、 樱花和流动共用同一个 rAF 循环(各开一个会抢 draw()))、 控件节点的节点名 / 变量名分离(左边新增变量名格子、标题行=节点名、引用走 varName、 改节点名不影响引用、三种控件都一样、滑条命名为 x 后 {x} 引用得到)、 双击改名(变量/运算/输出/勾选/滑条/通路/程序化/文本 八种节点逐个验、 改完真的写进去、控件节点没有 nameBox 也不能让命中函数抛异常)、 双击/三击分组(双击选组内全部节点且不含外框、框内空白也算、打在成员身上必须让给成员、 双击组内节点打开的确实是节点编辑器、框内空白仍然算双击分组、dblclick 必须让开否则会冒新节点、 空框例外仍是新建节点、递归到套娃、藏起来的跳过、 三击选外框、单击仍选中外框(拖拽不受影响)、框内空白双击仍是新建节点、 改名挪到右键与 F2)、 对齐与分布(六种对齐各自验边缘/中心、基准是整个选择的外接矩形、 横向竖向等距分布且首尾不动、宽度不一时按中心排序、选中的分组当整体搬且组内节点不重复搬、 对齐之后允许重叠(故意不跑防重叠)、可撤销重做、视图与多选右键两个入口、 排版按钮确实撤掉了)、 作用域合并:局内 = 下游 ∪ 指到的分组(只剩两种作用域、老存档的 group 自动并过来、 下游这条路、指向分组这条路、套娃成员也算、撤掉分组那条边就看不到了、下游不受影响、 面板只剩两个选项、函数分组隔离不受影响)、 变量定义节点的值也能引用变量(普通变量的值填 {别的变量}、链式 A→B→C、 字符串拼接、引用不存在的、转义、循环引用显示 [循环] 不爆栈、作用域规则一致、 滑条的值填表达式后先插值再夹取对齐、下游与运算节点接在后面都对、存读往返保住表达式)、 全局变量无需连线(零连线下正文/描述/连线标签/分组标题/组件文字·数字·颜色/优先级/ 程序节点数值/输出节点逐个验证、改一次全都跟着变、普通分组不隔断而函数分组才隔断、 三种作用域「要不要连线」的对照、程序节点数值支持表达式且链式累加只在数值算符上生效)、 樱花主题 / 飘落特效(马卡龙粉色卡逐位校验、特效开关(主题 + 用户 + 系统减少动态)、 花瓣铺得均匀 / 会飘 / 落到底重生 / 出界绕回、画布上真的找得到花瓣色、 换主题就没、导出图里一个花瓣都没有、网格关掉后底上不留格子)、 自定义组件 / 用户主题 / native 后端(组件声明与注册注销、多效果组合与部件编号、 部件默认值的逐级兜底、注销时清实例、定义跟着文档走、面板里新建编辑删除、 主题文件解析与套用与删除、主题跟着文档走与本地库重读、字体持久化链路、 native 后端(假宿主注入、base64 过桥还原成 Blob、被优先选中、卸掉宿主后退回))、 面向组件(注册表结构、增删改与「一个类型只留一个」、可引用属性真的过插值、 引用作用域与节点正文逐条对比、优先级填表达式、条件隐藏与折叠共用 hidden 表、 角标/描边/线宽的取值与绘制、存读往返、坏数据规整、组件面板对三种实体、 内置能力清单、专属组件的可见性)、 光标(两个主题各一个、不会同时出现也不会都没有、换主题立刻切、 按住鼠标中心出实心圆点、松开还原)、 素材库 / 存储适配层(扩展名归类、主题 json 与作品 json 区分、内存与 IndexedDB 后端增删查改、 一键导入按类分流、后端自动挑与降级、用户文件夹名可自定义且落盘、面板开合与互斥)、 主题 / 背景 / 设置面板 / 字体(棋盘主题一颗红心都不留(class + 计算样式都验)、 选项选中项高光、字号滑条实时读数与落盘、从文件加载字体(坏文件被挡、家族名清洗)、 主题 / 背景 / 设置面板(默认主题是棋盘、无红心无星号、四种背景各画一次比像素、 关掉背景底上不留格子、切主题红心回来、星级主题摘掉/保留对话开头的星号、 主题与背景落盘且重开还记得、设置面板开合与内容、面板之间不叠、 顶栏 11 个按钮全部真的有效果)、 三种特殊控件 + 任意文字引用变量(连线标签 / 分组标题 / 图片描述里引用变量、 改一个字就重算、控件改名共作用域、勾选选项与输出串、滑条对齐步长与夹上下限、 按坐标拖滑条、开关断开后值过不去且不算下游、控件命中区、控件存读往返)、 输出节点 / 函数分组 / 运算符表(输出节点、有入边取值、无入边按名字取、顶层输出、 函数分组的输出由它决定、没有输出就是空、每个作用域只有一个生效、函数分组内外完全隔离、 {嵌入名.输出名} 跨层引用、不污染当前文档、存读往返、运算符注册表(arity 驱动布局/ 命中/面板/求值,现场塞一个双输入算符验证它自动跟上))、 防止节点重叠(默认开、碰撞时推谁、留缝、关掉可叠、手动弹开无视开关、 全部堆在一起也能拆干净、节点一个不少)、 连到分组的线能存读往返、 变量系统(变量节点、三种作用域各自的可见性、{name} 插值、\{ 转义、[未定义]、 插值参与量宽、运算节点沿路径生效、多运算叠加、四种算符、除零、字符串拼接、 每条路径各带各的值(分支不互相污染)、函数分组、函数分组只看组内边、 优先级与同名变量的取舍、派生性(不写回裸字段)、删掉定义就回到未定义、存读往返、分部命中)、 分组折叠(组内后代藏起来、框留着、角标可点开、幽灵框抑制、空框不受影响、套娃逐层收、可存、藏起来的框点不到)、 分组自由框(至少两个节点才能成组、成组不动连线、新建时贴合成员、手动尺寸是下限(拉不小)、 成员顶出去自动长大且回来不缩、排版后自动重新贴合成员、空框不被排版动、 拖入自动收纳/拖出自动移出、拖标题框和成员一起走、 四个端点可命中可拉线、连线可指向分组、分组也能用节点那套端点吸附面板、 空框不会消失、成员被删后自动清理、解散只拆容器、可存、框内部不会挡成员、Ctrl+G)、 程序化节点(创建、没连线不生效、外观/形状/位置/数值四种算符、多个累加、按边序累加、 作用在整个分组上(含套娃内层)、程序节点之间链式累加、有环时迭代有界、 裸字段不被写回、删掉程序节点目标复原、程序节点之间不叠加、路由与命中都走有效位置、 转换与序列化往返、撤销回退、面板显示有效外观、标题跟着算符自动更新)、 菜单分层(根菜单收短、子菜单 hover 才展开、当前值带 ●、点了就生效、不越出视口右边、收起无残留)、 面板定位(五个面板都贴提示框上沿、右下角对齐、不顶到顶栏、提示框变高时跟着上移)、 方向生成与 Tab/Enter 结构操作、快捷键表、鼠标状态机、新建菜单、 导出(范围裁剪、标题、文件名净化、真产出 PNG、导出图不含选中态)。

运行器会同时收集三种错误:Runtime.exceptionThrown(未捕获异常)、 注入在 <head> 最前面的 window.__loadErrors(加载期错误),以及控制台 error 日志。 只收控制台日志是不够的——未捕获异常根本不走那条路。 浏览器 profile 每轮都是全新的,否则上一轮留下的 localStorage 会让断言互相污染。



路线图

Markdown 渲染(计划中)

节点文字目前是纯文本 + 自动换行。计划支持节点内 Markdown(粗体 / 斜体 / 行内代码 / 列表 / 标题), 导出 PNG 时一起渲染。

打算这么做:

  1. 不用第三方库 —— Unifont 没有粗体,所以「粗体」只能靠描边两次或换色来伪造; 下划线 / 删除线是画线,行内代码是加底色块。这些都和现有的 wrapText() / drawNode() 对得上。
  2. 先解析成行内片段树([{text, bold, italic, code, strike, link}]),再走现有的折行逻辑 —— 折行的单位从「字符」变成「片段」,宽度按片段量。这是主要工作量。
  3. 语法开关放在节点上(node.md = true),默认关闭,避免 * 之类的字符被误判。
  4. 导出的 PNG / 未来的 SVG 都复用同一套片段绘制。

提示语的规范

底栏那一行就是「刚发生了什么」。为了让它当日志用也站得住,定了几条规矩:

规矩 为什么
一律以 * 开头 底栏的固定形态
一句话,不超过 28 字 底栏只有一行,塞教程就没人看了
改动类必须报出被改的东西的名字 「尺寸已改」看完不知道改的是谁
改内容的也要记 改名 / 改值 / 改格子 / 改样式 / 改形状 / 改尺寸,一样要报名字
外观节点(程序节点)也算改动 它自己的参数、以及它作用到哪个目标上,都要报出来
不写快捷键 快捷键集中在帮助面板(按 H)
不用旧名字 程序节点 / 运算节点 / 通路节点 / 滑条节点 这些早就改名或取消了

报名字靠三个工具(core/state.js):

tagOf(n)      // 节点 → 「正文首行」;空正文退回类型词(「变量 音量」「表格」)
tagOf(g)      // 分组 → 「标题」
edgeTag(e)    // 连线 → 「起点 → 终点」
namesOf(ids)  // 一批 → 「甲」「乙」「丙」;超过三个报「等 N 个」

这些不是靠自觉:node tools/check-say.mjs 会逐条检查, 包括「句子里出现『改成 / 恢复 / 加了一 / 折叠了』这类词, 却没调 tagOf / edgeTag 报名字」这种情况。现在 178 条全合规。

全局操作(撤销 / 全选 / 居中 / 导出 / 复选快捷键…)本来就没有 「被改的某一个东西」,规则里用白名单放行。


操作记录

顶栏的「记录」按钮:这次会话里做过的每一件事,按时间倒序列出来。 数据源就是底栏那句话 —— say() 说一句「刚发生了什么」,顺手记一条。

  • 按分类筛:操作 / 文件 / 视图 / 设置 / 面板,按钮上带数量
  • 复制 到剪贴板,或 导出 txt(HH:MM:SS [分类] 文字)
  • 环形上限 800 条,只活在内存里,刷新就没了 —— 它是「我刚才干了什么」的记事本,不是存档

面板自己那几个动作(清空 / 复制 / 导出)不记自己, 不然清空之后会立刻冒出一条「记录已清空」。


变量节点的四种类型

变量节点的四种类型

样式面板里有一行**「内容」**可以直接改(勾选填逗号、列表一行一项、地图一行一对 key=value):

样式面板的内容编辑

变量节点可以随时切换类型,切换不丢数据(每种的字段各存各的):

类型 行为 引用写法
单一变量 填什么就是什么,数字 / 字符串都行 {名字}
列表 任意长度,默认 0 1 2 {名字.序号},序号从 0 开始
地图 key 索引 value {名字.键}
滑块 起点 / 终点 / 步长都是浮点数 {名字}

外加两种老类型:勾选(勾中的拼成一串)、条件(输入为 1 才放行)。

所有的格子都能写 {变量} —— 列表的每一项、地图的键和值、滑块的上下限都算。

⚠ {a.b} 这个写法以前就存在,是「嵌入文档的跨层引用」({嵌入名.输出名})。 现在变量优先:先试「变量 + 下标 / 键」,那个变量不存在、 或者它不是列表 / 地图,才退回嵌入那条路。


项目结构图

GRAPHEON 结构与功能

仓库根目录的 docs-structure.png 是用 Grapheon 自己画的项目结构与功能图:

node tools/make-structure-diagram.mjs

它会起一个无头 Edge 打开注入了 tools/structure-diagram.js 的页面, 页面里用 Grapheon 的 API 建出整张图,再走它自己的导出通道产出 PNG, 最后通过 CDP 把图取回写成文件。改完代码重跑一次,图就跟着更新。

这张图顺带当了一次回归测试:做它的时候发现导出时指向分组的连线会被整条丢掉 (buildExportCanvas 和 drawGraphForExport 各自过滤了一遍边,只认节点集合), 已经修掉并加了断言。


素材库(用户文件夹)

和程序分开,默认 user/,名字可以改。里面按类型分四个子目录:

user/
  assets/    图片
  docs/      .json 作品
  themes/    主题
  fonts/     .ttf / .otf / .woff2

顶栏「设置 → 素材库 → 打开素材库」或面板里的按钮。能看缩略图(图片给缩略图、 字体用它自己渲染、作品报节点数、主题给色块),「插入」按类型做最合理的事。

存储适配层(src/core/store.js)

这是为「以后打包成 exe / 移植到手机」准备的。 上层不直接调 File System Access API, 只认一套接口(list / get / put / del),后端可换:

后端 用在哪 说明
fs 桌面 Edge/Chrome File System Access API,真的磁盘文件夹。句柄存 IndexedDB,下次重连
idb 手机 / exe 里的 WebView / 不支持的浏览器 IndexedDB,素材在浏览器里
mem 最后兜底 内存,刷新即失

Store.init() 按可用性从好到差自动挑。实测 file:// 在 Edge 下是 secure context, showDirectoryPicker 可用(有断言盯着这个能力探测)。

「一键导入素材」= 多选文件或整个目录,按扩展名自动分流;.json 会读一眼内容, 带 canvas 调色板的算主题,否则算作品。

还没做的

主题文件一键套用

主题文件是 JSON:{ label, canvas:{ bg, white, yellow, red, gray, dim, grid }, grid?, cursor?, heart?, star? }。 也接受 { id, theme:{...} } 这种包一层的写法。

两个入口:设置面板「从文件加载主题…」、或素材库里主题卡片的「插入」。 套用后会进主题表(设置里的主题列表立刻多一项),也记进本地库,下次打开还记得。

主题定义也跟着文档走:serialize() 会带上用到的用户主题,别人拿到你的 .json 不用另外要主题文件。

字体持久化

从文件加载的字体会顺手存进素材库,下次开页面 restoreUserFonts() 再从库里捞回来 重新喂给浏览器。坏字体安静跳过,不挡启动(都有断言盯着)。

exe / 手机:native 存储后端

store.js 里留了第四个后端。宿主只要挂一个对象:

window.GrapheonNative = {
  label: '本机文件夹',
  ready()                 -> bool
  list()                  -> [{ kind, name, size, id }]
  get(id)                 -> base64 字符串(或 null)
  put(kind, name, base64) -> id
  del(id)
}

二进制走 base64 过桥 —— 任何桥都能传字符串,不用为每种宿主单独写序列化。

它排在 fs 前面:能挂上宿主就说明是原生环境,那边对文件系统的控制比浏览器的 File System Access API 强得多(没有授权弹窗、能读写任意路径、手机上也能用)。

还缺的是宿主那边的实际实现:接口留好了,Electron 主进程 / Capacitor 插件还没写。

面向组件(已落地)

路线 B:内置行为保留现有字段,另加一层通用组件。 老的 1000 多条断言一条没改。

注册表

src/core/components.js 里声明每个组件「能挂在哪」「有哪些属性」「哪些属性可以引用变量」:

{ id:'badge', label:'角标', scopes:['node','edge','group'],
  props:[ { key:'text',  label:'文字', type:'text',  refable:true, def:'' },
          { key:'color', label:'颜色', type:'color', refable:true, def:'' } ] }

现有四个扩展组件:

组件 能挂在哪 作用
角标 节点 / 连线 / 分组 左下角贴一个小标签,文字和颜色都能引用变量
条件隐藏 节点 / 连线 / 分组 条件不是 0/false/关 就藏起来 —— 和「折叠」共用同一张 hidden 表
自定义描边 节点 / 分组 外框外面再套一圈,粗细和颜色可引用变量
线宽 连线 连线粗细

组件面板(C 键 / 右键「组件…」)

一个 UI 同时管节点、连线、分组 —— 选中什么就列出它能挂的所有组件。

  • 勾上就加、取消就删;属性随便填
  • 可引用的属性在输入框旁边实时显示算出来的值(图里 打折 {折扣率} → 打折 0.85)
  • 下面还有一段内置能力清单(只读):把「其实一直都在的」那些能力也当成组件列出来, 免得界面上一套、数据里另一套让人犯迷糊

引用变量的作用域规则完全一致

可引用的属性走的是和节点正文同一套解析:

interpolateIn(liveCtx(), 属性原文, refAnchorOf(entity, scope))

锚点:节点用它自己;连线用起点节点(和连线标签一致);分组优先用第一个成员 (这样分组在函数分组里时,作用域也算在那一层)。所以「全局 / 局内 / 函数分组隔离」 这些规则,在组件属性和节点正文里表现一模一样 —— 有断言直接对比两者的结果。

优先级也能引用变量

优先级从「纯数字」改成了可以填表达式:{倍率} 会在求值时插值再转数字, 解析不出来就退回该类型的默认值(变量 1000 / 输出 900 / 运算 100 / 其余 0)。

右键菜单里那份「优先级」子菜单删掉了 —— 它现在是组件,归组件面板管, 两处维护同一个东西迟早会走偏。

效果 vs 组件

这两件事是分开的,别混:

是什么 谁定的
效果 EFFECT 真正干活的那部分代码(画角标 / 判隐藏 / 改线宽…) 写死在程序里,共 6 个
组件 COMPONENT 一个有名字的东西,由一到多个效果拼成 内置的 4 个 + 用户自己建的

内置组件 = 单个效果的预置。用户组件 = 自己起名 + 挑几个效果拼起来 + 定作用域 —— 这才是「自定义」的意义:一次给实体加上好几样东西,而不是给同一个效果换个名字。

六个效果:badge 角标 / hideIf 条件隐藏 / outline 自定义描边 / width 线宽 / tint 染色 / opacity 透明度。

自定义组件

组件面板(C)里 →「自定义组件」→「新建组件…」:

  1. 起个名字
  2. 勾能挂在哪(节点 / 连线 / 分组)
  3. 勾要包含哪些效果;每个效果的属性可以填默认值(同样能写 {变量})

实例上场时,属性按 实例填的 > 组件声明里的默认值 > 效果自己的默认值 逐级兜底。

多效果的组件,实例属性按 0.text / 1.width 这样编号 —— 单效果的组件不带前缀, 所以老存档完全兼容。

定义跟着文档走:serialize() 会把用到的组件定义写进 .json。 把文件发给别人,他那边没有你的组件库也能正常显示(deserialize 时自动并进注册表)。

还没做的

  • 组件的拖拽排序(现在一个类型只能挂一个,顺序由注册表定)
  • 组件之间的依赖关系(比如「有 A 才显示 B」)

原始设计记录(路线 B,已确认)

内置行为保留现有字段,另加一层通用组件。

  • 已有的(外观 / 变量定义 / 运算 / 程序算符 / 输出 / 控件 / 优先级 / 分组)保持原样, 但在 UI 上统一呈现为「组件」
  • 新增 entity.components[] 给扩展组件用
  • 组件注册表 { id, label, scope:node|edge|group, props:[{key,type,refable}], apply() }
  • 所有 refable 的属性值都过一遍插值,fromId 用该实体自己的作用域 —— 所以引用作用域逻辑和该节点直接引用完全一致(直接复用 interpolateIn(liveCtx(), text, fromId))
  • 「优先级」这类现在是纯数字的,改成可填表达式(能写 {倍率})

选路线 B 是为了不砸现有的 1022 条断言,可以渐进落地。这一块还没开始动。

About

A mind map tools

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages