自定义元素

文档站里有几样东西在 Typst 里没有对应物 —— 折叠面板、卡片、可拖拽缩放的节点图、抽屉侧栏。它们都靠浏览器端的 web component:Typst 负责把数据写进 HTML,剩下的交给 JS。这一页讲所有这类元素的共同结构,以及为此付出的几笔代价。某一类元素自己的细节,比如节点图的 <svg> 层和 <foreignObject>,见节点图。

两类增强:只加样式的,和真造 DOM 的

不是所有增强都要 JS:本页只讲第二种。

一个壳,两份内容

自定义元素由 lib/lib.typ 里的 custom_element 包出来:
#let custom_element(register, ele) = {
    [#metadata(register) <custom-element>]
    ele
}
它发两样东西:
  1. 一条 <custom-element> 标签的 metadata,值是那个注册函数(下面再说它干嘛)
  2. ele 本身 —— 真正要出现在正文里的 HTML 元素
注册函数不是直接调用的,而是被收集起来统一执行:
for x in query(<custom-element>).dedup() {
    (x.value)();
}
template(lib/template.typ)在每页的 <head> 里跑这一句。于是注册的内容落进 <head>,注册函数本身不参与正文排版。.dedup() 是关键:同一页里可以有几十个 <ui-graph-node>,但它们的注册内容一字不差,去重后只剩一份。ui-graph 和 ui-graph-node 特意注册成同一份内容(lib/graph.typ 的 graph_element 两个元素共用)—— 这样同一页里图和单独摆的节点混着用,脚本也只引一次。

三层结构

一次 ui-card 展开是这样:所以 lib/card.typ 拆成两半:Typst 侧声明 <template> 的结构和属性名,JS 侧只管克隆和填事件:
const template = document.getElementById("card");
customElements.define("ui-card", class extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.appendChild(template.content.cloneNode(true));
    }
});
卡片里的标题栏和正文框靠 part 属性认领(querySelector('[part="header"]')),内容靠命名 <slot name="trigger"> 投影。part 也能从页面外用 ::part() 改样式 —— 和 ::slotted() 一样,都是穿透 shadow 边界的官方口子。
<template id="expand-panel">
  <div part="header"><slot name="trigger"></slot><div part="arrow">▼</div></div>
  <div part="content"><div part="inner"><slot name="content"></slot></div></div>
</template>
投影的名字走 light DOM 的 slot 属性(<div slot="trigger">)。这层间接不是多余的:节点图靠它让节点留在 light DOM 里被 <slot> 投进 shadow root,元素不搬家,ui-graph > ui-graph-node 这类选择器才照常命中。细节见节点图。

样式要写两份

shadow root 里的东西,页面样式表选不中 —— 样式隔离是特性不是缺陷。所以每个元素都有两份样式:只有 outline 是例外:nav.outline 和它的 ✕ 按钮都在 light DOM 里(outline.css / outline.js),页面样式能直接够到。shadow 挡的只是选择器,自定义属性照样继承。所以布局常量(--header-h)统一定义在 theme.css 里,shadow 内的 header.css 用 var(--header-h) 就能拿到 —— 顶栏在 shadow 里定高,两侧抽屉在 light DOM 里从它算偏移,两边对得上。

三笔代价

解析顺序:元素比子节点先到

脚本在 <head>,自定义元素在解析到开标签的那一刻就 constructor,此刻它自己的子节点还没进 light DOM。直接 querySelectorAll 只会拿到空数组 —— 而且不报错,只是图上什么都没有。所以两边都推迟到文档就绪再建:
if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => this.#build());
} else {
    this.#build();
}

定义之前得能看

没跑 JS(或脚本 404)时,自定义元素就是个没样式的空标签。要么让它在 :defined 之前保持 display: none,要么留一句 fallback 文字让它解释自己 —— graph.css 两样都做了:
&:not(:defined) > .graph-fallback { display: block; ... }
&:not(:defined) > ui-graph-node { display: none; }
:defined 是自定义元素独有的伪类,元素定义那一刻起规则自动失效。JS 建好图之后还会把 fallback 文字删掉。

非 HTML 目标要能编

站点也会被导出成 pdf(Typst 的其它 target)。html_elem 在 target() != "html" 时一律返回纯内容、不产生标签;custom_element 的注册函数压根不会被收集(它在 <head> 里)。于是 card / expand-panel 这类函数都得自己准备一份非 HTML 的替身,card_info 落成 note-me 的 note、expand-panel 落成一行 / #trigger: #con。

现有清单

下表是站点里全部自定义元素。写一个新的照着任一行抄:Typst 侧一个 custom_element,JS 侧一段 define,CSS 两份。
来源标签<template>JS / CSS
lib/card.typ<ui-card>id="card"card.js / card.css
lib/expand-panel.typ<expand-panel>id="expand-panel"expand-panel.js / expand-panel.css
lib/code_block.typ<code-block>id="code-block"code_block.js / code_block.css
lib/graph.typ<ui-graph> + <ui-graph-node>共用一份注册graph.js / graph.css
lib/template.typ<toc-component>无(fetch 整页)toc.js / toc.css
lib/template.typ<site-header>无(fetch 整页)header.js / header.css
两个细节别照抄错:还有一个元素用了 custom_element 却不是自定义元素:outline(lib/outline.typ)。它注册 outline.css / outline.js,但产出的是普通的 <nav> 和 <button>,JS 走 DOMContentLoaded + querySelectorAll 直接操作 light DOM。custom_element 在这里只当「往 <head> 里塞脚本和样式」用 —— 所以名字有点名不副实,用的时候看的是那个注册函数,不是 ele。