---
title: "FusionSurface 融合表面"
description: "边上长出凸起、凸起可拉断成水滴的圆角表面"
category: Effects
status: beta
since: 0.6.0
tags: [fusion, surface, split, svg, spring]
syncStatus: reviewed
verified: true
---

## 安装

:::TuffCodeBlock{lang="bash"}
---
code: |
  pnpm add @talex-touch/tuffex
---
:::

:::TuffCodeBlock{lang="typescript"}
---
code: |
  import { TxFusionSurface } from '@talex-touch/tuffex/fusion-surface'
  import '@talex-touch/tuffex/fusion-surface/style.css'
  import '@talex-touch/tuffex/base.css' // 设计令牌与重置样式，全应用引入一次
---
:::

## 用法

### 工具栏托盘
`id` 不变时改 `center`，凸起滑到新位置；从 `buds` 移除，凸起原地收回。
:::TuffDemoWrapper{demo="FusionSurfaceTrayDemo" code-lang="vue"}
---
code: |
  <script setup lang="ts">
  import type { FusionSurfaceBud } from '@talex-touch/tuffex/fusion-surface'
  import { computed, ref } from 'vue'

  // 各工具在工具栏上的中心位置（px）
  const TRAYS = { draw: 106, shapes: 146 }
  const open = ref<keyof typeof TRAYS | null>(null)
  const shown = ref<keyof typeof TRAYS>('shapes')

  const buds = computed<FusionSurfaceBud[]>(() => open.value
    ? [{ id: 'tray', center: TRAYS[open.value], width: 146, height: 44, radius: 14 }]
    : [])

  function toggle(tool: keyof typeof TRAYS) {
    open.value = open.value === tool ? null : tool
    shown.value = tool
  }
  </script>

  <template>
    <TxFusionSurface
      :buds="buds"
      :radius="18"
      stroke="var(--tx-border-color-lighter)"
      shadow="var(--tx-elevation-4)"
    >
      <div class="tools">
        <button type="button" :aria-expanded="open === 'draw'" @click="toggle('draw')">画笔</button>
        <button type="button" :aria-expanded="open === 'shapes'" @click="toggle('shapes')">形状</button>
      </div>
      <template #bud>
        <!-- `shown` 的选项 -->
      </template>
    </TxFusionSurface>
  </template>
---
:::

### 分裂成水滴
`detach` 越过断裂点后颈部断开，凸起变成水滴，残留缩回边里。
:::TuffDemoWrapper{demo="FusionSurfaceSplitDemo" code-lang="vue"}
---
code: |
  <script setup lang="ts">
  import type { FusionSurfaceBud } from '@talex-touch/tuffex/fusion-surface'
  import { computed, ref } from 'vue'

  const sent = ref(false)
  const detach = ref(0)

  // 208：320px 宽的输入框上，凹角仍落在直边时凸起能到的最右位置
  const buds = computed<FusionSurfaceBud[]>(() => sent.value
    ? [{ id: 'message', center: 208, width: 168, height: 36, radius: 12, detach: detach.value }]
    : [])

  function send() {
    sent.value = true                            // 凸起从上边长出
    setTimeout(() => (detach.value = 22), 420)   // 颈部拉长、收窄
    setTimeout(() => (detach.value = 44), 940)   // 越过 breakAt：断开
  }
  </script>

  <template>
    <TxFusionSurface :buds="buds" stroke="var(--tx-border-color-lighter)">
      <span class="draft">今晚就发布</span>
      <button type="button" aria-label="发送" @click="send" />
      <template #bud>
        <span class="bubble">今晚就发布</span>
      </template>
    </TxFusionSurface>
  </template>
---
:::

### 四条边
`edge` 选择长出的边；贴近拐角的凸起会被夹紧到直边上。
:::TuffDemoWrapper{demo="FusionSurfaceEdgesDemo" code-lang="vue"}
---
code: |
  <script setup lang="ts">
  import type { FusionSurfaceBud } from '@talex-touch/tuffex/fusion-surface'
  import { computed, ref } from 'vue'

  const position = ref(50) // 在每条边上的百分比位置
  const height = ref(32)

  // 主体 220 × 132
  const buds = computed<FusionSurfaceBud[]>(() => [
    { id: 'top', edge: 'top', center: position.value * 2.2, width: 64, height: height.value, radius: 12 },
    { id: 'right', edge: 'right', center: position.value * 1.32, width: 40, height: height.value, radius: 12 },
    { id: 'bottom', edge: 'bottom', center: position.value * 2.2, width: 64, height: height.value, radius: 12 },
    { id: 'left', edge: 'left', center: position.value * 1.32, width: 40, height: height.value, radius: 12 },
  ])
  </script>

  <template>
    <TxFusionSurface :buds="buds" :radius="18" style="width: 220px; height: 132px" />
    <TxSlider v-model="position" :min="0" :max="100" aria-label="沿边位置" />
    <TxSlider v-model="height" :min="4" :max="40" aria-label="凸起高度" />
  </template>
---
:::

### 最佳实践

- 同一条边上的凸起不要重叠：每个凸起占 `width` 加两侧凹角。
- 移动凸起时保持 `id` 不变；换 `id` 会收回旧凸起、另长一个新的。
- 重播分裂时先关闭凸起（`open: false` 或移出 `buds`），或换一个新 `id`；调小 `detach` 只会把水滴移回来。
- 根节点不设 `background`、`border`、`box-shadow`，也不裁剪（`overflow: hidden`、`clip-path`、`contain: paint`）；凸起不占布局空间，父级要为它留出 padding。
- 颜色用 `var(--tx-…)` token 以跟随主题；阴影交给 `shadow`，不写在子元素上。

## API 参考

### 属性

| 属性名 | 类型 | 默认值 | 说明 |
|------|------|---------|------|
| `buds` | `FusionSurfaceBud[]` | `[]` | 要长出的凸起，字段见「类型」；移除的凸起先收回再卸载内容。 |
| `radius` | `number` | `16` | 主体圆角，不超过短边一半；也是凸起外角的默认值。 |
| `fillet` | `number` | `12` | 凸起与主体相接处凹角的最大半径，不超过凸起当前高度的一半。 |
| `breakAt` | `number` | `28` | 颈部完全闭合时的 `detach`；约在其 96% 处断开。 |
| `fill` | `string` | `var(--tx-bg-color-overlay)` | 表面填充色。 |
| `stroke` | `string` | `none` | 描边颜色，沿每个凸起、颈部和水滴走。 |
| `strokeWidth` | `number` | `1` | 描边宽度（px）。 |
| `shadow` | `string` | `var(--tx-elevation-3)` | `box-shadow` 语法，画成剪影上的 `drop-shadow()`；`'none'` 去掉阴影。 |
| `transition` | `'snappy' \| 'smooth' \| 'bouncy' \| SpringConfig` | `'smooth'` | 凸起运动的弹簧；没有时长写法，换目标时保留速度。 |
| `contentBlur` | `number` | `6` | 凸起内容展开时的起始模糊（px）；`0` 只保留淡入。 |

### 事件

| 事件名 | 参数 | 说明 |
|------|------|------|
| `break` | `(id: string)` | 凸起颈部断开的那一帧触发；每次分裂只触发一次。 |
| `settle` | `()` | 所有弹簧静止、帧循环休眠时触发；无需运动的变化不触发。 |

### 插槽

| 插槽名 | Props | 说明 |
|------|-------|------|
| `default` | - | 主体内容，可交互，画在剪影之上。 |
| `bud` | `{ bud: FusionSurfaceBud }` | 单个凸起的内容层（带 `data-bud`），随凸起或水滴移动；关闭时为 `inert`。 |

### 类型

`FusionSurfaceBud`，即 `buds` 的一项：

| 字段 | 类型 | 默认值 | 说明 |
|------|------|---------|------|
| `id` | `string` | - | 跨更新的身份标识，必填。 |
| `edge` | `'top' \| 'right' \| 'bottom' \| 'left'` | `'top'` | 长出的边；改边时在新边上重新长出，不绕过拐角。 |
| `open` | `boolean \| number` | `true` | 展开或关闭，或 0..1 的展开比例。 |
| `center` | `number` | 边的中点 | 沿边位置（px），上 / 下边从左量，左 / 右边从上量；会被夹紧。 |
| `width` | `number` | - | 沿边尺寸（px），必填；超出直边时收窄。 |
| `height` | `number` | - | 完全展开时向外的尺寸（px），必填。 |
| `radius` | `number` | 表面的 `radius` | 凸起外角半径，受自身尺寸限制。 |
| `detach` | `number` | `0` | 拉离主体的距离（px）；越过断裂点后变成水滴。 |
| `drift` | `number` | `0` | 被拉出部分的横向偏移（px），上 / 下边向右、左 / 右边向下为正。 |

:::TuffCodeBlock{lang="ts"}
---
code: |
  import type {
    FusionSurfaceBud,
    FusionSurfaceEdge, // 'top' | 'right' | 'bottom' | 'left'
    FusionSurfaceEmits,
    FusionSurfaceProps,
    FusionSurfaceTransition, // 'snappy' | 'smooth' | 'bouncy' | SpringConfig
    TxFusionSurfaceInstance,
    // fusionSurfacePath() 用到的类型，见「几何函数」
    FusionSurfaceBudShape,
    FusionSurfaceGeometry,
    FusionSurfaceGeometryInput,
    FusionSurfaceRect,
    FusionSurfaceSpan,
    FusionSurfaceSplit,
  } from '@talex-touch/tuffex/fusion-surface'
---
:::

### CSS 变量

| 变量 | 说明 |
|------|------|
| `--tx-fusion-surface-fill` | 剪影填充，由 `fill` 写入。 |
| `--tx-fusion-surface-stroke` | 描边颜色，由 `stroke` 写入。 |
| `--tx-fusion-surface-stroke-width` | 描边宽度，由 `strokeWidth` 写入。 |
| `--tx-fusion-surface-filter` | 剪影的 `filter`，由 `shadow` 写入。 |
| `--tx-fusion-surface-progress` | 每帧写在凸起内容层上的展开程度（`0`..`1`），供 `bud` 插槽读取。 |

前四个只在传入对应属性时写入，未传时可由祖先元素或主题设置。

## 几何函数

`fusionSurfacePath()` 是组件的几何部分，纯函数、不依赖 Vue 与 DOM；宿主不能改成 `TxFusionSurface` 时，用它在宿主上方画凸起、颈部和水滴。

:::TuffCodeBlock{lang="ts"}
---
code: |
  import type { FusionSurfaceSplit } from '@talex-touch/tuffex/fusion-surface'
  import {
    FUSION_SURFACE_BREAK_PINCH,
    fusionSurfacePath,
    fusionSurfacePinch,
    springSteps,
  } from '@talex-touch/tuffex/fusion-surface'

  const overlayPath = document.querySelector<SVGPathElement>('#composer-overlay path')!
  let detach = 0
  let velocity = 0
  let split: FusionSurfaceSplit | null = null

  function frame(dt: number) {
    // 与组件相同的弹簧，换目标时保留速度。
    ;[detach, velocity] = springSteps(detach, velocity, 44, 'smooth', dt)
    const pinch = fusionSurfacePinch(detach, 28)
    // 像组件一样锁存断裂（组件还会把 detach 限制在断裂点）。
    if (!split && pinch >= FUSION_SURFACE_BREAK_PINCH)
      split = { center: 208, width: 168, height: 36, detach, drift: 0, remnant: 1, tail: 1 }
    // …之后用 'snappy' 弹簧把 split.remnant 和 split.tail 降到 0。

    const { d } = fusionSurfacePath({
      width: 320,
      height: 48,
      radius: 16,
      includeBody: false, // 主体由宿主自己绘制
      buds: [{ id: 'message', center: 208, width: 168, height: 36, detach, pinch, split }],
    })
    overlayPath.setAttribute('d', d)
  }
---
:::

`includeBody: false` 的路径在宿主内闭合；要描边时先裁掉宿主内的部分，并沿 `spans` 断开宿主自己的边框。

### 输入

`FusionSurfaceGeometryInput`：

| 字段 | 类型 | 默认值 | 说明 |
|------|------|---------|------|
| `width` / `height` | `number` | - | 主体尺寸（px），两者都为正才绘制。 |
| `radius` | `number` | `16` | 主体圆角，不超过短边一半。 |
| `buds` | `FusionSurfaceBudShape[]` | `[]` | 当前这一帧的凸起。 |
| `includeBody` | `boolean` | `true` | 为 `false` 时只输出凸起、颈部和水滴。 |
| `baseOverlap` | `number` | `2` | `includeBody: false` 时相连形状伸进主体的距离（px），用来盖住宿主边框。 |

`FusionSurfaceBudShape` 的其余字段与 `FusionSurfaceBud` 相同，但描述当前帧而不是目标：

| 字段 | 类型 | 默认值 | 说明 |
|------|------|---------|------|
| `height` | `number` | - | 当前向外的高度，从 0 增长即展开；小于 0.5 px 不绘制。分裂后是水滴在 `split.scale` 之前的高度。 |
| `fillet` | `number` | `12` | 凹角的最大半径。 |
| `pinch` | `number` | `0` | 颈部收窄程度（0..1）；为 1 时收成一点。 |
| `split` | `FusionSurfaceSplit \| null` | `null` | 颈部断开后设置；此后忽略 `pinch`。 |

`FusionSurfaceSplit`：

| 字段 | 类型 | 默认值 | 说明 |
|------|------|---------|------|
| `center` / `width` / `height` / `detach` / `drift` | `number` | - | 断开那一帧的凸起；之后移动凸起只移动水滴。 |
| `remnant` | `number` | - | 残留缩回主体边的进度，从 1 降到 0。 |
| `tail` | `number` | - | 水滴尖尾收圆的进度，从 1 降到 0。 |
| `scale` | `number` | `1` | 水滴绕自身中心的缩放；关闭分裂的凸起时组件把它降到 `0`。 |

### 输出

`FusionSurfaceGeometry`：

| 字段 | 类型 | 说明 |
|------|------|------|
| `d` | `string` | 顺时针闭合子路径（保留 2 位小数）：先是并入相连凸起的主体，再是每滴水滴；不会出现 `NaN` 或 `Infinity`。 |
| `spans` | `FusionSurfaceSpan[]` | `{ id, edge, from, to }`：相连凸起（或残留）与主体边的接合区间，宿主边框在此断开。 |
| `rects` | `FusionSurfaceRect[]` | `{ id, edge, x, y, width, height }`：凸起（分裂后为水滴）当前的外框，组件据此摆放 `bud` 内容。 |

### 辅助导出

- `fusionSurfacePinch(detach, breakAt)`：组件的颈部曲线，`breakAt` 的前四分之一为 0，之后缓入到 1。
- `FUSION_SURFACE_BREAK_PINCH`：`0.985`，组件锁存分裂时的收窄程度。
- `springSteps(position, velocity, target, config, dt)`：组件的弹簧积分器；`dt` 以秒计，返回 `[position, velocity]`。

## 概述

- 剪影是根节点内 `z-index: -1` 的 SVG，`pointer-events: none` 且 `aria-hidden`，位于插槽内容之下。
- 加入 `buds` 的凸起原地长出；移除的凸起先收回，期间内容保持挂载并为 `inert`。
- `open`、`center`、`width`、`height`、`detach`、`drift` 各是带速度的弹簧，换目标时转向而不重来。
- 分裂会锁存：调小 `detach` 不会重新连上；关闭分裂的凸起时，水滴连同内容绕自身中心缩小。
- `shadow` 跳过 `inset` 和带扩展半径的层；`var()` 层原样透传，只能含一层（如 `--tx-elevation-*`）。
- 减少动态效果时（实时跟随），弹簧一帧到位，越过断裂点仍触发 `break`。

## 技术实现

- 每个表面一个 `requestAnimationFrame` 循环：逐帧积分共享弹簧（`liquid/src/spring.ts`），重算一条锐利的 SVG 路径，并直接写入凸起内容层的样式；静止后休眠。
- 相连凸起参照 [uiarc.dev](https://uiarc.dev/) 的 Dock，只借鉴观察到的手法，未使用其代码；颈部是本库自己的轮廓模型。
- 源码：`packages/tuffex/packages/components/src/fusion-surface/`。

<TuffDocSourceLink />

## 使用场景

- 工具栏或 Dock：工具从栏上长出选项托盘、子菜单或提示。
- 面板从边上长出气泡或徽标，再让它脱离。
- 发送消息：输入框把消息拉断，飘进会话；宿主自绘时用 `fusionSurfacePath()`。

## 相关组件

| 组件 | 用途 |
|------|------|
| [Fusion 交融](/docs/dev/components/fusion) | 两个插槽经 goo 滤镜交融 |
| [Liquid 液态流体](/docs/dev/components/liquid) | 自由移动的块像水滴般融合 |
| [BorderBeam 流光边框](/docs/dev/components/border-beam) | 沿边框游走或呼吸的光束 |
