协同编辑

GitHub在 GitHub 上编辑
Univer Pro
需要服务端部分功能依赖服务端支持

注意事项

协同编辑功能需要 Univer 服务端支持,请确保你已经正确安装并配置了 Univer 服务端。具体请参考:升级到 Pro

协同编辑功能允许多个用户同时编辑同一文档,实时同步更改,适用于团队协作和多人编辑场景。

预设模式

安装

@univerjs/preset-sheets-collaboration 的 UniveSheetsCollaborationPreset 预设在运行时依赖 UniverSheetsDrawingPresetUniverSheetsAdvancedPreset 预设,请先安装 @univerjs/preset-sheets-drawing 和 @univerjs/preset-sheets-advanced。

npm install @univerjs/preset-sheets-drawing @univerjs/preset-sheets-advanced @univerjs/preset-sheets-collaboration

使用

import { UniverSheetsAdvancedPreset } from '@univerjs/preset-sheets-advanced'
import UniverPresetSheetsAdvancedZhCN from '@univerjs/preset-sheets-advanced/locales/zh-CN'
import { UniverSheetsCollaborationPreset } from '@univerjs/preset-sheets-collaboration'
import UniverPresetSheetsCollaborationZhCN from '@univerjs/preset-sheets-collaboration/locales/zh-CN'
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core'
import UniverPresetSheetsCoreZhCN from '@univerjs/preset-sheets-core/locales/zh-CN'
import { UniverSheetsDrawingPreset } from '@univerjs/preset-sheets-drawing'
import UniverPresetSheetsDrawingZhCN from '@univerjs/preset-sheets-drawing/locales/zh-CN'
import { createUniver, LocaleType, mergeLocales } from '@univerjs/presets'

import '@univerjs/preset-sheets-core/lib/index.css'
import '@univerjs/preset-sheets-drawing/lib/index.css'
import '@univerjs/preset-sheets-advanced/lib/index.css'
import '@univerjs/preset-sheets-collaboration/lib/index.css'

const { univerAPI } = createUniver({
  locale: LocaleType.ZH_CN,
  locales: {
    [LocaleType.ZH_CN]: mergeLocales(
      UniverPresetSheetsCoreZhCN,
      UniverPresetSheetsDrawingZhCN, 
      UniverPresetSheetsAdvancedZhCN, 
      UniverPresetSheetsCollaborationZhCN, 
    ),
  },
  collaboration: true, 
  presets: [
    UniverSheetsCorePreset(),
    UniverSheetsDrawingPreset({ 
      collaboration: true, 
    }), 
    UniverSheetsAdvancedPreset({ 
      // 配置高级功能
    }), 
    UniverSheetsCollaborationPreset({ 
      universerEndpoint: 'http://localhost:3010', 
    }), 
  ],
})

如果你拥有 Univer 的商业许可证,请参考在客户端使用许可证进行配置。

预设与配置

interface IUniverSheetsCollaborationPresetConfig {
  /**
   * 是否上报前端异常日志到 Universer,以便于通过搜索 `Frontend` 关键字查看日志。
   * @default false
   */
  enableFrontendLog?: boolean
}

插件模式

安装

npm install @univerjs-pro/collaboration @univerjs-pro/collaboration-client @univerjs-pro/collaboration-client-ui

使用

import { UniverCollaborationPlugin } from '@univerjs-pro/collaboration'
import { UniverCollaborationClientPlugin } from '@univerjs-pro/collaboration-client'
import { BrowserCollaborationSocketService, UniverCollaborationClientUIPlugin } from '@univerjs-pro/collaboration-client-ui'
import CollaborationClientZhCN from '@univerjs-pro/collaboration-client/locale/zh-CN'
import { IAuthzIoService, IMentionIOService, IUndoRedoService, LocaleType, mergeLocales, Univer } from '@univerjs/core'

import '@univerjs-pro/collaboration-client/facade'
import '@univerjs-pro/collaboration-client-ui/facade'

import '@univerjs-pro/collaboration-client/lib/index.css'

const univer = new Univer({
  locale: LocaleType.ZH_CN,
  locales: {
    [LocaleType.ZH_CN]: mergeLocales(
      CollaborationClientZhCN, 
    ),
  },
})

// 通过将 override 选项设置为 [[IAuthzIoService, null]],可以告诉 Univer 不要注册内置的 IAuthzIoService。
// 通过将 override 选项设置为 [[IUndoRedoService, null]],可以告诉 Univer 不要注册内置的 IUndoRedoService。
// 通过将 override 选项设置为 [[IMentionIOService, null]],可以告诉 Univer 不要注册内置的 IMentionIOService。
// 这样,Univer 将使用 UniverCollaborationPlugin 中提供的服务作为权限、撤销/重做和提及功能的实现。
const univer = new Univer({
  override: [ 
    [IAuthzIoService, null], 
    [IUndoRedoService, null], 
    [IMentionIOService, null], 
  ], 
})

const serverEndpoint = 'http://localhost:3010'

univer.registerPlugin(UniverCollaborationPlugin) 
univer.registerPlugin(UniverCollaborationClientPlugin, { 
  socketService: BrowserCollaborationSocketService, 
  authzUrl: `${serverEndpoint}/universer-api/authz`, 
  snapshotServerUrl: `${serverEndpoint}/universer-api/snapshot`, 
  collabSubmitChangesetUrl: `${serverEndpoint}/universer-api/comb`, 
  collabWebSocketUrl: 'ws://localhost:3010/universer-api/comb/connect', 
  loginUrlKey: `${serverEndpoint}/universer-api/oidc/authpage`, 
  uploadFileServerUrl: `${serverEndpoint}/universer-api/stream/file/upload`, 
  signUrlServerUrl: `${serverEndpoint}/universer-api/file/{fileID}/sign-url`, 
  downloadEndpointUrl: `${serverEndpoint}/`, 
  wsSessionTicketUrl: `${serverEndpoint}/universer-api/user/session-ticket`, 
  startFormulaLimitUrl: `${serverEndpoint}/universer-api/license/formula/limit/start`, 
  getFormulaLimitStatusUrl: `${serverEndpoint}/universer-api/license/formula/limit/status`, 
  releaseFormulaLimitUrl: `${serverEndpoint}/universer-api/license/formula/limit/done`, 
  sendChangesetTimeout: 200, 
}) 
univer.registerPlugin(UniverCollaborationClientUIPlugin) 

如果你拥有 Univer 的商业许可证,请参考在客户端使用许可证进行配置。

插件与配置

interface IUniverCollaborationClientUIConfig {
  /**
   * 是否上报前端异常日志到 Universer,以便于通过搜索 `Frontend` 关键字查看日志。
   * @default false
   */
  enableFrontendLog?: boolean
}

协同文档数据

协同编辑插件依赖 Univer 服务,协同文档的数据存储在 Univer 服务中。

unitId

每篇协同文档在 Univer 服务中都有一个唯一的 unitId。Univer 协同客户端使用 unitId 可从 Univer 服务获取到对应的协同文档数据进行协同编辑。

type

type 是协同文档的类型,协同文档的类型决定了协同文档的初始数据结构。

创建协同文档

有多种方式在 Univer 服务中创建协同文档:

  • 通过创建文档接口可以创建一篇空白文档。
  • 通过导入插件提供的 FUniver.importXLSXToUnitIdAsync 方法,将 .xlsx 文件导入到 Univer 服务。

通过 URL 参数加载协同文档

@univerjs-pro/collaboration-client 插件内部提供了根据 URL 参数 unittype 自动加载对应的数据的功能,这可以简化一些场景下的数据加载逻辑。

如果你想使用该特性,你需要适当地修改一下原有的加载数据逻辑,并将 unittype 参数添加到 URL 中,如下所示:

import { UniverInstanceType } from '@univerjs/core'
// 预设模式下 `UniverInstanceType` 可从 @univerjs/presets 导入
import { UniverInstanceType } from '@univerjs/presets'

// 原有逻辑,只适用于非协同文档
univer.createUnit(UniverInstanceType.UNIVER_SHEET, {}) 

// 修改后的逻辑,适用于协同文档
const url = new URL(window.location.href) 
const unit = url.searchParams.get('unit') 

if (unit) { 
  // 如果 URL 中包含 unit 参数,则自动加载数据
} else { 
  // 创建一个新的文档
  fetch(`/universer-api/snapshot/${UniverInstanceType.UNIVER_SHEET}/unit/-/create`, { 
    method: 'POST', 
    headers: { 
      'Content-Type': 'application/json', 
    }, 
    body: JSON.stringify({ 
      type: UniverInstanceType.UNIVER_SHEET, // instance type
      name: 'New Sheet By Univer', // workbook name
      creator: 'user', // creator name
    }), 
  }).then((response) => { 
    if (!response.ok) { 
      throw new Error('create unit failed') 
    } 
    return response.json() 
  }).then((data) => { 
    if (!data.unitID) { 
      throw new Error('create unit failed') 
    } 
    url.searchParams.set('unit', data.unitID) 
    url.searchParams.set('type', String(UniverInstanceType.UNIVER_SHEET)) 
    window.location.href = url.toString() 
  }).catch((error) => { 
    console.error(error) 
  }) 
} 

Facade API

引入

插件模式提示

仅插件模式需要手动引入 Facade 包。预设模式已内置对应的 Facade 包,无需额外导入。

import '@univerjs-pro/collaboration-client/facade'
import '@univerjs-pro/collaboration-client-ui/facade'

加载协同文档

如果你不想使用 URL 参数来加载协同文档,你也可以通过 Facade API 来加载协同文档。

const collaboration = univerAPI.getCollaboration()
const workbook = await collaboration.loadSheetAsync('your-unit-id')

协同在 Node.js 环境下的使用示例

examples/sheets-node/main.ts
import { FUniver } from '@univerjs/core/facade'
import { createUniverOnNode } from './univer'

async function run(): Promise<void> {
  const univer = createUniverOnNode()
  const univerAPI = FUniver.newAPI(univer)

  await univerAPI.getCollaboration().loadSheetAsync('your-unit-id')

  TestSheetsFacadeAPI(univerAPI)
  TestSheetsConditionalFormattingAPI(univerAPI)
}

run()

async function TestSheetsFacadeAPI(univerAPI: FUniver) {
  const fWorkbook = univerAPI.getActiveWorkbook()
  if (!fWorkbook) return

  // Set workbook name
  fWorkbook.setName('Test Workbook - Node Collaboration Client')

  const fWorksheet = fWorkbook.getActiveSheet()

  // Set a value in a cell A1
  fWorksheet.getRange('A1').setValue(`Hello! message from the Node Collaboration Client! The current time is: ${new Date().toString()}.`)

  // Merge C2:E4 range cells
  const fRange2 = fWorksheet.getRange('C2:E4')
  if (fRange2.isPartOfMerge() || fRange2.isMerged()) {
    fRange2.breakApart()
  }
  fRange2.merge().setValue(1234).setNumberFormat('#,##0.00')

  // Create a defined name for cell A1 and use it in cell A6
  const definedNameBuilder = univerAPI.newDefinedName()
    .setRef(`${fWorksheet.getSheetName()}!$A$1`)
    .setName('MyDefinedName')
    .build()
  fWorkbook.insertDefinedNameBuilder(definedNameBuilder)
  fWorksheet.getRange('A6').setValue('=MyDefinedName')

  // Insert a new sheet
  const newSheet = fWorkbook.insertSheet('New Sheet from Node Client', {
    index: 1,
    sheet: {
      rowCount: 100,
      columnCount: 20,
      cellData: {
        0: {
          0: {
            v: 1,
          },
        },
        1: {
          1: {
            v: 2,
          },
        },
        2: {
          2: {
            v: 3,
          },
        },
        3: {
          3: {
            v: 4,
          },
        },
      },
    },
  })

  // Insert 3 rows after row 3 (0-based index).
  newSheet.insertRowsAfter(2, 3)

  const sheets = fWorkbook.getSheets()
  if (sheets.length) {
    // Rename the first sheet and set its row and column count
    sheets[0].setName('Renamed Sheet 1').setRowCount(100).setColumnCount(10)
    // Freeze first row and first column in the first sheet
    sheets[0].setFrozenRows(1).setFrozenColumns(1)
    // Set grid lines color to red in the first sheet
    sheets[0].setGridLinesColor('#FF0000')
    // Get data range of the first sheet and log its A1 notation
    const dataRange = sheets[0].getDataRange()
    console.warn(`Renamed Sheet 1 Data Range A1 Notation: ${dataRange.getA1Notation()}`)
    // Clear content of the data range after 5 seconds
    setTimeout(() => {
      dataRange.clearContent()
    }, 5000)
  }
}

async function TestSheetsConditionalFormattingAPI(univerAPI: FUniver) {
  const fWorkbook = univerAPI.getActiveWorkbook()
  if (!fWorkbook) return

  const newSheet = fWorkbook.insertSheet('Conditional Formatting Sheet', {
    sheet: {
      cellData: {
        0: {
          0: { v: 1, t: 2 },
          1: { v: 2, t: 2 },
          2: { v: 3, t: 2 },
          3: { v: 4, t: 2 },
        },
        1: {
          0: { v: 2, t: 2 },
          1: { v: 3, t: 2 },
          2: { v: 4, t: 2 },
          3: { v: 5, t: 2 },
        },
        2: {
          0: { v: 3, t: 2 },
          1: { v: 4, t: 2 },
          2: { v: 5, t: 2 },
          3: { v: 6, t: 2 },
        },
        3: {
          0: { v: 4, t: 2 },
          1: { v: 5, t: 2 },
          2: { v: 6, t: 2 },
          3: { v: 7, t: 2 },
        },
      },
    },
  })
  // Add conditional formatting rule for range A1:D4: italic font, red background, green font color when cell value > 3
  const fRange = newSheet.getRange('A1:D4')
  const rule = newSheet.newConditionalFormattingRule()
    .whenNumberGreaterThan(3)
    .setRanges([fRange.getRange()])
    .setItalic(true)
    .setBackground('red')
    .setFontColor('green')
    .build()
  newSheet.addConditionalFormattingRule(rule)
}
examples/sheets-node/univer.ts
import path from 'node:path'
import { UniverCollaborationPlugin } from '@univerjs-pro/collaboration'
import { UniverCollaborationClientPlugin } from '@univerjs-pro/collaboration-client'
import { NodeCollaborationSocketService, UniverCollaborationClientNodePlugin } from '@univerjs-pro/collaboration-client-node'
import { UniverSheetsChartPlugin } from '@univerjs-pro/sheets-chart'
import { UniverSheetsPivotTablePlugin } from '@univerjs-pro/sheets-pivot'
import { UniverSheetSparklinePlugin } from '@univerjs-pro/sheets-sparkline'
import { IAuthzIoService, IUndoRedoService, LocaleType, Univer } from '@univerjs/core'
import { UniverDataValidationPlugin } from '@univerjs/data-validation'
import { UniverDocsPlugin } from '@univerjs/docs'
import { UniverDocsDrawingPlugin } from '@univerjs/docs-drawing'
import { UniverDrawingPlugin } from '@univerjs/drawing'
import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula'
import { UniverNetworkPlugin } from '@univerjs/network'
import { UniverRPCNodeMainPlugin } from '@univerjs/rpc-node'
import { UniverSheetsPlugin } from '@univerjs/sheets'
import { UniverSheetsConditionalFormattingPlugin } from '@univerjs/sheets-conditional-formatting'
import { UniverSheetsDataValidationPlugin } from '@univerjs/sheets-data-validation'
import { UniverSheetsDrawingPlugin } from '@univerjs/sheets-drawing'
import { UniverSheetsFilterPlugin } from '@univerjs/sheets-filter'
import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'
import { UniverSheetsHyperLinkPlugin } from '@univerjs/sheets-hyper-link'
import { UniverSheetsNotePlugin } from '@univerjs/sheets-note'
import { UniverSheetsNumfmtPlugin } from '@univerjs/sheets-numfmt'
import { UniverSheetsSortPlugin } from '@univerjs/sheets-sort'
import { UniverSheetsTablePlugin } from '@univerjs/sheets-table'
import { UniverThreadCommentPlugin } from '@univerjs/thread-comment'

import '@univerjs/engine-formula/facade'
import '@univerjs/sheets/facade'
import '@univerjs/sheets-formula/facade'
import '@univerjs/sheets-numfmt/facade'
import '@univerjs/sheets-conditional-formatting/facade'
import '@univerjs/sheets-data-validation/facade'
import '@univerjs/sheets-filter/facade'
import '@univerjs/sheets-hyper-link/facade'
import '@univerjs/sheets-note/facade'
import '@univerjs/sheets-table/facade'
import '@univerjs/sheets-thread-comment/facade'
import '@univerjs-pro/sheets-pivot/facade'
import '@univerjs-pro/sheets-sparkline/facade'
import '@univerjs-pro/collaboration-client/facade'

export interface ICreateUniverOnNodeOptions {
  useComputingWorker?: boolean
}

export function createUniverOnNode(options: ICreateUniverOnNodeOptions = {}): Univer {
  const { useComputingWorker = false } = options

  const univer = new Univer({
    locale: LocaleType.ZH_CN,
    locales: {},
    override: [
      [IAuthzIoService, null],
      [IUndoRedoService, null],
    ],
  })

  registerBasicPlugins(univer, useComputingWorker)
  registerSharedPlugins(univer)

  if (useComputingWorker) {
    registerRPCPlugin(univer)
  }

  registerDocPlugins(univer)
  registerSheetPlugins(univer)
  registerCollaborationPlugins(univer)

  return univer
}

function registerBasicPlugins(univer: Univer, useComputingWorker: boolean): void {
  univer.registerPlugin(UniverFormulaEnginePlugin, { notExecuteFormula: useComputingWorker })
}

function registerSharedPlugins(univer: Univer): void {
  univer.registerPlugin(UniverThreadCommentPlugin)
  univer.registerPlugin(UniverDrawingPlugin)
}

function registerDocPlugins(univer: Univer): void {
  univer.registerPlugin(UniverDocsPlugin)
  univer.registerPlugin(UniverDocsDrawingPlugin)
}

function registerSheetPlugins(univer: Univer): void {
  univer.registerPlugin(UniverSheetsPlugin)
  univer.registerPlugin(UniverSheetsFormulaPlugin)
  univer.registerPlugin(UniverSheetsNumfmtPlugin)
  univer.registerPlugin(UniverSheetsConditionalFormattingPlugin)
  univer.registerPlugin(UniverDataValidationPlugin)
  univer.registerPlugin(UniverSheetsDataValidationPlugin)
  univer.registerPlugin(UniverSheetsFilterPlugin)
  univer.registerPlugin(UniverSheetsHyperLinkPlugin)
  univer.registerPlugin(UniverSheetsNotePlugin)
  univer.registerPlugin(UniverSheetsDrawingPlugin)
  univer.registerPlugin(UniverSheetsSortPlugin)
  univer.registerPlugin(UniverSheetsTablePlugin)
  univer.registerPlugin(UniverSheetsChartPlugin)
  univer.registerPlugin(UniverSheetsPivotTablePlugin)
  univer.registerPlugin(UniverSheetSparklinePlugin)
}

function registerRPCPlugin(univer: Univer): void {
  const childPath = path.join(__dirname, '../sdk/worker.js')
  univer.registerPlugin(UniverRPCNodeMainPlugin, { workerSrc: childPath })
}

function registerCollaborationPlugins(univer: Univer): void {
  univer.registerPlugin(UniverNetworkPlugin)
  univer.registerPlugin(UniverCollaborationPlugin)
  univer.registerPlugin(UniverCollaborationClientPlugin, {
    socketService: NodeCollaborationSocketService,
    enableOfflineEditing: false,
    enableSingleActiveInstanceLock: false,
    snapshotServerUrl: 'https://dev.univer.plus/universer-api/snapshot',
    collabSubmitChangesetUrl: 'https://dev.univer.plus/universer-api/comb',
    collabWebSocketUrl: 'http://dev.univer.plus/universer-api/comb/connect',
    wsSessionTicketUrl: 'http://dev.univer.plus/universer-api/user/session-ticket',
    sendChangesetTimeout: 200,
    retryConnectingInterval: 1000,
    customHeaders: {
      // Example: If your Univer server requires authentication via cookies, you can set the 'cookie' header here.
      cookie: '_univer=GY3WYUTBIJ2FEVDRI5FFI6SONN2XONDGGJAW2',
      // ... more headers
    },
  })
  univer.registerPlugin(UniverCollaborationClientNodePlugin)
}
examples/sheets-node/worker.ts
import { LocaleType, Univer } from '@univerjs/core'
import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula'
import { UniverRPCNodeWorkerPlugin } from '@univerjs/rpc-node'
import { UniverSheetsPlugin } from '@univerjs/sheets'

const univer = new Univer({
  locale: LocaleType.ZH_CN,
})

univer.registerPlugin(UniverSheetsPlugin, { onlyRegisterFormulaRelatedMutations: true })
univer.registerPlugin(UniverFormulaEnginePlugin)
univer.registerPlugin(UniverRPCNodeWorkerPlugin)

拓展阅读

如果你想进一步了解协同编辑的工作原理,可以阅读以下文章:

你觉得这篇文档如何?