跳转到内容

编辑器集成

playground 是核心库的应用层集成。编辑器负责维护文档状态、协调不同有效期的编译结果,并将几何与播放计划接到浏览器界面和设备;它不定义另一套记谱语义。

本文先说明数据依赖与结果生命周期,再介绍 playground 的具体功能实现。VS Code 部分提供扩展点的接入建议,相关插件尚未实现。

编辑器功能分为词法层和语义层,分别使用解析器的两个入口,详见 解析。两条路径共用核心语法和函数声明,应用层只负责消费结果,不另写一套参数绑定或简写识别规则:

词法层语义层
入口analyzeScoreSyntax(source)compileScore(source, options)
触发每次按键,同步防抖(playground 是 180ms),与渲染共用
产出{ syntax, diagnostics }{ lineStarts, maskedSource, diagnostics, ast, lowering, layout }
出错只记诊断,不抛致命错误直接抛出,此时没有 AST
服务高亮、补全、自动格式化、括号、函数名悬浮预览渲染、去糖悬浮、诊断面板

词法层随输入同步更新。语义层结果在文档修改后失效,直到下一次编译完成才可用。接入其他前端时,也应区分这两条路径:高亮、补全等功能使用当前词法结果,依赖 AST 的功能则在语义结果过期时暂停或使用词法信息作为替代。

状态来源与有效期
源码与编辑选区编辑器文档模型,是应用的输入状态
token 与调用边界当前源码的词法结果,不依赖成功排版
AST、Lowering、布局与诊断一次完整编译的结果,不与其他源码版本混用
源码与谱面映射从同轮源码区间和最终几何建立,源码变化后立即失效
播放计划从成功编译的 Lowering 按需生成,同一源码版本复用
缩放、面板、混音与设备状态应用状态,不回写 AST 或改变核心记谱语义

结果有效期不仅影响预览更新,也决定能否执行定位、去糖替换和播放。旧谱面上的坐标不能与新源码的偏移混用;异步接入时,同样需要防止较早请求的结果覆盖较新的文档状态。

功能数据来源VS Code 对应
语法着色syntax.tokensDocumentSemanticTokensProvider
函数名补全defaultFunctions 的 defCompletionItemProvider(触发字符 @)
符号补全symbols.table.values() 的名称和说明CompletionItemProvider(触发字符 $)
参数名补全syntax.calls + def.args + resolveArgType同上
当前参数提示syntax.calls + resolveArgDefSignatureHelpProvider
标签补全参数类型 + syntax.tokens 里的 label同上
回车自动格式化syntax.tokensOnTypeFormattingEditProvider(触发字符 \n)
函数文档悬浮syntax.calls + defHoverProvider
去糖写法悬浮compileScore().ast同上
去糖替换node.toString(source)CodeActionProvider(见下)
音高变换transformNotes(source, compiled.lowering, operation)CodeActionProvider + WorkspaceEdit
标签跳转定义syntax.tokens + ASTLabelNode.targetDefinitionProvider
标签重命名syntax.tokensRenameProvider
诊断compiled.diagnostics + 抛出的错误DiagnosticCollection
谱面预览paintLayoutPages + RecordingPainter,按页重放到 SVG / CanvasWebview
源码 / 谱面双向定位object.ast.sourceSpan + box、attachment.sourceSpan + regionsWebview 消息 + TextEditor.selection
分页、缩放与打印layout.pages + 前端页面组装Webview / Webview 打印
实际播放compilePlayback(lowering) + Web Audio 适配器Webview
MIDI 导出PlaybackPlan.events + SMF 适配器Webview 下载
注释/括号languageDatalanguage-configuration.json

playground 将源码与播放、谱面与诊断组织为两组视图。视图切换只改变展示和按需计算时机,不改变文档内容;桌面拆分布局与移动端上下布局共用这些状态。

布局选择、编辑器宽度、主题等偏好保存在 localStorage 中,存储不可用时退化为会话内状态。编译状态与统计来自当前结果,播放状态来自独立的计划和调度器。持久化失败或设备不可用不应阻断源码编辑与排版。

源码工具栏的音高菜单提供转绝对音高、转相对音高、升半音和降半音。有选区时只处理被选区完整覆盖的音符,支持多个选区;没有选区时处理整篇。互转保持实际音高;升降半音保持原来的绝对或相对表示,全部调号不变。休止符、占位符和打击音不参与变换,颤音等派生音继续按原调号生成。

transformNotes 由 note 模块提供,读取同一源码版本的 lowering,返回基于原始偏移的 { span, text }[]。函数形式与简写形式保留,显式调用只修改音高参数值;低八度逗号需要保护时使用引号或大括号,例如 @n(C3) 转成 @n("1,"),@div(C3, 1) 转成 @div({1,}, 1)。默认参数只在当前音符局部覆盖,不修改 @set。

playground 在执行前确认编译结果未过期,候选源码编译成功后用一次 CodeMirror 事务提交全部 edits,并隔离前后的撤销历史。光标和选区沿修改映射,文件关联不变;文档变化继续触发现有的脏状态、预览与播放失效流程。输入法组合期间不执行变换,失败时保留当前文档。

syntax.tokens 是扁平的 { kind, span } 数组,条目互不重叠,并按起点升序排列。这些信息可用于实现 VS Code 的 DocumentSemanticTokensProvider。建议按下表映射 token 类型:

kindplayground CSS 变量VS Code semantic token type
comment--syntax-comment(斜体)comment
string--syntax-stringstring
function--syntax-function(粗)function
label--syntax-variablelabel
property--syntax-typeproperty
number--syntax-numbernumber
boolean--syntax-keywordkeyword
length--syntax-numbernumber
atom--syntax-number(粗)需自定义 type,或 number 加 modifier
operator--syntax-keywordoperator
punctuation--syntax-comment无标准类型,可省略

offset 对应原始源码位置。preprocessSource 将注释、续行转义和字符串外 CRLF 中的 CR 等长替换为空格,因此 span 可直接用于原文,无需额外的位置映射。

VS Code 的 semantic tokens 异步提供,首次打开文档时可能短暂没有颜色。可用一份只识别 % 注释和 @name 的最小 TextMate 语法提供初始高亮。完整高亮仍使用词法结果,以免重复维护语法定义。

playground 的补全由 jpfun-language.ts 中的 complete() 实现。它先确定光标所在的调用和参数角色,再生成候选;接受参数候选时则重新读取当前语法区间。候选过滤范围与实际替换范围不必相同。

callAt(calls, pos) 从当前 syntax.calls 找到最内层调用:这些调用按起点升序排列,且同级不重叠,所以最后一个命中的就是目标。再由 argumentIndexAt 和参数的 nameSpan、equalsSpan、valueSpan 区分参数位置、名字和值。

当前参数提示 parameterDocAt 用共享的 resolveArgDef(def, name, index) 取得完整声明,补全用 resolveArgType(def, name, index) 查询类型,均不直接以 def.args[index] 推断绑定。namedOnly: true 的声明不占位置编号,因此 @voices / @vs 的位置参数都是 content,@tie 的位置参数都是 label;它们不会被误判成 connect 字符串或 height 长度。

提示按同一份声明展示类型、必填状态和默认值。普通参数显示位置编号,额外位置参数显示 extraArgType;仅命名参数明确标为“仅命名参数”,没有位置序号。当前参数提示、完整函数文档、候选文档和文档站函数参数表遵循同一规则。

complete() 区分四类候选:

  1. 函数名:当前前缀以 @ 开头时列出函数,通过 snippetCompletion 插入 @name(),把光标放进括号。VS Code 可使用 SnippetString("name($0)") 和 triggerCharacters: ["@"]。
  2. 参数名:在可输入参数名的位置,从 def.args 中列出其他参数尚未使用的具名选项。编辑已有名称时不把当前参数排除,进入等号右侧后则停止提供参数名。
  3. 标签值:只有当前参数类型为 label,且光标处于位置参数或具名参数的值中,才加入文档中已声明的标签。@x 形式的 label token 是声明,@tie(x) 中的 x 是引用;编辑参数名本身时不混入标签候选。
  4. 符号名:当前前缀以 $ 开头时,从 symbols.table.values() 提供已注册符号名称和说明,直接插入 $name。符号注册需在加载编辑器语言模块前完成;候选列表只构建一次,后续输入由 CodeMirror 过滤。

content 需要额外区分“开始输入一个参数”与“正在输入内容”:仅在参数起点的空白或名称前缀处,提供匹配的仅命名选项。已经输入音符、内容块或其他内容时,外层参数名补全返回 null,不能把内容误当成名字。@、$ 前缀和嵌套调用仍走各自的补全逻辑。

下表中的 ▮ 表示光标,不是源码的一部分:

光标位置候选行为
@voices(@voice(1), con▮)提供仅命名参数 connect
@voices(1 ▮)、@voices({▮})不提供外层参数名
@voices(@voice(1), connect=▮)已进入字符串值,不再提供参数名
@voices(@voice(@n(▮)))根据最内层 @n 的声明提供候选
1@a 2@b @tie(a, ▮)保留标签候选,也可选择尚未使用的 height 参数
1@a 2@b @tie(a, b, hei▮ght=1em)只提供匹配的参数名,不加入标签候选

候选始终按光标前的前缀过滤,而不是按整个待替换名称过滤。所有参数候选共用一个 applyParameterCompletion 回调;接受时,它从最新的 syntaxField 重新查找调用和 nameSpan,不捕获生成候选时的旧区间:

  • 新参数还没有 nameSpan,就在补全区间插入 name=。
  • 已有命名参数则替换完整 nameSpan,保留原来的等号、两侧空白和值。

例如在 @voices(@voice(1), con▮nect = "[-]") 中选择 connect,只规范化整个名称,保留 = "[-]",不会留下名称后缀或再插入等号。在名称前、名称中或名称末尾接受补全,都遵循相同规则。

回调使用 CodeMirror 的 insertCompletionText 构造事务,并添加 pickedCompletion 标记,而不是手写单光标替换。这样既支持匹配的多光标编辑,也能在候选被缓存、输入导致位置映射后,按最新名称范围完成替换。内容位置的候选不设置可复用的 validFor,以便继续输入时重新判断是否仍处于参数名边界。

编辑器顶部的「自动格式化」复选框默认开启,取消勾选后回车只执行普通换行。开启时,按 Enter 正常换行并保留缩进,然后只为刚离开的上一行补空格;行中回车不会格式化移到下一行的内容。粘贴、导入和保存不会触发此功能。

格式化只补缺失的空格,不压缩已有空白、不改变行首缩进,也不修改字符串或注释内部:

输入回车后上一行
6#36 #3
1^31 ^ 3
1/21/ 2(减时线与附点仍紧贴前面的音符)
1,,21,, 2(低八度逗号属于音符,不拆开)
@up(1,3)@up(1, 3)(参数逗号只在后面补空格)
@div(6#3,1)@div(6 #3, 1)(内容参数内同样处理)
@tempo(120)不变(函数名、括号保持紧凑)
V{}:、V[]:、V|:不变(完整声明 token 不拆开)
@set(voices.connect="[1-2]{3-}")不变(字符串中的括号和范围不格式化)

实现位于 playground 的 insertFormattedNewline,复用 syntaxField 中已缓存的 syntax.tokens:按源码顺序扫描相邻的 atom / operator 边界及参数逗号 punctuation,不另写词法规则,也不依赖 AST 或修改 core。CodeMirror 的 insertNewlineAndIndent 负责换行、缩进和多光标;补空格与换行合并为一次 transaction,因此一次撤销即可还原。无需补空格时直接提交原换行 transaction,避免重复更新状态。

悬浮内容按以下顺序查找:

  1. 词法层:光标落在 call.nameSpan 上时,显示函数简介、别名、参数信息及 def.details 中的 Markdown 详细说明(示例、简写与特性)。这部分使用当前词法结果,不依赖 AST。
  2. 语义层:词法层未命中且 AST 未过期时,沿 sourceSpan 和 children 递归查找命中路径上最深的、带 def 的 ASTFunctionNode,先显示 node.toString(source) 生成的去糖等价写法,再显示文档。
1/ 的 / -> @div(@n(1, , 0, #000), 1)
1^3^5 的 ^ -> @up(@n(1,...), @n(3,...), @n(5,...)) // 扁平化后的真实结果
2>3 的 > -> @grace(@n(3,...), @n(2,...), side=pre)
| -> @bar(0, 22px)

去糖行只用于语法糖。若节点的 sourceSpan 与 syntax.calls 中某个条目的起止位置完全相同,则它是源码中的显式调用,去糖行与原文相同,无需重复显示。

仅检查源码切片是否以 @name 开头无法区分这两种情况:语法糖可能扩展 span,例如 @note(1) ^ @note(3) 产生的 UpFunction 切片仍以 @note 开头。

去糖替换使用命中节点的 node.sourceSpan 和 node.toString(source),不是只替换鼠标下的 token。对 V{}:、V[]:、V|:,核心词法结果将整个声明标记识别为一个 operator,后接的 N: 无论同行还是换行都继续独立着色;语义结果则让这些声明落在同一个 voices 节点的源码范围内。因此悬浮在声明上会显示整个连续声部块的去糖结果,替换也覆盖声明及成员,而非单独的 V 标记。

VoicesFunction.toString 将生效的连接设置显式写为末尾的 connect 参数:继承的预设被固定到输出中,局部声明输出自己的范围,只有 V|: 时也保留 connect=""。这些范围只影响视觉连接,输出仍是一个含全部直接声部的 @voices,不会生成分组函数或新音轨层级。编辑器不重新计算范围,也不依赖替换位置仍有原来的 @set 连接设置。

高亮和回车格式化只需要核心 token,不必等待这个语义节点出现;去糖悬浮则必须等待当前版本的 AST。用户尚未完成 V 声明或后续声部时,继续使用词法容错结果,不拿旧块执行替换。

去糖行单独显示,使用 --syntax-function 颜色和等宽字体;行尾提供「替换」按钮。等价写法不截断、不折行,超长内容在行内横向滚动。悬浮框整体有最大宽高限制,正文过长时由外层滚动;「替换」按钮在滚动时保持可见。

playground 自行控制 CodeMirror 悬浮框的关闭时机:悬浮框优先放在当前行上方,保留 4px 间距;鼠标离开 token 后延迟 250ms 关闭,进入悬浮框或其外扩 8px 区域时取消关闭。点击编辑器后,在鼠标再次移动前不会原地弹出悬浮框。这些数值由 playground 的交互设置决定。

VS Code 接入:Hover 使用 MarkdownString,不能放置交互式按钮。可在 Markdown 中提供 command: 链接(需要 isTrusted),也可用 CodeActionProvider 提供「替换为函数调用」的 refactor。后者更符合 VS Code 的操作习惯,也可针对选区批量提供替换。

Ctrl/Cmd + 点击标签引用时,光标跳到被标注对象,便于查看引用对应的音符或其他对象。按住修饰键时,可跳转的标签显示下划线和手型。只有引用支持跳转,声明、音符和注释中的同名文字不响应。

光标位于声明或引用上时,按 F2 可重命名该组标签。编辑器通过 EditorState.allowMultipleSelections 选中该组的所有出现位置,输入时同步修改,不显示额外弹窗。

跳转和重命名使用相同的作用域规则,与 parseArgWithType 的 label 分支一致:

一个引用绑定到所属函数调用之前最近的同名声明。

同一个标签名可以重复使用:

1@x 2@y @tie(x,y) 3@x 4@y @tie(x,y)

第一条延音线的 x 指向 1,因为 3@x 位于该调用之后;第二条的 x 指向 3,因为两个声明都在调用之前,而 3@x 更近。

因此,F2 按绑定到同一个声明的引用分组。跳转和重命名可共用声明查找逻辑:跳转查找引用绑定的声明,重命名收集绑定到该声明的全部引用。

词法层比较「声明起点 < 该调用的 span.start」,核心比较「被标注对象起点 < funcStart」。对有效源码,两者等价。只有函数调用位于对象与其标签之间时才可能产生差异,例如 1 @tie(x) @x,而该写法会触发 E_UNKNOWN_LABEL。

查找声明只使用词法层结果,随输入同步更新。只有从声明定位到被标注对象时,才需要读取 AST 中的 ASTLabelNode.target。编译失败或文档刚修改、AST 不可用时,跳转目标改为声明 @x 本身。F2 不依赖 AST。

定位被标注对象使用 target,不使用 parent。ASTLabelNode.parent 表示 AST 中的容器关系,可能在 ASTBraceNode 等容器构造时被改写;target 始终指向被标注节点。

跳转后,右侧谱面对目标音符显示与普通点击相同的波纹强调。playground 在跳转的 transaction 中加入 labelJumped effect,由 updateListener 识别后通知外层。

通知不依赖 click 事件,因为跳转在 mousedown 阶段拦截事件并调用 preventDefault,以阻止 CodeMirror 默认的「Ctrl+点击添加多光标」行为。此后浏览器不一定再生成 click。

VS Code 接入:DefinitionProvider 返回被标注对象的 Location,修饰键下划线、F12 和悬浮预览由编辑器提供。重命名使用 RenameProvider,将同组标签的 range 作为 WorkspaceEdit 返回;可通过 prepareRename 将范围限定在名字部分,不包含 @。

  • 非致命诊断包含警告和已处理、允许继续编译的错误,记录在 compiled.diagnostics 中,编译仍会完成。
  • 致命错误由 compileScore 抛出,捕获到的 Diagnostic 单独展示。单行页面溢出在布局入口转换为带 span 的 E_PAGE_OVERFLOW。其他非 Diagnostic 异常没有 span,前端用一条「排版中断」文本行表示。
  • Diagnostic 提供 code、message、span(offset)和 toLineCol(lineStarts)。没有编辑器时可用 lineStarts 转换行列;playground 直接使用 doc.lineAt(offset),VS Code 使用 document.positionAt(offset)。后两种方式也适用于致命错误,此时无法取得 compileScore 的 lineStarts。
  • 空 span(start === end)在显示时扩展为 1 个字符,使波浪线可见。

诊断标签显示总数和最高严重级别,面板显示错误与警告计数。带 span 的诊断行显示 行:列,点击后切回源码、选中对应区间并滚动到视口中央;无 span 的诊断行只显示文本。

analyzeScoreSyntax 也返回 diagnostics,例如未闭合调用、位置参数排在命名参数之后等。前端可自行决定是否在输入过程中展示这些诊断。playground 目前只展示编译路径的诊断,减少输入未完成时的错误提示。

只有致命错误会自动切换到诊断面板,下一次编译成功后切回预览。非致命诊断即使是 error 级别,也只更新标签徽标,谱面继续显示。compiled.diagnostics 中的 ErrorDiagnostic 都允许编译继续,因此不能仅凭错误级别决定是否切换面板。

播放诊断也区分是否阻止继续运行,但不会自动切换右侧谱面。PlaybackPlan.diagnostics 与排版诊断合并进入统一列表;warning 同时在播放页显示数量,不阻止播放。compilePlayback 抛出的带 span 的 Diagnostic 会禁用走带,并同时显示在诊断列表和播放页。只有用户点击播放页摘要时才切到诊断页。

远程脚本、AudioContext 和 MIDI 下载失败没有源码位置,只在播放页显示,不生成源码波浪线。

实时播放和 MIDI 导出共用 PlaybackPlan,但输出适配器不同:浏览器将事件转成声音,MIDI 将事件编码成文件。事件、增时线和连音的核心语义见 播放,这里说明前端如何消费这些数据。

首次切到播放标签,或用户点击 MIDI 导出时,playground 使用当前成功编译结果中的 lowering 调用 compilePlayback,生成 PlaybackPlan。同一源码版本复用计划。

播放标签保持打开时,源码变化会立即停止声音并使旧计划失效,下一次 compileScore 成功后重建计划。其他情况下,普通编辑和排版不会触发播放编译。

tiny-synth.ts 按需加载远程 tinySynth,打开播放页时提供 128 个 GM 风格的旋律音色,点击播放时才创建并恢复 AudioContext。

适配器沿 Tempo 事件将 QN 换算成秒,按 noteId 配对 NoteOn/NoteOff,得到待调度的音符。调度器每 100ms 补足约 1 秒的绝对 AudioContext 时间窗口。暂停时停止已排程节点;恢复播放、跳转或播放中调整速度倍率、移调、音色覆盖时,从当前位置重建窗口。

PlaybackPlan.tracks[index]、混音设置与 tinySynth 的 channel[index] 一一对应。这里的 channel 标识浏览器内的混音输出,MIDI 通道编号由导出适配器另行分配。音量、Mute 和 Solo 直接作用于原声部的输出增益,无需重建计划。

播放秒数通过 scoreMap 映射回原谱 QN。右侧 SVG/Canvas 共用 overlay,以半透明矩形标示当前时间列,并行声部合并成一个区域。跨行或跨页时,只在列变化时滚动。暂停保留矩形,停止或源码变化时清除矩形。

播放标签激活时,点击右侧有正时值的谱面对象会将进度移到该对象的记谱位置,不跳回源码。

X 与 9 等价,谱面均显示 X。它们产生真实的 NoteOn/NoteOff:NoteOn 携带 percussion: true、midi: 37 和原声部身份,NoteOff 通过 noteId 配对。37 在这里是 GM Side Stick 的打击键号;0/Z 和 8 仍不产生音符事件。

项目浏览器播放MIDI 导出
声音来源tinySynth 额外注册的单一木击近似音色接收端 GM 音源的 Side Stick,实际听感由音源决定
音色与键号内部音色槽 128,固定频率的短促波形;不加入乐器下拉框打击键 37,不导出内部音色槽 128
通道与归属保留原声部的 tinySynth channel,共用该声部增益所有打击音汇入额外的打击 MTrk,使用 MIDI channel 10(零基编号 9)
增时线X - - 只触发一次短包络,调度参数 last 固定为 25ms,尾音自然衰减保留延长后的 NoteOff 时刻,不把记谱时值改成 25ms
音色覆盖与移调忽略旋律 program、乐器选择和移调,打击音不变不向鼓通道写入旋律 Program Change,键号保持 37
原声部音量通过原声部 gain 控制,Mute/Solo 同样生效音量折算到每个打击 NoteOn 的 velocity;Mute/Solo 不参与导出

例如,X - - 1 中,X 在 QN=0 起音,NoteOff 在 QN=3,旋律音 1 也从 QN=3 开始。浏览器只在开头敲击一次,之后仍按三拍推进进度;MIDI 则保存完整的三拍事件对。打击音的物理衰减与记谱占用时值是两件事,增时线不会让短促音效反复响起。

前端为 tinySynth 补充了一个固定频率、快速衰减的合成音色,近似表现 Side Stick 的短促敲击,调用现有的 play(),复用调度、混音和 stopAll()。从 X 的延续区间中途 seek 或恢复播放时,不重新触发已经过去的敲击;回到起音位置则正常触发。

midi-export.ts 使用同一事件计划,按需加载远程 midi.js,导出 format 1 的 Standard MIDI File,固定为 480 PPQ:

  • conductor 轨保存 Tempo、拍号和曲末时长标记,因此尾部休止不会丢失;
  • 含旋律音的原声部各有一个 MTrk,保存旋律音符、Program Change 和音量 CC7;旋律通道在每个 port 中避开 channel 10,超过 15 个旋律通道时沿用 MIDI Port 元事件扩展;
  • 按需新增一条打击 MTrk,NoteOn/NoteOff 都按 noteId 路由到 channel 10。它只是导出文件的轨道,不会增加 PlaybackPlan.tracks 或播放页的声部数量。

多个原声部的 X 共用 MIDI channel 10,不能分别向这个共享通道写不同的 CC7。导出器将鼓通道的 CC7 固定为 127,再按各原声部音量缩放打击力度;音量为零时整对省略 NoteOn/NoteOff,不产生空鼓轨。MTrk 是文件中的轨道块,MIDI channel 是事件中的通道编号,两者独立分配。

播放页的设置是否影响导出,取决于设置本身:

设置浏览器播放MIDI 导出
按谱面 / 乐器覆盖普通音使用谱面 program,或整声部覆盖;X 除外同样决定旋律 Program Change;打击轨不写 program
声部音量修改原声部输出增益旋律写 CC7;打击音缩放 velocity
Mute / Solo决定各声部是否听得到忽略,不因静音或独奏筛除音符
速度倍率缩放播放时间,X 的短包络参数不变忽略,保留计划中的 Tempo 和记谱时值
移调只改变旋律音高忽略,保留计划中的音符键号

SMF 拍号分母只能是 2 的幂,分子必须在 1..255,三字节 Tempo 也有范围限制;超出范围时导出失败。音符键号和力度则由适配器整数化并限制到设备可接受的范围,不应把前述限制理解为所有数值都严格原样输出。

打击乐目前支持播放和导出。MIDI/MusicXML 导入器仍跳过鼓通道,导出的 X 再导入后会丢失。

tinySynth 与 midi.js 均从 https://madderscientist.github.io/noteDigger/lib/ 按需加载,不进入 npm/Vite bundle。网络加载失败只影响对应播放或 MIDI 操作,源码编辑、排版和图像导出继续可用。

compileScore 返回的 layout 可直接传入两个渲染后端:

renderLayoutPagesToSvg(layout, { padding, background }) // 返回分页 SVG 字符串数组
renderLayoutPagesToCanvas(layout, contexts) // 画到每页的 2D context

SVG 路径直接使用最终坐标,不生成 <defs>/<use>,以简化渲染结构。SVG 内容与缩放无关,缩放时只修改 style.width/height,无需重新生成。

renderLayoutPagesToCanvas 在内部按页执行 translate(-bounds.x, -bounds.y)。前端负责创建数量匹配的 context,并处理画布尺寸、devicePixelRatio 和缩放。

playground 使用「全量布局与指令缓存、按需创建绘图表面」:paintLayoutPages 一次将全曲绘制指令路由到各页 RecordingPainter,保留跨页元素和绘制层级;页面进入视口后,再重放到 SvgPainter 或 CanvasPainter。这减少 SVG 序列化、DOM 和 Canvas 像素缓冲开销,不减少全曲解析、布局和指令生成。

页面生命周期由 preview-pages.ts 统一管理:所有页保留尺寸占位,IntersectionObserver 以滚动容器为根、上下各预留 600px 缓冲区,绘制更新合并到一个 animation frame。离屏表面移除,Canvas 同时缩至 1 × 1 释放像素缓冲,指令缓存保留;源码和播放定位可临时绘制目标页,再由观察器接管。

图片导出复用同一源码版本的编译结果和完整分页指令缓存,不重新排版、调用布局对象的 paint 或读取预览 DOM。SVG 逐页序列化,PNG/JPEG 按导出 PPI 在独立 Canvas 上重放并编码,不受预览缩放和 devicePixelRatio 影响;导出不清空共享指令。位图导出持有启动时的页面快照,期间编辑不会混入新版本。源码已变化时,先编译更新,失败则中止导出。

VS Code 可使用 Webview 展示预览。jpfun 是纯 ESM,不依赖 DOM,因此既可在扩展主进程编译,通过 postMessage 将 SVG 字符串发送给 Webview,也可全部放在 Webview 中运行。在扩展主进程编译时,诊断与预览可以复用同一份编译结果。

playground 将每个 layout.pages[i].bounds 展示为独立纸张,纵向排列,保留固定页间距。工具栏提供:

  • 即时切换 SVG / Canvas 后端;
  • 缩放菜单中的「适合宽度」和 50%~200% 常用倍率,内部缩放范围为 25%~300%;
  • 默认适宽模式,按钮以 适合宽度:53% 的形式显示实时倍率;容器或页面宽度变化时自动调整,手动选择倍率或使用 Ctrl+滚轮 后退出适宽模式,只显示百分比;
  • 普通滚轮滚动页面,Ctrl+滚轮 以指针位置为锚点缩放。

多页缩放锚点保存为「具体页面 + 页面内归一化坐标」,避免将页间距计入缩放比例。缩放立即更新所有占位尺寸并恢复锚点;SVG 只更新已挂载根元素的 CSS 尺寸,Canvas 通过同一帧调度按最终缩放和 devicePixelRatio 重绘视口附近页。

发生致命错误时,当前编译结果被清空,之后缩放不会重新显示上一次成功的谱面。

源码与谱面共用一份不依赖渲染后端的映射:可见 Temporal 使用 ast.sourceSpan + box,最终 PlacedAttachment 使用 sourceSpan + regions。

除顶层 layout.objects 外,前端还从 lowering.astToTemporal 收集已完成布局的折叠成员,因此 grace/up 内部音符与各层复合体均可分别命中。regions 来自最终 attachment geometry,不包含试测轮的几何结果。自动 beam 的 span 取首末端点及其生效 div 作用域的并集。

函数包装只有在以下条件全部满足时,才会并入对象范围:仅有一个可见后代,且父 AST 本身既没有可见 Temporal,也没有独立 attachment。因此,1/ 的音符和减时线都对应完整的 1/;grace 操作符不会覆盖内部音符各自的 span;@box 仍通过自身 attachment 定位。

playground 只在鼠标点击源码或标签跳转时同步右侧谱面,键盘移动光标不触发同步。点击注释、空白或没有可见输出的声明时,清除强调,不选择附近对象。

文档修改后立即丢弃上一轮导航映射,防抖编译完成后再恢复,避免旧谱面使用新文档中已失效的 span。命中后只修改 previewScroll.scrollLeft/scrollTop,不通过 scrollIntoView 移动整个工作台;目标中心显示双层圆形波纹。

点击右侧谱面时,行为由左侧当前标签决定:

  • 源码标签激活时,第一次 click 将光标放到 span 起点;500ms 内同一位置的第二次 click 选中完整 span。第二次点击的选区处理在 document 捕获层完成,因此即使布局变化使点击落在其他 DOM 元素上,也能保留原 range。
  • 播放标签激活时,点击改为 seek 到对象的 score time,不改变编辑器选区。

命中测试先扣除纸张元素的 CSS 边框,再根据当前纸张的 bounds,将 SVG/Canvas 内容盒像素还原为全局布局坐标。随后在 region 外扩 6px 的范围内,选择面积最小、距离最近的目标。细 beam、tie 等关系图形因此优先于覆盖它们的大盒子,SVG 和 Canvas 无需分别维护 DOM source marker。

打印复用当前源码版本的编译结果与分页指令缓存,只有结果失效时才重新编译;将全部页面重放为白底 SVG,建立独立打印树。动态 @page size 与布局纸张尺寸一致;工作台在打印媒体中隐藏,每个 SVG 后强制分页。打印结束后,清空临时节点和样式。

若重新编译失败,打印树保持为空,不打印过期谱面。

playground 通过 EditorState.languageData 声明:

commentTokens: { line: "%" }
closeBrackets: { brackets: ["(", "{", "[", "\""] }

对应 VS Code 的 language-configuration.json(comments.lineComment、brackets、autoClosingPairs)。

playground 使用 Tab 接受补全,回车插入换行。VS Code 默认允许 Tab 和回车接受补全;要保持相同交互,可在 [jpfun] 作用域下设置 editor.acceptSuggestionOnEnter: "off"。

playground 还提供行号、括号匹配、多光标、搜索、Alt+Z 切换折行、选中空白可视化和 Ctrl+Enter 手动排版。接入 VS Code 时,可复用编辑器已有的通用编辑功能。

以下功能目前未实现:

  • 查找引用:尚不能从音符列出所有引用它的位置。F2 已计算绑定到同一个声明的引用,但没有提供引用列表 UI。
  • 语义高亮:尚未对未绑定标签、未知函数名等进行错误着色。这需要遍历 AST,仅靠 token 不足以判断。
  • 全文格式化和代码折叠:已有回车时为上一行补空格的功能,但不提供完整文档的格式化命令。
  • 增量解析:每次按键仍会重新扫描全文词法信息。若需要支持更大规模的乐谱,可先考虑按行或大括号边界进行区间重扫。节点复用还需处理两个问题:span 使用绝对 offset,且 AST 会改写 grammar 阶段的 span 对象。
  • @div(1)/ 这类语法糖作用于同名显式调用的写法,节点 span 没有扩展到尾部字符,仍为 [0,7),因此悬浮在 / 上时没有响应。
  • 文档修改后到下一次编译完成前,去糖悬浮不可用;词法层的函数文档悬浮不受影响。