Appearance
html块
HTML 块允许用户在思源笔记中嵌入自定义的 HTML 代码片段。这些代码片段的渲染和管理涉及以下几个关键部分:
1. HTML 块的识别与初步处理 (siyuan/app/src/protyle/render/htmlRender.ts)
当含有 HTML 块的文档在编辑器(Protyle)中渲染时,htmlRender.ts 中的 htmlRender 函数会被调用。它的主要职责是:
- 定位 HTML 块:该函数会检查传入的元素是否本身就是一个 HTML 块(
data-type="NodeHTMLBlock"),或者在传入元素的子节点中查找所有具有data-type="NodeHTMLBlock"属性的元素。这些元素代表了用户在笔记中插入的各个 HTML 块。 - 辅助功能增强:对于找到的每一个 HTML 块,
htmlRender会为其内部的编辑按钮和"更多"操作按钮添加aria-label属性。这些标签通常来自window.siyuan.languages对象,用于提供本地化的描述,以增强可访问性 (a11y)。
typescript
// siyuan/app/src/protyle/render/htmlRender.ts 示例片段
export const htmlRender = (element: Element) => {
let htmlElements: Element[] = [];
if (element.getAttribute("data-type") === "NodeHTMLBlock") {
// 编辑器内代码块编辑渲染
htmlElements = [element];
} else {
htmlElements = Array.from(element.querySelectorAll('[data-type="NodeHTMLBlock"]'));
}
if (htmlElements.length === 0) {
return;
}
htmlElements.forEach((e: HTMLDivElement) => {
// 假设 HTML 块内部结构,为特定子元素设置 aria-label
e.firstElementChild.firstElementChild.setAttribute("aria-label", window.siyuan.languages.edit);
e.firstElementChild.lastElementChild.setAttribute("aria-label", window.siyuan.languages.more);
});
};2. HTML 内容的实际渲染与管理 (siyuan/app/stage/protyle/js/protyle-html.js)
每个 HTML 块的实际内容展示和交互是由一个名为 <protyle-html> 的自定义 Web Component (Custom Element) 负责的。这个组件定义在 protyle-html.js 文件中。
html
<!-- HTML 块在 DOM 中的大致结构 -->
<div data-type="NodeHTMLBlock" data-node-id="20231026104500-abcdefg">
<protyle-html data-content="<p>用户输入的 HTML 内容</p>"></protyle-html>
</div><protyle-html> 组件的关键行为如下:
- Shadow DOM 隔离:它使用 Shadow DOM (
mode: 'open') 来渲染 HTML 内容。这有助于将 HTML 块的样式和行为与主文档隔离,避免意外的冲突。 - 内容来源 (
data-contentattribute):组件通过其data-content属性接收原始的 HTML 字符串。 - 构造与初始化 (
constructor):- 当组件实例创建时,它会读取
data-content属性的值。 - 为了处理特定情况 (如 GitHub Issue #11321),内容会经过
Lute.EscapeHTMLStr转义,然后重新设置回data-content属性,并作为初始内容填充到 Shadow DOM 中。
- 当组件实例创建时,它会读取
- 内容变更监听与处理 (
attributeChangedCallback):- 组件会监听
data-content属性的变化。当用户编辑 HTML 块导致此属性更新时,回调函数会执行。 - HTML 反转义:首先,使用
Lute.UnEscapeHTMLStr将data-content的值反转义回原始 HTML。 - 安全策略 (DOMPurify):
- 思源笔记默认不允许在 HTML 块中执行 JavaScript 脚本,以防止跨站脚本攻击 (XSS)。这是通过检查全局配置
window.siyuan.config.editor.allowHTMLBLockScript(注意这里的BLock可能是Block的笔误) 来控制的。 - 如果该配置为
false(默认),则 HTML 内容会经过DOMPurify.sanitize()处理。DOMPurify 是一个强大的 HTML 清理库,它会移除所有潜在的恶意代码,包括<script>标签、不安全的属性 (如onerror) 等,只保留安全的 HTML 结构和样式。
- 思源笔记默认不允许在 HTML 块中执行 JavaScript 脚本,以防止跨站脚本攻击 (XSS)。这是通过检查全局配置
- 内容注入:处理过的(可能已清理的)HTML 字符串会被赋给 Shadow DOM 的
innerHTML,从而在页面上渲染出来。
- 组件会监听
- 脚本执行的特殊处理:
- 即使用户通过设置开启了 HTML 块中的脚本执行 (
allowHTMLBLockScript为true),思源仍然对<script>标签有特殊的处理逻辑:- 解析脚本:组件会创建一个临时的
div,将 HTML 内容赋给其innerHTML,然后从中提取所有的<script>标签。 document.write的处理:如果某个<script>标签内的代码包含document.write(...),该脚本不会被执行。相反,思源会显示一个错误信息(window.siyuan.languages.htmlBlockError),并将该脚本内容包裹在一个<textarea>中展示给用户。这是因为document.write在 Shadow DOM 环境中或在文档加载完毕后执行,通常会导致非预期的行为或直接覆盖页面内容。- 安全脚本的执行:对于不包含
document.write的脚本,思源会动态创建一个新的<script>元素,将原始脚本的所有属性(如src,type等)和其文本内容复制到新脚本元素上。然后,这个新创建的<script>元素会被追加到<protyle-html>组件的 Shadow DOM 中。这种方式可以确保脚本在 Shadow DOM 的上下文中正确执行,并且能够访问和操作 Shadow DOM 内部的元素。
- 解析脚本:组件会创建一个临时的
- 即使用户通过设置开启了 HTML 块中的脚本执行 (
javascript
// siyuan/app/stage/protyle/js/protyle-html.js 中的 ProtyleHtml 类 (简化示例)
class ProtyleHtml extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
this.display = this.shadowRoot;
// 初始内容处理 (涉及 Lute.EscapeHTMLStr)
const content = Lute.EscapeHTMLStr(this.getAttribute('data-content'));
this.setAttribute('data-content', content);
this.display.innerHTML = content;
}
static get observedAttributes() {
return ['data-content'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'data-content') {
let dataContent = Lute.UnEscapeHTMLStr(this.getAttribute('data-content'));
if (!window.siyuan.config.editor.allowHTMLBLockScript) { // 检查配置
dataContent = DOMPurify.sanitize(dataContent); // 清理 HTML
}
this.display.innerHTML = dataContent; // 渲染到 Shadow DOM
// 处理 <script> 标签
const el = document.createElement('div');
el.innerHTML = dataContent; // 临时容器解析脚本
const scripts = el.getElementsByTagName('script');
let fatalHTML = '';
for (const script of scripts) {
if (script.textContent.indexOf('document.write') > -1) {
// 处理 document.write
fatalHTML += `<div>Error: document.write is not allowed...</div>`;
} else {
// 创建并执行安全脚本
const s = document.createElement('script');
for (const attr of script.attributes) {
s.setAttribute(attr.name, attr.value);
}
s.textContent = script.textContent;
this.display.appendChild(s); // 在 Shadow DOM 中执行
}
}
if (fatalHTML) {
this.display.innerHTML += fatalHTML;
}
}
}
}
customElements.define('protyle-html', ProtyleHtml);这种结合 htmlRender.ts 的预处理和 <protyle-html> Web Component 的沙箱化渲染及安全机制,使得思源笔记可以在支持用户自定义 HTML 的同时,尽可能保障编辑器的稳定性和安全性。
3. 在 HTML 块内脚本中获取块 ID (推荐方法)
当你在 HTML 块中运行 JavaScript 时,通常需要获取该 HTML 块自身所在的思源笔记块的 ID (即 data-node-id 属性)。一个更稳健的方法是使用一个自定义 HTML 元素作为脚本的"运行器"。这个自定义元素负责找到其所在的思源块,然后执行用户提供的脚本,并将块相关信息(如块ID和块DOM元素)传递给该脚本。
工作原理:
- 定义辅助自定义元素: 你需要在 HTML 块中首先定义一个自定义元素,例如
<siyuan-script-runner>。 - 查找宿主块: 这个自定义元素在其生命周期回调(如
constructor或connectedCallback)中,会调用一个辅助函数 (例如findHostBlock)。 findHostBlock逻辑:- 此函数从自定义元素实例 (
this) 开始。 - 它会递归地向上查找:
- 如果当前检查的元素是 ShadowRoot (
element.host存在),则转移到其宿主元素 (element.host)上继续查找。这能有效地"跳出"当前 Shadow DOM 到其宿主。<siyuan-script-runner>本身位于<protyle-html>的 Shadow DOM 中,所以这一步能帮助定位到<protyle-html>元素。 - 如果当前检查的元素不是 ShadowRoot (例如,已经是
<protyle-html>或其父元素),则检查其parentNode是否有data-node-id属性。
- 如果当前检查的元素是 ShadowRoot (
- 一旦找到带有
data-node-id的元素,即为目标宿主块。
- 此函数从自定义元素实例 (
- 执行用户脚本:
<siyuan-script-runner>找到宿主块后,会查找其内部特定类型的<script>标签 (例如<script type="text/siyuan-scope">),读取其内容,并通过new Function()等方式执行,同时将找到的宿主块元素 (hostBlock) 和块ID (blockId) 作为参数或作用域内变量传递给用户脚本。
完整示例:
以下代码可以直接粘贴到思源笔记的 HTML 块中进行测试。它首先定义了 <siyuan-script-runner> 元素,然后使用它来执行一个简单的脚本。
html
<div>
<!-- 步骤 1: 定义 siyuan-script-runner 自定义元素 -->
<script>
try {
const findHostBlock = (element) => {
try {
if (!element) { // 增加一个检查,防止element为null时继续递归
return undefined;
}
if (element.host) {
return findHostBlock(element.host);
}
if (
element.parentNode &&
element.parentNode.getAttribute &&
element.parentNode.getAttribute("data-node-id")
) {
return element.parentNode;
}
return findHostBlock(element.parentNode);
} catch (e) {
// console.error('findHostBlock internal error:', e); // 可选的错误处理
return undefined;
}
};
class SiyuanScriptRunner extends HTMLElement {
constructor() {
super();
// 一般情况下,这个runner本身不需要显示
// this.style.display = 'none';
const hostBlock = findHostBlock(this);
const userScriptTag = this.querySelector('script[type="text/siyuan-scope"]');
const runnerElement = this; // <siyuan-script-runner> 元素实例本身
if (hostBlock && userScriptTag && userScriptTag.textContent) {
const blockId = hostBlock.getAttribute('data-node-id');
try {
// 通过 new Function 将 hostBlock, blockId 和 runnerElement 注入到用户脚本的作用域
const userFunction = new Function('hostBlock', 'blockId', 'runnerElement', userScriptTag.textContent);
userFunction(hostBlock, blockId, runnerElement);
} catch (e) {
console.error("Error executing script in SiyuanScriptRunner:", e, {
scriptContent: userScriptTag.textContent,
hostBlock,
blockId
});
}
} else {
if (!hostBlock) {
console.warn("SiyuanScriptRunner: Could not find the host Siyuan block element.");
}
if (!userScriptTag) {
console.warn("SiyuanScriptRunner: Could not find a <script type=\"text/siyuan-scope\"> tag inside.");
} else if (!userScriptTag.textContent) {
console.warn("SiyuanScriptRunner: The <script type=\"text/siyuan-scope\"> tag is empty.");
}
}
}
}
// 使用 try-catch 防止重复定义错误
if (!customElements.get('siyuan-script-runner')) {
customElements.define('siyuan-script-runner', SiyuanScriptRunner);
}
} catch (e) {
console.error("Error defining SiyuanScriptRunner:", e);
}
</script>
<!-- 步骤 2: 使用 siyuan-script-runner 执行你的脚本 -->
<siyuan-script-runner>
<script type="text/siyuan-scope">
// 在这个脚本中, 'hostBlock', 'blockId', 和 'runnerElement' 变量是自动可用的
console.log('My Siyuan Block ID is:', blockId);
console.log('My Siyuan Host Block element:', hostBlock);
console.log('My Siyuan Script Runner element:', runnerElement);
// 示例: 在块内显示块ID (将内容附加到 runnerElement)
const p = document.createElement('p');
p.textContent = '此脚本在思源块 [ID: ' + blockId + '] 中运行。输出到 runnerElement。';
p.style.color = 'darkgreen';
p.style.fontSize = 'small';
// 将创建的元素附加到 <siyuan-script-runner> 元素内部
runnerElement.appendChild(p);
// 如果需要,也可以直接操作 hostBlock 内的其他内容,但需谨慎
// 例如: hostBlock.querySelector('.some-target-within-block')?.appendChild(anotherElement);
</script>
</siyuan-script-runner>
<!-- 你可以在这里添加HTML块的其他内容 -->
<div>这是HTML块中的一些其他静态内容。</div>
</div>使用说明:
- 定义
siyuan-script-runner: 将上述代码中的第一个<script>块 (包含findHostBlock和SiyuanScriptRunner类的定义) 放置在你的 HTML 块的较早位置。它只需要定义一次。 - 包裹你的脚本: 将你自己的逻辑代码放在
<siyuan-script-runner>标签内部,并确保你的<script>标签类型为type="text/siyuan-scope"。 - 访问变量: 在你的
text/siyuan-scope脚本中,你可以直接使用hostBlock(指向包含data-node-id的 DOM 元素) 和blockId(字符串形式的节点ID) 这两个变量。
这种方法更加健壮,因为它不依赖于脚本自身在 DOM 中的特定层级,而是主动去查找其执行环境的根源,并且能正确处理 Shadow DOM 的边界。