react-okr-tree Logoreact-okr-tree
指南

Demo 总览

24 个可交互用例,逐条对应源项目 playground 的演示场景;源码由构建期读取真实组件文件生成。

这 24 个用例与源项目 playground/components/demos/ 一一对应(编号见开发计划的阶段 8 契约表), 用来做「特性是否对齐」的人工核对。每个用例下面折叠的源码就是这一块正在跑的组件文件本身<DemoBlock> 在构建期用 readFileSync 读它,再用 shiki 高亮成 HTML,所以文档里的代码与页面里 跑着的代码不会各写一份再漂移,浏览器也不为此背一个 shiki。

数据全部来自 components/demo/data.ts 的工厂函数——每次调用返回新副本。原因在 数据变更与源数据回写append / remove 这类方法会写回你传进来的源数据, 多个用例共用一份会互相污染。

布局与展开

xxx科技有有限公司
产品研发部
研发-前端
研发-后端
UI 设计
销售部
销售一部
销售二部
财务部

基础用法(垂直)

只传 data,默认 direction="vertical"

查看源码
'use client'

import { useMemo } from 'react'
import { OkrTree } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'

/**
 * 基础用法(对应源项目 playground/components/demos/Base01.vue)
 *
 * 每个 demo 文件都是「只放活组件」的客户端组件:标题、说明与源码由 MDX 页面交给
 * `<DemoBlock>`(源码由它按 `file` 读本文件,不在这份代码里再抄一遍)。
 *
 * `useMemo` 是必需的而不是习惯问题:data 换引用会触发 store 全量重建(requirements R2),
 * 用户刚点开的展开态会被冲掉。
 */
export function BasicDemo() {
  const data = useMemo(baseData, [])

  return <OkrTree data={data} />
}
xxx科技有有限公司
产品研发部
研发-前端
研发-后端
UI 设计
销售部
销售一部
销售二部
财务部

水平方向

direction="horizontal":同层节点竖排成一列,整体向右生长,这是组织架构图常用的形态。

查看源码
'use client'

import { useMemo } from 'react'
import { OkrTree } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'

/**
 * 水平方向(对应源项目 playground/components/demos/Base02.vue)
 *
 * `direction="horizontal"` 下同层节点竖排成一列、整棵树向右生长,是组织架构图的常用形态;
 * 不传则为 `vertical`(向下生长)。
 *
 * 两个与方向相关的约束:
 * - OKR 模式的 `onlyBothTree`(子树在根节点左右两侧展开)只在 horizontal 下有效,
 *   垂直方向配它会在开发期给一次警告。
 * - `direction` 是创建期快照的 prop,运行时换值不会重排已有的树(React 侧要换方向
 *   请给组件绑 `key` 重挂载);Vue 版同样是创建期生效,只是宿主改 `:data` 时会顺带重建。
 */
export function HorizontalDemo() {
  const data = useMemo(baseData, [])

  return <OkrTree data={data} direction="horizontal" />
}

是否可展开

showCollapsable 打开圆盘才有收起动作;不打开时组件强制全部展开(原版既有行为)。

查看源码
'use client'

import { useMemo, useState } from 'react'
import { OkrTree } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'

/**
 * 节点是否可展开(对应源项目 playground/components/demos/Base03.vue)
 *
 * 三个开关对比着看:
 * - `showCollapsable` 关掉时根本不给收起的动作,组件**强制全部展开**(原版既有行为),
 *   所以它是另外两个前提:圆盘不出现,`defaultExpandAll` 与 `showNodeNum` 都无从谈起。
 * - `defaultExpandAll` 只是「默认」而不是「受控」:一进来全展开,用户照样能逐个收起。
 * - `showNodeNum` 在折叠的圆盘里显示子节点数(只算通过过滤的可见子节点)。
 *
 * 圆盘打开后键盘操作与源项目一致:Tab 进入树,→ 展开 / 进入子节点,← 收起 / 回到父节点。
 *
 * React 与 Vue 的一处差别:`showCollapsable` 与 `showNodeNum` 运行时换值会立刻重绘(组件对
 * 这两类 prop 做了全树通知),但 `defaultExpandAll` 是**初始态**语义——它只在节点创建时决定
 * 展开状态,运行时换值不会把已经手动收起的节点再摊开。所以本例只把它拼进 `key` 触发重挂载,
 * 让「默认全展开」这一档可以反复重放。Vue 版同样不会重放初始值,只是那边一般直接重挂组件。
 */
export function CollapsableDemo() {
  const data = useMemo(baseData, [])
  const [showCollapsable, setShowCollapsable] = useState(true)
  const [defaultExpandAll, setDefaultExpandAll] = useState(false)
  const [showNodeNum, setShowNodeNum] = useState(false)

  const toggles = [
    ['showCollapsable', showCollapsable, setShowCollapsable],
    ['defaultExpandAll', defaultExpandAll, setDefaultExpandAll],
    ['showNodeNum', showNodeNum, setShowNodeNum],
  ] as const

  return (
    <div>
      <div className="mb-3 flex flex-wrap gap-2 text-sm">
        {toggles.map(([name, on, set]) => (
          <button
            key={name}
            type="button"
            aria-pressed={on}
            onClick={() => set(!on)}
            className={`rounded-lg border border-fd-border px-3 py-1 ${
              on ? 'bg-fd-accent text-fd-accent-foreground' : 'text-fd-muted-foreground'
            }`}
          >
            {`${name}=${on}`}
          </button>
        ))}
      </div>
      <OkrTree
        key={`expand-all-${defaultExpandAll}`}
        data={data}
        direction="horizontal"
        showCollapsable={showCollapsable}
        defaultExpandAll={defaultExpandAll}
        showNodeNum={showNodeNum}
      />
    </div>
  )
}
xxx科技有有限公司
产品研发部
研发-前端
研发-后端
UI 设计
销售部
销售一部
销售二部
财务部

默认全部展开

defaultExpandAll。需要一进来就全展开时用它,不要挂载后再调 expandAll()——后者要走一次整树重渲染。

查看源码
'use client'

import { useMemo } from 'react'
import { OkrTree } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'

/**
 * 默认全部展开(对应源项目 playground/components/demos/Base04.vue)
 *
 * `defaultExpandAll` 需要和 `showCollapsable` 一起用:不开圆盘时组件本身就是全展开的
 * (没有收起的入口),开了圆盘又不开这个 prop,进来的默认态是「只见根节点」。
 *
 * 它是「默认态」而不是「锁死全展开」:挂载后用户照样能逐个收起。
 * 也正因为只在节点创建时生效,想要一进来就全展开请直接用这个 prop,
 * 别等挂载后再调 `handle.expandAll()`——那会多走一次整树通知。
 * 运行时改这个 prop 不会重放(React 侧换 `key` 重挂载才会重来,见「是否可展开」用例)。
 */
export function ExpandAllDemo() {
  const data = useMemo(baseData, [])

  return <OkrTree data={data} direction="horizontal" showCollapsable defaultExpandAll />
}
defaultExpandedKeys

指定默认展开的节点

nodeKey + defaultExpandedKeys:命中节点的祖先链自动展开。

查看源码
'use client'

import { useMemo, useState } from 'react'
import { OkrTree, type TreeKey } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'

/**
 * 指定默认展开的节点(对应源项目 playground/components/demos/Base041.vue)
 *
 * `defaultExpandedKeys` 命中节点的**整条祖先链**会一并展开,所以本例展开 `[5]`(UI 设计,
 * 一个叶子)看到的是「根 → 产品研发部 → UI 设计」这条路径亮了。前提是给 `nodeKey`:
 * 它是注册表的主键,缺了这些 key 找不到节点(开发期会警告并且完全不起作用)。
 *
 * 语义上有两点容易踩:
 * 1. 换一批 key 只「叠加展开」,不会把上一批收起——它是默认值,不是受控值;
 *    要能收要放得用 `expandedKeys` + `onExpandedKeysChange`(受控用法见对应用例)。
 * 2. 数组必须是稳定引用。这里用模块级常量而不是在 JSX 里写 `[5]`:组件按引用变化
 *    来重新应用这批 key,每次渲染新建字面量会把用户手动收起的节点又弹开(requirements R2)。
 */
const EXPANSIONS: { label: string; keys: TreeKey[] }[] = [
  { label: '[5] UI 设计', keys: [5] },
  { label: '[2] 产品研发部', keys: [2] },
  { label: '[7, 8] 销售部两支', keys: [7, 8] },
]

export function DefaultExpandedKeysDemo() {
  const data = useMemo(keyedData, [])
  const [expansion, setExpansion] = useState(EXPANSIONS[0])

  return (
    <div>
      <div className="mb-3 flex flex-wrap gap-2 text-sm">
        <span className="text-fd-muted-foreground">defaultExpandedKeys</span>
        {EXPANSIONS.map(item => (
          <button
            key={item.label}
            type="button"
            aria-pressed={item === expansion}
            onClick={() => setExpansion(item)}
            className={`rounded-lg border border-fd-border px-3 py-1 ${
              item === expansion
                ? 'bg-fd-accent text-fd-accent-foreground'
                : 'text-fd-muted-foreground'
            }`}
          >
            {item.label}
          </button>
        ))}
      </div>
      <OkrTree
        data={data}
        direction="horizontal"
        showCollapsable
        nodeKey="id"
        defaultExpandedKeys={expansion.keys}
      />
    </div>
  )
}
labelWidth × labelHeight
xxx科技有有限公司
产品研发部
研发-前端
研发-后端
UI 设计
销售部
销售一部
销售二部
财务部

节点尺寸与类名

labelWidth / labelHeight(number 走 px,string 原样)、labelClassNamecurrentLableClassName(后者是原版拼写,刻意保留)。

查看源码
'use client'

import { useMemo, useState } from 'react'
import { OkrTree, type TreeNode } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'

/**
 * 节点的样式(对应源项目 playground/components/demos/Base05.vue)
 *
 * - `labelWidth` / `labelHeight`:number 按 px,string 原样进 style,`undefined` 即 auto。
 *   上面三个按钮同时改这两个值——它们属于渲染配置,而节点组件是 memo 的,
 *   运行时换 prop 不会让已渲染的卡片重绘(Vue 的模板会自动跟上),所以按钮把值拼进
 *   `key` 重挂载。日常用法是定好尺寸后就不改了。
 * - `labelClassName` / `currentLableClassName`(后者是原版拼写,刻意保留):类名加在卡片
 *   `.org-chart-node-label-inner` 上,接受固定字符串或 `Function(node)`;入参是内部 TreeNode,
 *   源数据在 `node.data`。这两个 prop 组件内部会逐节点通知,所以可以运行时改。
 * - **默认主题下选中态本身没有任何外观**(卡片外观用 `:where()` 声明成零优先级,
 *   就是为了让你经 `currentLableClassName` 传的类直接盖上去),点一个节点才看得出差别。
 *
 * 类名这里用文档站现成的 Tailwind utility,带 `!` 是因为本站的 utilities 在 `@layer` 里、
 * 而库样式是无层的(同属性时未层声明优先)。自己的项目里写普通 class 就不用这个 `!`。
 */
function labelClassName(node: TreeNode) {
  if (node.level === 1) return 'font-semibold underline'
  return node.isLeaf ? 'opacity-60' : undefined
}

function currentLableClassName() {
  return 'rounded-md! bg-fd-primary! text-fd-primary-foreground!'
}

const SIZES: { label: string; width?: string | number; height?: string | number }[] = [
  { label: 'auto(默认)' },
  { label: '140 × 40', width: 140, height: 40 },
  { label: "'8rem' × auto", width: '8rem' },
]

export function NodeStyleDemo() {
  const data = useMemo(baseData, [])
  const [size, setSize] = useState(SIZES[0])

  return (
    <div>
      <div className="mb-3 flex flex-wrap items-center gap-2 text-sm">
        <span className="text-fd-muted-foreground">labelWidth × labelHeight</span>
        {SIZES.map(item => (
          <button
            key={item.label}
            type="button"
            aria-pressed={item === size}
            onClick={() => setSize(item)}
            className={`rounded-lg border border-fd-border px-3 py-1 ${
              item === size ? 'bg-fd-accent text-fd-accent-foreground' : 'text-fd-muted-foreground'
            }`}
          >
            {item.label}
          </button>
        ))}
      </div>
      <OkrTree
        key={size.label}
        data={data}
        direction="horizontal"
        showCollapsable
        defaultExpandAll
        labelWidth={size.width}
        labelHeight={size.height}
        labelClassName={labelClassName}
        currentLableClassName={currentLableClassName}
      />
    </div>
  )
}

节点内容定制

写法:

优先级 renderNode > nodeComponent > renderContent > node.label。点卡片选中节点,三种写法里都读得到node.isCurrent

renderContentxxx科技有有限公司这是一个有活力的公司
renderContent产品研发部这是一个有活力的产品研发部
renderContent研发-前端这是一个有活力的研发-前端
renderContent研发-后端这是一个有活力的研发-后端
renderContentUI 设计这是一个有活力的UI 设计
renderContent销售部这是一个有活力的销售部
renderContent销售一部这是一个有活力的销售一部
renderContent销售二部这是一个有活力的销售二部
renderContent财务部这是一个有活力的财务部

三种内容写法对比

优先级 renderNode > nodeComponent > renderContent > 内置 node.label。React 版的 renderContent 只收 (node),没有 Vue 的 h

查看源码
'use client'

import { useMemo, useState } from 'react'
import {
  OkrTree,
  type NodeComponent,
  type NodeComponentProps,
  type RenderContentFunction,
  type TreeNode,
} from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { contentData } from './data'

/**
 * 节点内容定制的三种写法对比(对应源项目 playground/components/demos/Base06.vue)
 *
 * 三种写法渲染同一张卡片,卡片左上角标出「这一张是从哪个口渲染出来的」;
 * 「三种同传」这一档用来验优先级:renderNode > nodeComponent > renderContent > node.label。
 *
 * 与 Vue 用例的四处差别:
 * 1. `renderContent(node)` 只收 node,没有 Vue 传进来的 `h`(D1),直接返回 JSX;
 * 2. `#default` 作用域插槽 → `renderNode` prop(`children` 传函数等价),
 *    `node-component` → `nodeComponent`,入参形状仍是 `{ node, data }`;
 * 3. Vue 用 `markRaw(defineComponent(...))` 防 reactive 代理,React 没这个问题,
 *    但组件身份同样要稳定:`DiyCard` 必须定义在模块作用域——写在渲染函数里,
 *    每次渲染都是一个「新的组件类型」,卡片会整棵重挂载;
 * 4. 切换写法**不需要**重挂载:渲染定制虽然走 `configRef`(换回调身份不该让整树重渲染,R1),
 *    但组件对这一组 prop 做了一次全树通知,所以换了口已挂载的节点会立刻重绘,展开态保住。
 *    (这一条是文档 demo 反过来发现的库缺口:早期版本换 renderContent 视觉上毫无反应,
 *    当时只能靠 `key` 重挂载绕过。)
 */

const ROUTES = ['label', 'renderContent', 'nodeComponent', 'renderNode', 'all'] as const
type Route = (typeof ROUTES)[number]

function Card({ node, via }: { node: TreeNode; via: string }) {
  return (
    <div
      className={`flex flex-col items-start text-left ${
        node.isCurrent ? 'text-fd-primary' : 'text-fd-foreground'
      }`}
    >
      <span className="text-[10px] text-fd-muted-foreground">{via}</span>
      <span className="text-sm font-medium">{node.label}</span>
      <span className="text-xs text-fd-muted-foreground">{node.data.content}</span>
    </div>
  )
}

/** 写法一:内容区渲染函数,只有 node(源数据在 node.data,文本在 node.label) */
const renderContent: RenderContentFunction = node => <Card node={node} via="renderContent" />

/** 写法二:内容组件,props 为 { node, data } */
const DiyCard: NodeComponent = ({ node }) => <Card node={node} via="nodeComponent" />

/** 写法三:整节点渲染(对应源项目 #default 插槽),优先级最高 */
const renderNode = ({ node }: NodeComponentProps) => <Card node={node} via="renderNode" />

export function ContentModesDemo() {
  const data = useMemo(contentData, [])
  const [route, setRoute] = useState<Route>('renderContent')
  const on = (key: Route) => route === key || route === 'all'

  return (
    <div className="space-y-3">
      <div className="flex flex-wrap items-center gap-2">
        <span className="text-sm text-fd-muted-foreground">写法:</span>
        {ROUTES.map(key => (
          <button
            key={key}
            type="button"
            onClick={() => setRoute(key)}
            className={`rounded-lg border border-fd-border px-2 py-1 text-sm ${
              route === key ? 'bg-fd-accent font-medium' : 'text-fd-muted-foreground'
            }`}
          >
            {key === 'all' ? '三种同传' : key}
          </button>
        ))}
      </div>
      <p className="text-sm text-fd-muted-foreground">
        优先级 <code>renderNode</code> &gt; <code>nodeComponent</code> &gt;{' '}
        <code>renderContent</code> &gt; <code>node.label</code>。点卡片选中节点,三种写法里都读得到
        <code>node.isCurrent</code>。
      </p>
      <OkrTree
        data={data}
        direction="horizontal"
        showCollapsable
        defaultExpandAll
        renderContent={on('renderContent') ? renderContent : undefined}
        nodeComponent={on('nodeComponent') ? DiyCard : undefined}
        renderNode={on('renderNode') ? renderNode : undefined}
      />
    </div>
  )
}
按钮内容:

点圆盘收起 / 展开即可看到自定义内容替掉了内置的 +/−;renderExpandBtn 拿到的 expanded 就是该侧当前状态。

xxx科技有有限公司
产品研发部
研发-前端
研发-后端
UI 设计
销售部
销售一部
销售二部
财务部

展开按钮内容

renderExpandBtn(旧名 nodeBtnContent 仍在);showNodeNum 折叠时优先显示数字。

查看源码
'use client'

import { useMemo, useState } from 'react'
import {
  OkrTree,
  type ExpandBtnScope,
  type NodeBtnContentFunction,
  type TreeNode,
} from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'

/**
 * 展开圆盘内容定制(对应源项目 playground/components/demos/Base062.vue)
 *
 * 两个口 + 一条优先级:`showNodeNum` 的折叠数字 > `renderExpandBtn`(对应 #expand-btn 插槽)
 * > `nodeBtnContent`(旧名,仍在 API 表里)> 内置 CSS 画的 +/−。「两个同传」与
 * 「showNodeNum」两档就是给这条优先级用的。
 *
 * 与 Vue 用例的差别:
 * 1. 插槽 → render prop:`renderExpandBtn(scope)` 的作用域形状与 `#expand-btn` 一致
 *    (`{ node, data, expanded, side, loading }`),`side` 在 OKR 左子树才会是 'left';
 * 2. `nodeBtnContent(node)` 去掉了 Vue 的第一个参数 `h`(D1),只给 node;
 * 3. 自定义内容要包一层内置类 `org-chart-node-btn-text`:它用不透明底铺满整个圆盘,
 *    把 CSS 伪元素画的 +/− 盖掉——这属于 DOM 契约,不是可选美化;
 * 4. 圆盘是 20px 的绝对定位元素,按钮里放长文案会溢出,本例只放单字符;
 * 5. 换按钮口不需要重挂载:渲染定制这一组 prop 变更会被组件广播成一次全树重绘,
 *    已挂载节点立刻跟上,用户的展开态不受影响。
 */

const MODES = ['default', 'nodeBtnContent', 'renderExpandBtn', 'both', 'showNodeNum'] as const
type Mode = (typeof MODES)[number]

/** 旧名:与 renderContent 同一套约定,只有 node */
const nodeBtnContent: NodeBtnContentFunction = (node: TreeNode) => (
  <span className="org-chart-node-btn-text" title={node.label}>

  </span>
)

/** 新名(等价 #expand-btn 插槽):拿得到该侧的展开态与懒加载态 */
const renderExpandBtn = ({ expanded, side, loading }: ExpandBtnScope) => (
  <span className="org-chart-node-btn-text" title={side}>
    {loading ? '…' : expanded ? '−' : '+'}
  </span>
)

export function ExpandBtnDemo() {
  const data = useMemo(baseData, [])
  const [mode, setMode] = useState<Mode>('renderExpandBtn')
  const useNum = mode === 'showNodeNum'
  const useBtnContent = mode === 'nodeBtnContent' || mode === 'both' || useNum
  const useScope = mode === 'renderExpandBtn' || mode === 'both' || useNum

  return (
    <div className="space-y-3">
      <div className="flex flex-wrap items-center gap-2">
        <span className="text-sm text-fd-muted-foreground">按钮内容:</span>
        {MODES.map(key => (
          <button
            key={key}
            type="button"
            onClick={() => setMode(key)}
            className={`rounded-lg border border-fd-border px-2 py-1 text-sm ${
              mode === key ? 'bg-fd-accent font-medium' : 'text-fd-muted-foreground'
            }`}
          >
            {key === 'both' ? '两个同传' : key}
          </button>
        ))}
      </div>
      <p className="text-sm text-fd-muted-foreground">
        {useNum
          ? '树是展开的,随便收起一层就能看到圆盘里的数字:showNodeNum 优先,两个自定义口完全不会被调用。'
          : '点圆盘收起 / 展开即可看到自定义内容替掉了内置的 +/−;renderExpandBtn 拿到的 expanded 就是该侧当前状态。'}
      </p>
      <OkrTree
        data={data}
        direction="horizontal"
        showCollapsable
        defaultExpandAll
        showNodeNum={useNum}
        nodeBtnContent={useBtnContent ? nodeBtnContent : undefined}
        renderExpandBtn={useScope ? renderExpandBtn : undefined}
      />
    </div>
  )
}
animate-name:
xxx科技有有限公司
产品研发部
研发-前端
研发-后端
UI 设计
销售部
销售一部
销售二部
财务部

展开动画

animate + animateName 六个方向 + animateDuration;系统开启「减弱动态效果」时自动按关闭处理。

查看源码
'use client'

import { useMemo, useRef, useState } from 'react'
import { OkrTree, type AnimateName, type OkrTreeHandle } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'

/**
 * 展开 / 收起过渡动画(对应源项目 playground/components/demos/Base061.vue)
 *
 * `animate` 是开关,`animateName` 选六个内置名字之一,`animateDuration` 给时长(ms)。
 * 「全部收起 / 全部展开」两个按钮走 ref,点一次能看到整树同时在动。
 *
 * 与 Vue 用例的差别:
 * 1. 名字与取值集合完全一致(`AnimateName`),类名形态是 `is-animated` + `okr-anim-{name}`,
 *    写在子节点容器上,不是包一层 `<Transition>` 组件;
 * 2. `animateDuration` 在 React 侧不只是 CSS 变量:收起要等这么久才把容器压成 `height: 0`
 *    (`useDelayedCollapse`,R7),因为 `height: auto → 0` 不可插值,早压会让下方节点跳位。
 *    所以把时长调到 1500ms 时,收起后的「留白」也一起变长;
 * 3. 系统开「减弱动态效果」时组件按 animate=false 处理(JS 与 CSS 两侧都判),
 *    所以这台机器上可能看不到动效,不是 bug;
 * 4. 这三个 prop 运行时切换都真的生效(内部会逐节点通知),不需要像别的定制口那样重挂载。
 */

const NAMES: AnimateName[] = [
  'okr-zoom-in-center',
  'okr-zoom-in-top',
  'okr-zoom-in-bottom',
  'okr-zoom-in-left',
  'okr-fade-in',
  'okr-fade-in-linear',
]

export function AnimationDemo() {
  const data = useMemo(baseData, [])
  const tree = useRef<OkrTreeHandle>(null)
  const [animate, setAnimate] = useState(true)
  const [animateName, setAnimateName] = useState<AnimateName>('okr-zoom-in-center')
  const [animateDuration, setAnimateDuration] = useState(200)

  return (
    <div className="space-y-3">
      <div className="flex flex-wrap items-center gap-2">
        <span className="text-sm text-fd-muted-foreground">animate-name:</span>
        {NAMES.map(name => (
          <button
            key={name}
            type="button"
            onClick={() => setAnimateName(name)}
            className={`rounded-lg border border-fd-border px-2 py-1 text-sm ${
              animateName === name ? 'bg-fd-accent font-medium' : 'text-fd-muted-foreground'
            }`}
          >
            {name}
          </button>
        ))}
      </div>
      <div className="flex flex-wrap items-center gap-4 text-sm">
        <label className="flex items-center gap-2">
          <input
            type="checkbox"
            checked={animate}
            onChange={event => setAnimate(event.target.checked)}
          />
          animate
        </label>
        <label className="flex items-center gap-2">
          <span className="text-fd-muted-foreground">animate-duration</span>
          <input
            className="w-24 rounded-lg border border-fd-border px-2 py-1"
            type="number"
            min={0}
            step={100}
            value={animateDuration}
            onChange={event => setAnimateDuration(Number(event.target.value))}
          />
          ms
        </label>
        <button
          type="button"
          onClick={() => tree.current?.collapseAll()}
          className="rounded-lg border border-fd-border px-2 py-1"
        >
          全部收起
        </button>
        <button
          type="button"
          onClick={() => tree.current?.expandAll()}
          className="rounded-lg border border-fd-border px-2 py-1"
        >
          全部展开
        </button>
      </div>
      <OkrTree
        ref={tree}
        data={data}
        direction="horizontal"
        showCollapsable
        defaultExpandAll
        animate={animate}
        animateName={animateName}
        animateDuration={animateDuration}
      />
    </div>
  )
}

OKR 模式

(左)研发-前端
(左)研发-后端
(左)UI 设计
(左)产品研发部
(左)销售一部
(左)销售二部
(左)销售部
(左)财务部
xxx科技有有限公司
产品研发部
研发-前端
研发-后端
UI 设计
销售部
销售一部
销售二部
财务部
(左)前端一部
(左)前端二部
(左)研发-前端
(左)研发-后端
(左)UI 设计
(左)产品研发部
(左)销售一部
(左)销售二部
(左)销售部
(左)财务部
xxx科技有有限公司
产品研发部
研发-前端
研发-后端
UI 设计
销售部
销售一部
销售二部
财务部

OKR 双树与根对齐

两棵 onlyBothTree 的树并排,外面套 OkrTreeGroup 让左右子树宽度取组内最宽者;alignRoot 保证根节点水平坐标不随展开跳位。

查看源码
'use client'

import { useMemo, useState } from 'react'
import { OkrTree, OkrTreeGroup } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData, leftData, leftData2 } from './data'

/**
 * OKR 双树与跨实例根对齐(对应源项目 playground/components/demos/Base07.vue)
 *
 * 两棵 `onlyBothTree` 的树上下排开做对比,第二棵的左子树多一层(`leftData2`)。
 * `alignRoot`(React 里默认 true)只负责「每棵树自己的根节点在自身容器内水平居中」:
 * 纯 CSS,展开收起都不会让根节点跳位。但两棵树的左子树深度不同时,深的那一行会被
 * `min-width: max-content` 撑得更宽,居中坐标就跟着偏——这正是 `<OkrTreeGroup>` 要解决的:
 * 它量出组内所有左子树容器的最大自然宽度并统一写入,两棵树的根节点才落在同一条竖线上。
 * 关掉 `align` 就能看到差别(等价于不套 Group,组会清掉已写入的宽度)。
 *
 * 与源用例的差别:Vue 侧把 group 开关和 `align-root` 绑在同一个变量上,这里拆成两个按钮,
 * 免得读者把「各自居中」和「组内统一宽度」当成一件事。成员树传 `alignRoot={false}` 时,
 * 组内对齐没有意义(Group 建在 alignRoot 的居中机制之上)。
 */
export function OkrGroupDemo() {
  const [groupAlign, setGroupAlign] = useState(true)
  const [alignRoot, setAlignRoot] = useState(true)
  // 三份数据都必须稳定:换引用 = store 全量重建,展开态会被冲掉(requirements R2)
  const [data, left, deeperLeft] = useMemo(() => [keyedData(), leftData(), leftData2()], [])

  return (
    <div>
      <div className="mb-3 flex flex-wrap gap-2">
        <button
          type="button"
          onClick={() => setGroupAlign(value => !value)}
          className={`rounded-lg border border-fd-border px-3 py-1.5 text-sm transition-colors ${
            groupAlign ? 'bg-fd-accent text-fd-accent-foreground' : 'text-fd-muted-foreground'
          }`}
        >
          OkrTreeGroup align:{groupAlign ? '开启' : '关闭'}
        </button>
        <button
          type="button"
          onClick={() => setAlignRoot(value => !value)}
          className={`rounded-lg border border-fd-border px-3 py-1.5 text-sm transition-colors ${
            alignRoot ? 'bg-fd-accent text-fd-accent-foreground' : 'text-fd-muted-foreground'
          }`}
        >
          alignRoot:{alignRoot ? '开启' : '关闭'}
        </button>
      </div>
      <div className="overflow-x-auto">
        <OkrTreeGroup align={groupAlign}>
          <OkrTree
            data={data}
            leftData={left}
            onlyBothTree
            direction="horizontal"
            nodeKey="id"
            showCollapsable
            defaultExpandAll
            alignRoot={alignRoot}
          />
          <OkrTree
            data={data}
            leftData={deeperLeft}
            onlyBothTree
            direction="horizontal"
            nodeKey="id"
            showCollapsable
            defaultExpandAll
            alignRoot={alignRoot}
          />
        </OkrTreeGroup>
      </div>
    </div>
  )
}

历史进展 · (左)研发-前端

这是一个有活力的研发-前端

历史进展 · (左)研发-后端

这是一个有活力的研发-后端

历史进展 · (左)UI 设计

这是一个有活力的UI 设计

历史进展 · (左)产品研发部

这是一个有活力的产品研发部

历史进展 · (左)销售一部

这是一个有活力的销售一部

历史进展 · (左)销售二部

这是一个有活力的销售二部

历史进展 · (左)销售部

这是一个有活力的销售部

历史进展 · (左)财务部

这是一个有活力的财务部

xxx科技有有限公司

这是一个有活力的公司

产品研发部

这是一个有活力的产品研发部

研发-前端

这是一个有活力的研发-前端

研发-后端

这是一个有活力的研发-后端

UI 设计

这是一个有活力的UI 设计

销售部

这是一个有活力的销售部

销售一部

这是一个有活力的销售一部

销售二部

这是一个有活力的销售二部

财务部

这是一个有活力的财务部

OKR 下的自定义内容

同一份 renderContent 里用 node.isLeftChild 分侧渲染——左树是同一组件的完整镜像,不是另一套 API。

查看源码
'use client'

import { useMemo } from 'react'
import { OkrTree, type TreeNode } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { okrContentData, okrContentLeftData } from './data'

/**
 * OKR 模式下的自定义节点内容(对应源项目 playground/components/demos/Base08.vue)
 *
 * 左右两棵树是**同一个组件、同一份 `renderContent`**,靠 `node.isLeftChild` 分侧:
 * 右树渲染「标题 + 描述」卡片,左树渲染成信息层级反过来的一张卡,一眼看出两侧不是两套 API。
 *
 * 与源用例的差别:
 * 1. Vue 的 `render-content` 签名是 `(h, node)`,React 侧去掉了框架注入的 `h`(requirements D1),
 *    直接返回 JSX。
 * 2. 源用例用 `label-class-name="no-padding"` 加一张全局样式表抵消卡片内边距;文档站的 demo
 *    是单文件、没有自己的样式表,所以这里保留库默认内边距,内容不再自带 padding。
 *    要自定义外观取值,用 `--okr-*` 变量或 `labelClassName`(库的外观规则是 `:where()`,
 *    单个工具类就能覆盖)。
 * 3. 自定义字段一律从 `node.data` 取(`node.label` 是按 `props.label` 解析后的文本)。
 */
export function OkrContentDemo() {
  const [data, leftData] = useMemo(() => [okrContentData(), okrContentLeftData()], [])

  function renderContent(node: TreeNode) {
    const { content } = node.data
    if (node.isLeftChild) {
      return (
        <div className="min-w-40 text-left">
          <p className="text-xs text-fd-muted-foreground">历史进展 · {node.label}</p>
          <p className="mt-1 text-sm">{content}</p>
        </div>
      )
    }
    return (
      <div className="min-w-40 text-left">
        <p className="text-sm font-medium">{node.label}</p>
        <p className="mt-1 text-xs text-fd-muted-foreground">{content}</p>
      </div>
    )
  }

  return (
    <div className="overflow-x-auto">
      <OkrTree
        data={data}
        leftData={leftData}
        onlyBothTree
        direction="horizontal"
        nodeKey="id"
        showCollapsable
        defaultExpandAll
        renderContent={renderContent}
      />
    </div>
  )
}

OKR 折叠计数

showNodeNum 在左右两侧圆盘上分别显示可见子节点数(被 filter 隐藏的不计)。

查看源码
'use client'

import { useMemo } from 'react'
import { OkrTree } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData, leftData } from './data'

/**
 * OKR 模式显示节点数(对应源项目 playground/components/demos/Base081.vue)
 *
 * `showCollapsable` 打开圆盘、`showNodeNum` 让圆盘里显示子节点数。OKR 根节点左右各一个圆盘,
 * 所以同一份配置会在两侧分别报出自己那侧的可见子节点数;点圆盘收起/展开即可看到数字变化,
 * 深层节点展开后再收起也会有数字。
 *
 * 与源用例的差别:源用例同时给了 `show-node-num` 和 `node-btn-content`(自己 return
 * `node.childNodes.length`),React 侧不需要那半边——`showNodeNum` 优先于
 * `nodeBtnContent` / `renderExpandBtn`,而且数字按「未被 filter 隐藏的可见子节点」计,
 * 手写 `childNodes.length` 反而会在过滤后对不上视觉。
 */
export function OkrNodeNumDemo() {
  const [data, left] = useMemo(() => [keyedData(), leftData()], [])

  return (
    <div className="overflow-x-auto">
      <OkrTree
        data={data}
        leftData={left}
        onlyBothTree
        direction="horizontal"
        nodeKey="id"
        showCollapsable
        showNodeNum
      />
    </div>
  )
}

状态、数据与画布

expandedKeys:[1]currentKey:null
展开:
选中:
数据与查询:
原地变更(R2 / D7):

受控状态与 ref 方法

expandedKeys / currentKey 成对 props 即受控;ref 上的方法入参普遍接受 key / data 对象 / TreeNode 三种形态。原地改数据后要宿主重渲染或调 refreshData()

查看源码
'use client'

import { useRef, useState } from 'react'
import {
  OkrTree,
  type FilterNodeMethod,
  type OkrTreeHandle,
  type TreeKey,
  type TreeNodeData,
} from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'

/**
 * 受控展开/选中 + 走 ref 的方法(对应源项目 playground/components/demos/Base09.vue)
 *
 * `v-model:expanded-keys` / `v-model:current-key` 在 React 里拆成「值 + 回调」一对(D3):
 * 传了值就是受控,`undefined` 才是非受控。下面这些方法按钮只「请求」,最终显示什么由
 * 宿主回写的 state 决定——所以本文件里没有任何一处绕过 state 直接改 store。
 *
 * 与 Vue 用例的差别:
 * 1. 模板 ref → `useRef<OkrTreeHandle>(null)`,28 个方法全在 handle 上;
 * 2. 事件回调不再有第三个参数 `nodeComponent`(D2),要在 DOM 上做事用 `getNodeEl()`;
 * 3. **`data` 放在 `useState` 里而不是每次渲染新建字面量**:这里要演示换引用;顺带一条边界——
 *    「换外壳数组、元素逐个还是同引用」会被判为未变(那是给宿主每帧新建字面量兜的,R2),
 *    既不全量重建也不跑脏检查;
 * 4. 原地 `push` 一个节点后组件不会自己发现(引用没变,Vue 的 deep watch 在 React 没有对应物,
 *    即 D7)。被看见只有两条路:**宿主用同一引用重渲染**(渲染期结构脏检查 → 增量更新,
 *    保留各节点状态),或显式调 **`handle.refreshData()`**(不依赖宿主是否重渲染)。
 *    换成一批新的元素引用则是全量重建,重建后按受控值恢复展开与选中。
 */

const BTN = 'rounded-lg border border-fd-border px-2 py-1 text-sm'

/** 空值必须返回 true,否则「清空输入 = 恢复全显」不成立 */
const filterNodeMethod: FilterNodeMethod = (value, data) =>
  !value || String(data.label).includes(String(value))

export function ControlledDemo() {
  const [data, setData] = useState<TreeNodeData[]>(keyedData)
  const [expandedKeys, setExpandedKeys] = useState<TreeKey[]>([1])
  const [currentKey, setCurrentKey] = useState<TreeKey | null>(null)
  const [note, setNote] = useState('')
  const tree = useRef<OkrTreeHandle>(null)
  const idSeed = useRef(100)

  /** 原地改源数据:引用不变,组件收不到任何通知 */
  function pushChild(): number {
    const id = ++idSeed.current
    const children = (data[0].children ??= []) as TreeNodeData[]
    children.push({ id, label: `原地新增 ${id}` })
    return id
  }

  return (
    <div className="space-y-2">
      <div className="flex flex-wrap items-center gap-2 text-sm">
        <span className="text-fd-muted-foreground">expandedKeys:</span>
        <code>{JSON.stringify(expandedKeys)}</code>
        <span className="text-fd-muted-foreground">currentKey:</span>
        <code>{currentKey ?? 'null'}</code>
        {note ? <span className="text-fd-muted-foreground">— {note}</span> : null}
      </div>
      <div className="flex flex-wrap items-center gap-2">
        <span className="text-sm text-fd-muted-foreground">展开:</span>
        <button className={BTN} type="button" onClick={() => tree.current?.expandAll()}>
          expandAll()
        </button>
        <button className={BTN} type="button" onClick={() => tree.current?.collapseAll()}>
          collapseAll()
        </button>
        <button className={BTN} type="button" onClick={() => tree.current?.expandNode(5)}>
          expandNode(5)
        </button>
        <button className={BTN} type="button" onClick={() => setExpandedKeys([1, 6])}>
          expandedKeys = [1, 6]
        </button>
      </div>
      <div className="flex flex-wrap items-center gap-2">
        <span className="text-sm text-fd-muted-foreground">选中:</span>
        <button className={BTN} type="button" onClick={() => tree.current?.setCurrentKey(8)}>
          setCurrentKey(8)
        </button>
        <button
          className={BTN}
          type="button"
          onClick={() => setNote(`getCurrentKey() → ${tree.current?.getCurrentKey() ?? 'null'}`)}
        >
          getCurrentKey()
        </button>
        <button className={BTN} type="button" onClick={() => setCurrentKey(null)}>
          currentKey = null
        </button>
      </div>
      <div className="flex flex-wrap items-center gap-2">
        <span className="text-sm text-fd-muted-foreground">数据与查询:</span>
        <button
          className={BTN}
          type="button"
          onClick={() => tree.current?.append({ id: ++idSeed.current, label: 'append 进来的' }, 1)}
        >
          append 到 id=1
        </button>
        <button className={BTN} type="button" onClick={() => tree.current?.remove(3)}>
          remove(3)
        </button>
        <button className={BTN} type="button" onClick={() => tree.current?.filter('研发')}>
          filter('研发')
        </button>
        <button
          className={BTN}
          type="button"
          onClick={() => {
            tree.current?.filter('')
            setNote('filter("") 恢复全显')
          }}
        >
          filter('')
        </button>
        <button className={BTN} type="button" onClick={() => void tree.current?.scrollToNode(8)}>
          scrollToNode(8)
        </button>
      </div>
      <div className="flex flex-wrap items-center gap-2">
        <span className="text-sm text-fd-muted-foreground">原地变更(R2 / D7):</span>
        <button
          className={BTN}
          type="button"
          onClick={() => setNote(`已 push id=${pushChild()},这次重渲染让组件的脏检查接住了它`)}
        >
          push + 靠宿主重渲染
        </button>
        <button
          className={BTN}
          type="button"
          onClick={() => {
            pushChild()
            tree.current?.refreshData()
          }}
        >
          push + refreshData()
        </button>
        <button
          className={BTN}
          type="button"
          onClick={() => setData(data.map(item => ({ ...item })))}
        >
          换新引用(整树重建)
        </button>
      </div>
      <OkrTree
        ref={tree}
        data={data}
        nodeKey="id"
        direction="horizontal"
        showCollapsable
        expandedKeys={expandedKeys}
        onExpandedKeysChange={setExpandedKeys}
        currentKey={currentKey}
        onCurrentKeyChange={setCurrentKey}
        filterNodeMethod={filterNodeMethod}
      />
    </div>
  )
}
已发起 0 次请求(每个节点至多一次,失败后重试会再多一次)

懒加载子节点

lazy + load(node, resolve, reject);失败分支与重试、isLeaf 判定。

查看源码
'use client'

import { useCallback, useMemo, useRef, useState } from 'react'
import {
  OkrTree,
  type OkrTreeHandle,
  type TreeLoadFunction,
  type TreeNodeData,
} from 'react-okr-tree'
import 'react-okr-tree/style.css'

/**
 * 懒加载子节点(对应源项目 playground/components/demos/Base10.vue)
 *
 * `lazy` + `load(node, resolve, reject)`:初始只给顶层,首次展开时才取数(本例固定 300ms 假延迟)。
 * `SERVER` 是假的服务端表,`leaf: true` 配合 `props.isLeaf` 把圆盘收掉;
 * id=5 这一支首次必 reject,配一个「重试」按钮走 `expandNode()` 再请求一次。
 *
 * 与 Vue 用例的差别(这一条 React 专属,必须留意):
 * 1. **`load` 是创建期快照**(store 建好就不再换),所以它是个稳定身份:闭包里的 state
 *    会永远停在首次渲染那一刻。计数只能用函数式 `setRequests(n => n + 1)`、
 *    跨次状态用 `useRef`,别直接读 state;
 * 2. **`props`(字段映射)同样有代价**:它的身份一变,组件就把 `data` 重新塞给 store 走全量重建,
 *    刚点开的展开态会被冲掉——所以 `TREE_PROPS` 必须在模块作用域定义,不能写内联字面量;
 * 3. `resolve(children)` 会原地写进源数据的 `children`(Q3 回写),因此不需要换 `data` 引用、
 *    也不需要 `refreshData()`(R2 那两条兜底路径在懒加载这里用不上);
 * 4. 加载中的圆盘自带 `is-loading` 旋转;要在按钮上自己显示进度,用 `renderExpandBtn` 的
 *    `loading` 作用域(见 expand-btn 用例)。
 */

/** 首次必失败的那一支 */
const FAIL_ID = 5

/** 假的服务端:父 id → 子节点,缺项表示没有子节点 */
const SERVER: Record<number, TreeNodeData[]> = {
  1: [
    { id: 2, label: '产品研发部' },
    { id: 6, label: '销售部' },
    { id: 9, label: '财务部(叶子)', leaf: true },
  ],
  2: [
    { id: 3, label: '研发-前端', leaf: true },
    { id: 4, label: '研发-后端', leaf: true },
    { id: FAIL_ID, label: 'UI 设计(首次请求必失败)' },
  ],
  [FAIL_ID]: [
    { id: 51, label: '视觉组' },
    { id: 52, label: '交互组' },
  ],
  6: [
    { id: 7, label: '销售一部' },
    { id: 8, label: '销售二部' },
  ],
}

/** 初始只有顶层;带 leaf 的那条永远不会触发 load */
const lazyData = (): TreeNodeData[] => [
  { id: 1, label: 'xxx科技有有限公司' },
  { id: 90, label: '外部顾问(isLeaf,不请求)', leaf: true },
]

/** 字段映射必须引用稳定,见上面第 2 条 */
const TREE_PROPS = { isLeaf: 'leaf' }

export function LazyDemo() {
  const data = useMemo(lazyData, [])
  const tree = useRef<OkrTreeHandle>(null)
  const [requests, setRequests] = useState(0)
  const [failedId, setFailedId] = useState<number | null>(null)
  const failedOnce = useRef(new Set<number>())

  const load = useCallback<TreeLoadFunction>((node, resolve, reject) => {
    setRequests(count => count + 1)
    const id = node.data.id as number
    window.setTimeout(() => {
      if (id === FAIL_ID && !failedOnce.current.has(id)) {
        failedOnce.current.add(id)
        // reject:节点回到折叠态、清掉加载中标记,下次展开重新请求
        setFailedId(id)
        reject?.()
        return
      }
      setFailedId(null)
      resolve(SERVER[id] ?? [])
    }, 300)
  }, [])

  return (
    <div className="space-y-2">
      <div className="flex flex-wrap items-center gap-3 text-sm">
        <span className="text-fd-muted-foreground">
          已发起 <strong>{requests}</strong> 次请求(每个节点至多一次,失败后重试会再多一次)
        </span>
        {failedId === null ? null : (
          <button
            type="button"
            className="rounded-lg border border-fd-border bg-fd-accent px-2 py-1 text-sm"
            onClick={() => tree.current?.expandNode(failedId)}
          >
            重试 id={failedId}
          </button>
        )}
      </div>
      {failedId === null ? null : (
        <p className="text-sm text-fd-muted-foreground">
          id={failedId} 首次 reject:节点保持折叠、圆盘不再有加载指示,点上面的重试即可。
        </p>
      )}
      <OkrTree
        ref={tree}
        data={data}
        nodeKey="id"
        direction="horizontal"
        showCollapsable
        lazy
        load={load}
        props={TREE_PROPS}
      />
    </div>
  )
}
100%

当前缩放 100% · 拖动画布平移;按住 Ctrl / Cmd 滚动缩放。

画布缩放与平移

OkrTreeViewport 包住树:滚轮/捏合/拖拽、fitToScreencenterNodeexportImage(可选依赖 html-to-image 以注入函数的方式使用)。

查看源码
'use client'

import { useMemo, useRef, useState } from 'react'
import {
  OkrTree,
  OkrTreeViewport,
  type OkrTreeViewportHandle,
  type TreeNodeData,
  type ViewportWheelBehavior,
} from 'react-okr-tree'
import 'react-okr-tree/style.css'

/**
 * 画布组件 OkrTreeViewport(对应源项目 playground/components/demos/Base11.vue)
 *
 * 它只做外层 `transform: translate() scale()`,不侵入树本体:滚轮以指针为锚缩放、拖拽平移
 * (3px 阈值,平移结束时吞掉随后那次 click)、双击复位、双指捏合。这里用 `toolbar` 的默认
 * 工具栏,另外排一排按钮走 ref 方法:`fitToScreen()` / `centerNode(key)` / `exportImage()`。
 * `wheelBehavior` 三个值都点一遍:默认 `ctrl-zoom` 不劫持页面滚动,`zoom` 直接缩放,
 * `scroll` 完全不拦滚轮。`zoom` / `onZoomChange` 是受控写法(等价源项目 `v-model:zoom`),
 * 范围由 `minZoom` / `maxZoom` 钳制。
 *
 * 与源用例的差别:`html-to-image` 在 React 版里是**可选 peer**,站里没装,所以导出走
 * `exportImage({ toPng })` 的注入形式(下面那份 `toPng` 是本文件自带的最小实现)而不是
 * 库内部的动态 import。装过 `html-to-image` 的项目把它的 `toPng` 传进来就行,不传则库按需
 * 动态导入、没装时抛带安装指引的错。
 */

const WHEEL_MODES: Array<{ value: ViewportWheelBehavior; label: string }> = [
  { value: 'ctrl-zoom', label: 'Ctrl / Cmd + 滚轮缩放' },
  { value: 'zoom', label: '滚轮直接缩放' },
  { value: 'scroll', label: '滚轮滚动页面' },
]

/** 三层部门树:够宽才看得出缩放与平移的价值 */
function dept(id: number, label: string, depth: number): TreeNodeData {
  if (depth <= 0) return { id, label }
  return {
    id,
    label,
    children: [
      dept(id * 10 + 1, `${label}-A`, depth - 1),
      dept(id * 10 + 2, `${label}-B`, depth - 1),
    ],
  }
}

/**
 * 注入给 `exportImage` 的渲染函数,签名与 html-to-image 的 toPng 一致(el, { pixelRatio, backgroundColor })。
 * 把画布节点连同页面样式表一起塞进 `<foreignObject>`,再用 canvas 位图化。
 * 少了「内联样式表」这一步,隔离文档里渲染出来的就是无样式的纯文本。
 * 这份最小实现不做字体与外链图片的内联(隔离文档不加载外部资源),真需要就装 `html-to-image`。
 */
async function toPng(el: HTMLElement, options?: Record<string, any>): Promise<string> {
  const ratio = options?.pixelRatio ?? 2
  const css = Array.from(document.styleSheets)
    .map(sheet => {
      try {
        return Array.from(sheet.cssRules)
          .map(rule => rule.cssText)
          .join('')
      } catch {
        return '' // 跨域样式表读不到规则,跳过
      }
    })
    .join('')
  const box = document.createElementNS('http://www.w3.org/1999/xhtml', 'div')
  const style = document.createElement('style')
  style.textContent = css
  box.append(style, el.cloneNode(true))
  // 交给 XMLSerializer 转义:CSS 文本里的 & 与 > 手工拼串会破坏 XML 解析
  const markup = new XMLSerializer().serializeToString(box)
  const svg =
    `<svg xmlns="http://www.w3.org/2000/svg" width="${el.offsetWidth}" height="${el.offsetHeight}">` +
    `<foreignObject width="100%" height="100%">${markup}</foreignObject></svg>`
  const img = new Image()
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
  await img.decode()
  const canvas = document.createElement('canvas')
  canvas.width = Math.round(el.offsetWidth * ratio)
  canvas.height = Math.round(el.offsetHeight * ratio)
  const ctx = canvas.getContext('2d')
  if (!ctx) throw new Error('当前环境拿不到 canvas 2d 上下文')
  if (options?.backgroundColor) {
    ctx.fillStyle = options.backgroundColor
    ctx.fillRect(0, 0, canvas.width, canvas.height)
  }
  ctx.scale(ratio, ratio)
  ctx.drawImage(img, 0, 0)
  return canvas.toDataURL('image/png')
}

export function ViewportDemo() {
  const vp = useRef<OkrTreeViewportHandle>(null)
  const [zoom, setZoom] = useState(1)
  const [wheelBehavior, setWheelBehavior] = useState<ViewportWheelBehavior>('ctrl-zoom')
  const [status, setStatus] = useState('拖动画布平移;按住 Ctrl / Cmd 滚动缩放。')
  const data = useMemo(() => [dept(1, 'xxx科技有有限公司', 3)], [])

  async function centerOn(id: number) {
    // centerNode 会先展开目标祖先,再把视口中心对准它,返回是否命中
    const done = await vp.current?.centerNode(id)
    setStatus(done ? `已居中到 id=${id}(祖先已展开)` : `centerNode(${id}) 未命中节点`)
  }

  async function exportPng() {
    setStatus('导出中…')
    try {
      await vp.current?.exportImage({ type: 'png', scale: 2, background: '#ffffff', toPng })
      setStatus('已导出 PNG,浏览器应已触发下载(Promise 以 dataURL 结束)')
    } catch (error) {
      setStatus(`导出失败:${(error as Error).message}`)
    }
  }

  return (
    <div>
      <div className="mb-3 flex flex-wrap items-center gap-2">
        {WHEEL_MODES.map(mode => (
          <button
            key={mode.value}
            type="button"
            onClick={() => setWheelBehavior(mode.value)}
            className={`rounded-lg border border-fd-border px-3 py-1.5 text-sm transition-colors ${
              wheelBehavior === mode.value
                ? 'bg-fd-accent text-fd-accent-foreground'
                : 'text-fd-muted-foreground'
            }`}
          >
            {mode.label}
          </button>
        ))}
        <button
          type="button"
          onClick={() => vp.current?.fitToScreen()}
          className="rounded-lg border border-fd-border px-3 py-1.5 text-sm transition-colors hover:bg-fd-accent"
        >
          fitToScreen()
        </button>
        <button
          type="button"
          onClick={() => void centerOn(121)}
          className="rounded-lg border border-fd-border px-3 py-1.5 text-sm transition-colors hover:bg-fd-accent"
        >
          centerNode(121)
        </button>
        <button
          type="button"
          onClick={() => void exportPng()}
          className="rounded-lg border border-fd-border px-3 py-1.5 text-sm transition-colors hover:bg-fd-accent"
        >
          导出 PNG(注入 toPng)
        </button>
      </div>
      <OkrTreeViewport
        ref={vp}
        toolbar
        zoom={zoom}
        onZoomChange={setZoom}
        minZoom={0.3}
        maxZoom={3}
        wheelBehavior={wheelBehavior}
        style={{ height: 420 }}
      >
        <OkrTree data={data} nodeKey="id" direction="horizontal" showCollapsable />
      </OkrTreeViewport>
      <p className="mt-2 text-sm text-fd-muted-foreground">
        当前缩放 {Math.round(zoom * 100)}% · {status}
      </p>
    </div>
  )
}

过滤

输入关键字试试,清空即恢复全部节点

xxx科技有有限公司
产品研发部
研发-前端
研发-后端
UI 设计
销售部
销售一部
销售二部
财务部

过滤

输入即 handle.filter(value)清空输入等于恢复全显filterNodeMethod!value 直接返回 true),多根数据也要一起恢复。

查看源码
'use client'

import { useRef, useState } from 'react'
import { OkrTree, type OkrTreeHandle, type TreeNodeData } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'

/**
 * 节点过滤 + 通过 ref 调用的方法(对应源项目 playground/components/demos/BaseFilter.vue)
 *
 * **空值恢复**是这里唯一容易写错的语义:`filter('')` 不会走「跳过过滤」的捷径,它会以空值
 * 再执行一次 `filterNodeMethod`,所以方法里 `!value` 必须返回 `true`,否则清空关键字后节点
 * 再也回不来(源项目修过的 Q1 行为)。父节点自身不匹配但后代命中时它会保持可见。
 * 下面的按钮就是源用例那批方法:`getNode` / `setCurrentNode` / `getCurrentKey` / `remove` /
 * `append` / `insertBefore` / `insertAfter` / `updateKeyChildren` 都依赖 `nodeKey`,
 * 增删类还会同步改你传进来的那份源数据(Q3)。
 *
 * 与源用例的差别:Vue 用 `watch(filterText)` 触发过滤,React 在 `onChange` 里直接调
 * `handle.filter(value)`;默认主题不给选中态内置外观(与原版一致),所以照源用例传
 * `currentLableClassName`(保留原版拼写),值写站里现成的 Tailwind 令牌而不是全局样式表。
 */
const BTN =
  'rounded-lg border border-fd-border px-2 py-1 text-sm transition-colors hover:bg-fd-accent'

/** 空值直接放行:清空输入 = 显示全部 */
function filterNode(value: string, data: TreeNodeData) {
  if (!value) return true
  return String(data.label).includes(value)
}

export function FilterDemo() {
  const tree = useRef<OkrTreeHandle>(null)
  const [filterText, setFilterText] = useState('')
  // data 用 state 持有而不是每次渲染现造:换引用 = store 全量重建,展开/选中态都会冲掉(R2)
  const [data, setData] = useState(keyedData)
  const [log, setLog] = useState('输入关键字试试,清空即恢复全部节点')

  function onFilter(value: string) {
    setFilterText(value)
    tree.current?.filter(value)
  }

  function getNodeByData() {
    const node = tree.current?.getNode({ id: 7, label: '销售一部' })
    say(node ? `getNode({ id: 7 }) → ${node.data.label}` : 'getNode({ id: 7 }) → null(已被删除)')
  }

  function getNodeById() {
    const node = tree.current?.getNode(7)
    say(node ? `getNode(7) → ${node.data.label}` : 'getNode(7) → null(已被删除)')
  }

  function setCurrentNode() {
    const node = tree.current?.getNode(7)
    if (!node) return say('销售一部不存在')
    tree.current?.setCurrentNode(node)
    say(`setCurrentNode(node) → getCurrentKey() = ${tree.current?.getCurrentKey()}`)
  }

  function getCurrentNode() {
    const node = tree.current?.getCurrentNode()
    say(node ? `当前选中的节点是「${node.label}」` : '当前没有选中节点')
  }

  function clearCurrent() {
    tree.current?.setCurrentKey(null)
    say(`setCurrentKey(null) → getCurrentKey() = ${tree.current?.getCurrentKey()}`)
  }

  function remove() {
    const node = tree.current?.getNode(2)
    if (!node) return say('产品研发部已删除')
    tree.current?.remove(node)
    say('remove(产品研发部):源数据里那一项也一起没了')
  }

  function append() {
    if (tree.current?.getNode(10)) return say('销售三部已经存在了,不可再增加')
    tree.current?.append({ id: 10, label: '销售三部' }, tree.current?.getNode(6))
    say('append(销售三部, 销售部) 完成')
  }

  function insertBefore() {
    const ref = tree.current?.getNode(6)
    if (!ref) return say('销售部不存在')
    if (tree.current?.getNode(11)) return say('销售总部已经存在了,不可再增加')
    tree.current?.insertBefore({ id: 11, label: '销售总部' }, ref)
    say('insertBefore(销售总部, 销售部) 完成')
  }

  function insertAfter() {
    const ref = tree.current?.getNode(6)
    if (!ref) return say('销售部不存在')
    if (tree.current?.getNode(11)) return say('销售总部已经存在了,不可再增加')
    tree.current?.insertAfter({ id: 11, label: '销售总部' }, ref)
    say('insertAfter(销售总部, 销售部) 完成')
  }

  function updateKeyChildren() {
    tree.current?.updateKeyChildren(6, [
      {
        id: 7,
        label: '销售一部',
        children: [
          { id: 1117, label: '销售一部--子一' },
          { id: 1118, label: '销售一部--子二' },
        ],
      },
      { id: 8, label: '销售二部' },
      { id: 77, label: '销售三部' },
    ])
    say('updateKeyChildren(6, [...]) 完成')
  }

  function reset() {
    setData(keyedData())
    setFilterText('')
    say('已重置:换 data 引用 = store 全量重建(过滤与展开态回到初始)')
  }

  function say(text: string) {
    setLog(text)
  }

  return (
    <div className="space-y-3">
      <input
        type="text"
        value={filterText}
        onChange={event => onFilter(event.target.value)}
        placeholder="输入关键字进行过滤(清空即恢复全部节点)"
        className="w-full max-w-sm rounded-lg border border-fd-border px-2 py-1 text-sm"
      />
      <div className="flex flex-wrap gap-2">
        <button type="button" className={BTN} onClick={getNodeByData}>
          通过 data 获取销售一部
        </button>
        <button type="button" className={BTN} onClick={getNodeById}>
          通过 id 获取销售一部
        </button>
        <button type="button" className={BTN} onClick={setCurrentNode}>
          setCurrentNode 选中销售一部
        </button>
        <button type="button" className={BTN} onClick={getCurrentNode}>
          getCurrentNode
        </button>
        <button type="button" className={BTN} onClick={clearCurrent}>
          setCurrentKey(null)
        </button>
        <button type="button" className={BTN} onClick={remove}>
          删除产品研发部
        </button>
        <button type="button" className={BTN} onClick={append}>
          为销售部追加销售三部
        </button>
        <button type="button" className={BTN} onClick={insertBefore}>
          在销售部之前插入销售总部
        </button>
        <button type="button" className={BTN} onClick={insertAfter}>
          在销售部之后插入销售总部
        </button>
        <button type="button" className={BTN} onClick={updateKeyChildren}>
          updateKeyChildren(6, ...)
        </button>
        <button type="button" className={BTN} onClick={reset}>
          重置数据
        </button>
      </div>
      <p className="text-sm text-fd-muted-foreground">{log}</p>
      <div className="overflow-x-auto">
        <OkrTree
          ref={tree}
          data={data}
          direction="horizontal"
          nodeKey="id"
          filterNodeMethod={filterNode}
          currentLableClassName="bg-fd-primary text-fd-primary-foreground"
        />
      </div>
    </div>
  )
}

两侧都命中的词会一起留下

OKR 模式过滤

一次 filter() 同时作用于左右两棵树,两侧都命中的关键词会一起留下。

查看源码
'use client'

import { useMemo, useRef, useState } from 'react'
import { OkrTree, type OkrTreeHandle, type TreeNodeData } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData, leftData } from './data'

/**
 * OKR 模式下的节点过滤(对应源项目 playground/components/demos/BaseFilterOkr.vue)
 *
 * 调的还是同一个 `handle.filter(value)`:`onlyBothTree` 下它内部跑两遍遍历,右树走
 * `childNodes`、左树走 `leftChildNodes`,所以「销售」这种两侧都命中的词会一起留下,
 * 空值恢复也一样(`filterNodeMethod` 里 `!value` 直接 true)。
 * 这里刻意不开 `defaultExpandAll`:关键字非空时命中分支会自动展开,收起的树一输入关键字
 * 就直接跳到命中处。
 *
 * 与源用例的差别:左右两棵树可以存在相同 id(本例根都是 1),`getNode` / `setCurrentKey`
 * 这类按 key 查找的方法**右树优先、未命中才回退左树**——这是源项目的正式语义。
 * 源用例旁边那批方法按钮(getNode / append / updateKeyChildren 等)在普通过滤那一例里
 * 已经演示过,这里只留过滤本身。
 */
const KEYWORDS = ['销售', '左', '前端']

/** 空值直接放行:清空输入 = 显示全部 */
function filterNode(value: string, data: TreeNodeData) {
  if (!value) return true
  return String(data.label).includes(value)
}

export function FilterOkrDemo() {
  const tree = useRef<OkrTreeHandle>(null)
  const [filterText, setFilterText] = useState('')
  const [status, setStatus] = useState('两侧都命中的词会一起留下')
  // 两份数据都要稳定:换引用等于 store 全量重建(requirements R2)
  const data = useMemo(keyedData, [])
  const left = useMemo(leftData, [])

  function apply(value: string) {
    setFilterText(value)
    tree.current?.filter(value)
    // getVisibleNodes 含 OKR 左树:一次 filter 之后两侧各命中了多少,这里直接读得出来
    const visible = tree.current?.getVisibleNodes() ?? []
    const onLeft = visible.filter(node => node.isLeftChild).length
    setStatus(
      value
        ? `「${value}」左右合计 ${visible.length} 个可见节点,其中左树 ${onLeft} 个`
        : `空值已放行:全部节点恢复可见,当前沿展开路径能看到 ${visible.length} 个`
    )
  }

  return (
    <div className="space-y-3">
      <div className="flex flex-wrap items-center gap-2">
        <input
          type="text"
          value={filterText}
          onChange={event => apply(event.target.value)}
          placeholder="输入关键字进行过滤(如:销售 / 左 / 前端)"
          className="w-full max-w-sm rounded-lg border border-fd-border px-2 py-1 text-sm"
        />
        {KEYWORDS.map(word => (
          <button
            key={word}
            type="button"
            onClick={() => apply(filterText === word ? '' : word)}
            className={`rounded-lg border border-fd-border px-2 py-1 text-sm transition-colors ${
              filterText === word
                ? 'bg-fd-accent text-fd-accent-foreground'
                : 'text-fd-muted-foreground'
            }`}
          >
            {word}
          </button>
        ))}
      </div>
      <p className="text-sm text-fd-muted-foreground">{status}</p>
      <div className="overflow-x-auto">
        <OkrTree
          ref={tree}
          data={data}
          leftData={left}
          onlyBothTree
          direction="horizontal"
          nodeKey="id"
          showCollapsable
          filterNodeMethod={filterNode}
          currentLableClassName="bg-fd-primary text-fd-primary-foreground"
        />
      </div>
    </div>
  )
}

事件

事件输出(最近 8 条)

尚未触发事件,试试点击节点 / 右键 / 展开按钮。

试试:左键 / 右键卡片、点 +/− 圆盘、勾复选框、拖动卡片。复选框与拖拽为了触发对应事件才开着, 用法见各自的用例。

事件回调

14 个事件都是 onXxx 回调 prop。onNodeContextMenu 只有在你传了它的时候才会 preventDefault 掉浏览器原生菜单。

查看源码
'use client'

import { useMemo, useState } from 'react'
import { OkrTree, type TreeKey } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'
import { EventLog, LOG_LIMIT, type LogLine } from './event-log'

/**
 * 事件回调总览(对应源项目 playground/components/demos/BaseEvents.vue)
 *
 * api 表里的 14 个事件在 React 侧全是 `onXxx` 回调 prop,与 Vue 的三点差异:
 * 1. 回调参数去掉了源项目的第三参 `nodeComponent`(D2),只有 `(data, node)`;
 *    要拿 DOM 用 `handle.getNodeEl(key)`。
 * 2. `node-contextmenu` → `onNodeContextMenu`,**只有传了这个 prop 组件才 preventDefault**
 *    掉浏览器原生菜单;不传就照常弹原生菜单(要恢复原生菜单,把这条 prop 删掉即可)。
 * 3. `event` 参数是 React 合成事件(D11),原生事件在 `event.nativeEvent` 上。
 *
 * `onExpandedKeysChange` / `onCurrentKeyChange` 只在受控模式下触发(等价 Vue 的
 * `v-model:expanded-keys`,React 拆成「值 prop + 回调 prop」一对),所以本例把两个值也传了。
 */
export function EventsDemo() {
  // 变量名不叫 data:下面每个回调的首参都是源数据对象,叫 data 会把它遮住
  const treeData = useMemo(keyedData, [])
  const [lines, setLines] = useState<LogLine[]>([])
  const [expandedKeys, setExpandedKeys] = useState<TreeKey[]>([1])
  const [currentKey, setCurrentKey] = useState<TreeKey | null>(null)

  /** 最新一条插到最前,超出 LOG_LIMIT 直接丢——不截断的话点一分钟就把页面撑爆了 */
  function push(event: string, text: string) {
    setLines(prev => [{ event, text }, ...prev].slice(0, LOG_LIMIT))
  }

  return (
    <>
      <EventLog lines={lines} onClear={() => setLines([])} />
      <p className="mb-3 text-sm text-fd-muted-foreground">
        试试:左键 / 右键卡片、点 +/− 圆盘、勾复选框、拖动卡片。复选框与拖拽为了触发对应事件才开着,
        用法见各自的用例。
      </p>
      <OkrTree
        data={treeData}
        nodeKey="id"
        direction="horizontal"
        showCollapsable
        showCheckbox
        draggable
        expandedKeys={expandedKeys}
        currentKey={currentKey}
        onNodeClick={(data, node) =>
          push('onNodeClick', `「${data.label}」被点击(level ${node.level})`)
        }
        onNodeExpand={data => push('onNodeExpand', `「${data.label}」展开`)}
        onNodeCollapse={data => push('onNodeCollapse', `「${data.label}」收起`)}
        // 传了本 prop,组件才会 preventDefault 掉浏览器原生右键菜单
        onNodeContextMenu={(event, data) =>
          push(
            'onNodeContextMenu',
            `「${data.label}」右键,原生事件取 event.nativeEvent(此刻坐标 ${Math.round(
              event.clientX
            )}, ${Math.round(event.clientY)})`
          )
        }
        onCheck={(data, info) =>
          push(
            'onCheck',
            `「${data.label}」勾选变化:已选 ${info.checkedKeys.length} 个,半选 ${info.halfCheckedKeys.length} 个`
          )
        }
        onCheckChange={(data, checked, indeterminate) =>
          push(
            'onCheckChange',
            `「${data.label}」→ ${checked ? '已选' : indeterminate ? '半选' : '未选'}`
          )
        }
        onExpandedKeysChange={keys => {
          setExpandedKeys(keys)
          push('onExpandedKeysChange', `[${keys.join(', ')}]`)
        }}
        onCurrentKeyChange={key => {
          setCurrentKey(key)
          push('onCurrentKeyChange', key === null ? 'null(无选中)' : String(key))
        }}
        onNodeDragStart={node => push('onNodeDragStart', `开始拖动「${node.label}」`)}
        onNodeDragEnter={(dragging, dropNode) =>
          push('onNodeDragEnter', `「${dragging.label}」进入「${dropNode.label}」`)
        }
        onNodeDragLeave={(dragging, dropNode) =>
          push('onNodeDragLeave', `「${dragging.label}」离开「${dropNode.label}」`)
        }
        // dragover 随鼠标移动连续触发,日志会被它刷满——真要分区变化看 onNodeDrop
        onNodeDragOver={(dragging, dropNode) =>
          push('onNodeDragOver', `悬停在「${dropNode.label}」的放置区内`)
        }
        onNodeDragEnd={(dragging, dropNode, dropType) =>
          push(
            'onNodeDragEnd',
            dropNode && dropType
              ? `「${dragging.label}」结束,落点是 ${dropType}`
              : `「${dragging.label}」结束,未完成放置`
          )
        }
        onNodeDrop={(dragging, dropNode, dropType) =>
          push('onNodeDrop', `「${dragging.label}」→「${dropNode.label}」的 ${dropType}`)
        }
      />
    </>
  )
}
事件输出(最近 8 条)

尚未触发事件,试试点击节点 / 右键 / 展开按钮。

(左)研发-前端
(左)研发-后端
(左)UI 设计
(左)产品研发部
(左)销售一部
(左)销售二部
(左)销售部
(左)财务部
xxx科技有有限公司
产品研发部
研发-前端
研发-后端
UI 设计
销售部
销售一部
销售二部
财务部

OKR 模式事件

左右两棵树走同一批回调;左树节点的 node.isLeftChildtrue

查看源码
'use client'

import { useMemo, useState } from 'react'
import { OkrTree, type TreeNode } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData, leftData } from './data'
import { EventLog, LOG_LIMIT, type LogLine } from './event-log'

/**
 * OKR 模式下的事件(对应源项目 playground/components/demos/BaseEventsOkr.vue)
 *
 * 左右两棵子树由**同一个** `<OkrTree>` 渲染,所以触发的也是同一批 `onXxx` 回调——
 * 区分左右只有一个字段:`node.isLeftChild`。Vue 用例里那句「点击根节点左右两侧的 +/−
 * 分别触发 node-expand / node-collapse」在 React 下形状不变,只是从 `$emit` 换成回调。
 *
 * 勾选两棵树各自维护状态(左树 id 12–19、右树 id 1–9,根同为 id 1),
 * 所以 onCheck / onCheckChange 必须带 side 才看得清是谁变了。
 * 拖拽与受控那两类回调与本目录 events.tsx 用例完全同一批,这里不再重复挂。
 */
export function EventsOkrDemo() {
  const [data, left] = useMemo(() => [keyedData(), leftData()], [])
  const [lines, setLines] = useState<LogLine[]>([])

  const side = (node: TreeNode) => (node.isLeftChild ? '左树' : '右树')

  function push(event: string, text: string) {
    setLines(prev => [{ event, text }, ...prev].slice(0, LOG_LIMIT))
  }

  return (
    <>
      <EventLog lines={lines} onClear={() => setLines([])} />
      <OkrTree
        data={data}
        leftData={left}
        nodeKey="id"
        direction="horizontal"
        onlyBothTree
        showCollapsable
        showCheckbox
        defaultExpandAll
        onNodeClick={(data, node) => push('onNodeClick', `[${side(node)}] 「${data.label}」被点击`)}
        onNodeExpand={(data, node) => push('onNodeExpand', `[${side(node)}] 「${data.label}」展开`)}
        onNodeCollapse={(data, node) =>
          push('onNodeCollapse', `[${side(node)}] 「${data.label}」收起`)
        }
        // 只有传了这个 prop,组件才 preventDefault 掉浏览器原生右键菜单
        onNodeContextMenu={(_event, data, node) =>
          push('onNodeContextMenu', `[${side(node)}] 「${data.label}」右键`)
        }
        onCheck={(data, info) =>
          push(
            'onCheck',
            `「${data.label}」勾选变化:已选 ${info.checkedKeys.length} 个,半选 ${info.halfCheckedKeys.length} 个`
          )
        }
        onCheckChange={(data, checked, indeterminate) =>
          push(
            'onCheckChange',
            `「${data.label}」→ ${checked ? '已选' : indeterminate ? '半选' : '未选'}`
          )
        }
      />
    </>
  )
}

交互

手风琴

accordion 展开一个自动收起同级;只约束交互展开,expandAll() 这类整体操作不受影响。

查看源码
'use client'

import { useMemo, useRef } from 'react'
import { OkrTree, type OkrTreeHandle } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'

/**
 * 手风琴模式(对应源项目 playground/components/demos/BaseAccordion.vue)
 *
 * `accordion`:用户展开某个节点时自动收起同级已展开的兄弟(内部走 `collapseSiblings`)。
 * 边界与 el-tree 一致——**只约束交互展开**:点 +/− 圆盘、点卡片内容、键盘 ←/→。
 * 下面两个按钮是反例:`expandAll()` 与 `expandNode()` 这类程序化方法、以及受控
 * `expandedKeys` 都不受互斥限制,全展开后手风琴依然生效于用户的下一次点击。
 */
export function AccordionDemo() {
  const data = useMemo(keyedData, [])
  const handle = useRef<OkrTreeHandle>(null)

  return (
    <>
      <div className="mb-3 flex flex-wrap gap-2">
        <button
          type="button"
          onClick={() => handle.current?.expandAll()}
          className="rounded-lg border border-fd-border bg-fd-accent px-3 py-1 text-sm hover:bg-fd-muted"
        >
          expandAll() —— 手风琴不管它
        </button>
        <button
          type="button"
          onClick={() => handle.current?.collapseAll()}
          className="rounded-lg border border-fd-border px-3 py-1 text-sm hover:bg-fd-accent"
        >
          collapseAll()
        </button>
      </div>
      <OkrTree
        ref={handle}
        data={data}
        nodeKey="id"
        direction="horizontal"
        showCollapsable
        accordion
      />
    </>
  )
}

最近点击:

点击节点展开

expandOnClickNode:点卡片切展开态,选中与勾选照旧。

查看源码
'use client'

import { useMemo, useState } from 'react'
import { OkrTree } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'

/**
 * 点击卡片切换展开(对应源项目 playground/components/demos/BaseNodeClick.vue)
 *
 * `expandOnClickNode` 默认关闭(保持原版行为:只有 +/− 圆盘能收起)。打开后点卡片内容即切展开,
 * 选中态与 `onNodeClick` 照常触发;两个例外按源项目语义保留:
 * - **叶子节点**只选中,不切换(没有可切的东西);
 * - **OKR 根节点**只切右侧子树,左树仍由左侧圆盘控制。
 *
 * 与 `showCheckbox` 同时开启时各管各的:勾勾选框不会切换展开,点卡片也不会改勾选。
 * 两个开关都是运行时可切换的 prop(组件内有同步 effect),不需要重建。
 */
export function NodeClickDemo() {
  const data = useMemo(keyedData, [])
  const [expandOnClickNode, setExpandOnClickNode] = useState(true)
  const [showCheckbox, setShowCheckbox] = useState(false)
  const [current, setCurrent] = useState<string | null>(null)

  const btn = 'rounded-lg border border-fd-border px-3 py-1 text-sm'
  const active = 'bg-fd-accent font-medium'

  return (
    <>
      <div className="mb-3 flex flex-wrap gap-2">
        <button
          type="button"
          onClick={() => setExpandOnClickNode(v => !v)}
          className={`${btn} ${expandOnClickNode ? active : ''}`}
        >
          expandOnClickNode:{expandOnClickNode ? '开' : '关'}
        </button>
        <button
          type="button"
          onClick={() => setShowCheckbox(v => !v)}
          className={`${btn} ${showCheckbox ? active : ''}`}
        >
          showCheckbox:{showCheckbox ? '开' : '关'}
        </button>
      </div>
      <p className="mb-3 text-sm text-fd-muted-foreground">最近点击:{current ?? '无'}</p>
      <OkrTree
        data={data}
        nodeKey="id"
        direction="horizontal"
        showCollapsable
        expandOnClickNode={expandOnClickNode}
        showCheckbox={showCheckbox}
        onNodeClick={(data, node) => setCurrent(`「${data.label}」(key ${node.key})`)}
      />
    </>
  )
}
事件输出(最近 8 条)

尚未触发事件,试试点击节点 / 右键 / 展开按钮。

复选框

showCheckbox + checkStrictly(父子联动 vs 各自独立)、半选态,以及 getCheckedKeys / setCheckedKeys 的取与设。

查看源码
'use client'

import { useMemo, useRef, useState } from 'react'
import { OkrTree, type OkrTreeHandle, type TreeCheckInfo, type TreeNodeData } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'
import { EventLog, LOG_LIMIT, type LogLine } from './event-log'

/**
 * 复选框模式(对应源项目 playground/components/demos/BaseCheckbox.vue)
 *
 * `showCheckbox` 开勾选框,父子联动带半选态;`checkStrictly` 关掉联动后勾谁只影响谁(也就没有半选)。
 * `onCheck(data, info)` 只在**用户点击**勾选框时触发(`setCheckedKeys()` 不触发),
 * `onCheckChange(data, checked, indeterminate)` 则每个状态变化的节点各触发一次,含联动与批量设置。
 *
 * React 与 Vue 的差别只在读结果:`getCheckedKeys()` / `getHalfCheckedKeys()` 是命令式方法,
 * 读的是内部 store 而不是 React state,所以只能在事件回调里取——渲染期取不到,
 * 也就没法「派生出勾选态」。本例把结果写进日志面板。
 * OKR 模式下左右两树勾选独立维护,方法按 key 对两树同时生效。
 */
export function CheckboxDemo() {
  const data = useMemo(keyedData, [])
  const handle = useRef<OkrTreeHandle>(null)
  const [checkStrictly, setCheckStrictly] = useState(false)
  const [lines, setLines] = useState<LogLine[]>([])

  function push(event: string, text: string) {
    setLines(prev => [{ event, text }, ...prev].slice(0, LOG_LIMIT))
  }

  function handleCheck(data: TreeNodeData, info: TreeCheckInfo) {
    push(
      'onCheck',
      `「${data.label}」→ 已选 ${info.checkedKeys.length} 个,半选 ${info.halfCheckedKeys.length} 个`
    )
  }

  function handleCheckChange(data: TreeNodeData, checked: boolean, indeterminate: boolean) {
    push(
      'onCheckChange',
      `「${data.label}」→ ${checked ? '已选' : indeterminate ? '半选' : '未选'}`
    )
  }

  function setChecked() {
    handle.current?.setCheckedKeys([7, 8])
    push(
      'setCheckedKeys',
      checkStrictly
        ? '勾选 [7, 8](独立模式:父节点 6 不受影响)'
        : '勾选 [7, 8](联动模式:父节点 6 自动变半选)'
    )
  }

  function readKeys() {
    const h = handle.current
    if (!h) return
    push(
      'getCheckedKeys',
      `checked=[${h.getCheckedKeys().join(', ')}] half=[${h.getHalfCheckedKeys().join(', ')}]`
    )
  }

  const btn = 'rounded-lg border border-fd-border px-3 py-1 text-sm'

  return (
    <>
      <div className="mb-3 flex flex-wrap gap-2">
        <button
          type="button"
          onClick={() => setCheckStrictly(v => !v)}
          className={`${btn} ${checkStrictly ? 'bg-fd-accent font-medium' : ''}`}
        >
          checkStrictly(父子不联动):{checkStrictly ? '开' : '关'}
        </button>
        <button type="button" onClick={setChecked} className={`${btn} hover:bg-fd-accent`}>
          setCheckedKeys([7, 8])
        </button>
        <button type="button" onClick={readKeys} className={`${btn} hover:bg-fd-accent`}>
          getCheckedKeys / getHalfCheckedKeys
        </button>
      </div>
      <EventLog lines={lines} onClear={() => setLines([])} />
      <OkrTree
        ref={handle}
        data={data}
        nodeKey="id"
        direction="horizontal"
        showCollapsable
        showCheckbox
        checkStrictly={checkStrictly}
        defaultCheckedKeys={[3, 4]}
        onCheck={handleCheck}
        onCheckChange={handleCheckChange}
      />
    </>
  )
}
事件输出(最近 8 条)

尚未触发事件,试试点击节点 / 右键 / 展开按钮。

拖拽排序

draggable + allowDrag / allowDrop 谓词;卡片上/中/下三段分别是 prev / inner / next,禁止放进自身子树。

查看源码
'use client'

import { useMemo, useState } from 'react'
import { OkrTree, type DropType, type TreeNode } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'
import { EventLog, LOG_LIMIT, type LogLine } from './event-log'

/**
 * 拖拽调整层级(对应源项目 playground/components/demos/BaseDraggable.vue)
 *
 * `draggable` 开 HTML5 拖拽,落点按目标卡片的 25% / 50% / 25% 分三区:
 * prev(排在目标前)/ inner(成为目标的子节点,目标自动展开)/ next(排在目标后)。
 * **分区轴随方向换**:`direction="horizontal"` 时同层是上下排列,按 Y 轴分;
 * 默认 vertical 时同层左右排列,按 X 轴分。指示线颜色走 `--okr-drop-color`。
 *
 * 两条硬性规则不是 `allowDrop` 能改的:不可放进自身或自己的子树;OKR 下跨左右树默认禁止
 * (`allowDrop` 明确返回 true 才放开)。移动会同步回写源数据的 children。
 *
 * 回调参数与 Vue 同形,只是没有第三参 `nodeComponent`(D2):
 * `onNodeDrop(draggingNode, dropNode, dropType)` 三个都是内部 TreeNode。
 * 程序化入口是 `handle.moveNode(7, 2, 'inner')`,规则一致(本例未挂按钮)。
 */
export function DraggableDemo() {
  const data = useMemo(keyedData, [])
  const [lines, setLines] = useState<LogLine[]>([])

  function push(event: string, text: string) {
    setLines(prev => [{ event, text }, ...prev].slice(0, LOG_LIMIT))
  }

  /** 示例规则:财务部(id 9)不许被拖走 */
  function allowDrag(node: TreeNode) {
    return node.key !== 9
  }

  /** 示例规则:财务部也不能当落点;叶子不接受 inner(放成子节点后它就成了父级) */
  function allowDrop(_dragging: TreeNode, target: TreeNode, type: DropType) {
    if (target.key === 9) return false
    return type !== 'inner' || !target.isLeaf
  }

  return (
    <>
      <EventLog lines={lines} onClear={() => setLines([])} />
      <OkrTree
        data={data}
        nodeKey="id"
        direction="horizontal"
        showCollapsable
        draggable
        allowDrag={allowDrag}
        allowDrop={allowDrop}
        onNodeDragStart={node => push('onNodeDragStart', `开始拖动「${node.label}」`)}
        onNodeDrop={(dragging, target, type) =>
          push('onNodeDrop', `「${dragging.label}」→「${target.label}」的 ${type}`)
        }
        onNodeDragEnd={(dragging, target, type) =>
          push(
            'onNodeDragEnd',
            target && type ? `「${dragging.label}」放置完成` : `「${dragging.label}」未完成放置`
          )
        }
      />
    </>
  )
}
形状(仅 svg 生效)

SVG 连接线

connector="svg" 换成覆盖层路径,布局零改动;三种形状在收起子树时会跟着重绘。

查看源码
'use client'

import { useMemo, useState } from 'react'
import { OkrTree, type ConnectorMode, type ConnectorShape } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'

/**
 * SVG 连接线(对应源项目 playground/components/demos/BaseConnector.vue)
 *
 * `connector="css"`(默认,伪元素像素几何)与 `connector="svg"`(覆盖层路径)可运行时切换,
 * 两者**布局完全一致**,svg 只替换线条渲染,随展开收起、`animate` 过渡、尺寸变化自动重绘。
 * `connectorShape` 三种形状只在 svg 模式下有意义,所以非 svg 时把形状按钮 `disabled` 掉——
 * 传了也不会生效,按钮状态比一句提示诚实。
 * 线色线宽继续走 `--okr-line-color` / `--okr-line-width`,主题变量与 `--okr-drop-color` 通用。
 */
export function ConnectorDemo() {
  const data = useMemo(keyedData, [])
  const [connector, setConnector] = useState<ConnectorMode>('svg')
  const [connectorShape, setConnectorShape] = useState<ConnectorShape>('curve')

  const btn =
    'rounded-lg border border-fd-border px-3 py-1 text-sm disabled:cursor-not-allowed disabled:opacity-50'

  return (
    <>
      <div className="mb-3 flex flex-wrap items-center gap-2">
        {(['css', 'svg'] as const).map(mode => (
          <button
            key={mode}
            type="button"
            onClick={() => setConnector(mode)}
            className={`${btn} ${connector === mode ? 'bg-fd-accent font-medium' : ''}`}
          >
            connector="{mode}"
          </button>
        ))}
        <span className="text-sm text-fd-muted-foreground">形状(仅 svg 生效)</span>
        {(['curve', 'orthogonal', 'straight'] as const).map(shape => (
          <button
            key={shape}
            type="button"
            disabled={connector !== 'svg'}
            onClick={() => setConnectorShape(shape)}
            className={`${btn} ${connectorShape === shape ? 'bg-fd-accent font-medium' : ''}`}
          >
            {shape}
          </button>
        ))}
      </div>
      <OkrTree
        data={data}
        nodeKey="id"
        direction="horizontal"
        showCollapsable
        defaultExpandedKeys={[1]}
        animate
        connector={connector}
        connectorShape={connectorShape}
      />
    </>
  )
}

本页目录