すべてのプロダクト
Search
ドキュメントセンター

Intelligent Media Management:会議でコンテンツをプレゼンテーションする

最終更新日:Apr 01, 2025

このトピックでは、WebOffice のオンラインプレビューおよび編集機能を使用して、オンライン会議中にドキュメントファイルをプレゼンテーションする方法について説明します。

実装の概要

  • オンラインドキュメントのプレビューおよび編集機能を統合します。

  • WebSocket を使用して、プレゼンターによるリアルタイムのドキュメント操作の同期、視聴者へのドキュメントの初期化、プレゼンターによるドキュメントタイプの切り替えの同期など、同期されたドキュメント共有を実装します。

  • js-sdk の API 操作を使用して、プレゼンター関連のイベントと視聴者による操作をリッスンして同期します。

  • Word ファイル、スプレッドシート、プレゼンテーション、 PDF ファイルの同期共有を実装します。

  • ページの切り替え、スクロール、再生、選択などのイベントの同期をサポートします。

  • 会議リンクを共有して、ユーザーが会議にすばやく参加できるようにします。

image

フロントエンドロジック

サンプルファイル office-sync.ts は、フロントロジックを提供します。

// プレゼンテーションを定義します。
class Presentation {
  wsServer: WebSocket
  ins: any
  app: any
  meetingInfo: any
  cacheInfo = {} // ドキュメント操作をキャッシュします。
  optionType = {
    SLIDESHOWONNEXT: { name: 'SlideShowOnNext' }, // スライドショーモードで次のスライドに移動したときにトリガーされます。
    SLIDESHOWONPREVIOUS: { name: 'SlideShowOnPrevious' }, // スライドショーモードで前のスライドに移動したときにトリガーされます。
    SLIDEMEDIACHANGED: { name: 'SlideMediaChanged' }, // メディアの再生ステータスが変更されたときにトリガーされます。
    SLIDEPLAYERCHANGE: { name: 'SlidePlayerChange' }, // スライドショーのステータスが変更されたときにトリガーされます。
    SLIDELASERPENINKPOINTSCHANGED: { name: 'SlideLaserPenInkPointsChanged' }, // レーザーポインターのインクを送信します。
    SLIDESHOWBEGIN: { name: 'SlideShowBegin' }, // スライドショーを開始します。
    SLIDESHOWEND: { name: 'SlideShowEnd' }, // スライドショーを終了します。
    SLIDEINKVISIBLE: { name: 'SlideInkVisible' }, // アノテーションを表示するかどうか。
    SLIDEINKTOOLBARVISIBLE: { name: 'SlideInkToolbarVisible' }, // レーザーポインターとツールバーを使用するかどうか。
    SLIDESELECTIONCHANGED: { name: 'SlideSelectionChanged' }, // 選択範囲が変更されます。これは、スライドショーモード以外でのドキュメントの同期に関係します。
  }

  constructor(wsServer: WebSocket, meetingInfo: any, app: any, ins: any) {
    this.wsServer = wsServer
    this.meetingInfo = meetingInfo
    this.app = app
    this.ins = ins
  }

  // プレゼンターによるプレゼンテーションイベントのイベントリスナーを初期化します。
  speakerInit() {
    Object.keys(this.optionType).forEach(key => {
      this.ins.ApiEvent.AddApiEventListener(this.optionType[key].name, async (e: any) => {
        this.postMessage({ type: this.optionType[key].name, value: e })
        this.cacheInfo[this.optionType[key].name] = e
        // スライドショーモードでは、右クリックメニュー、ホバー効果、リンクを非表示にします。
        if (this.optionType[key].name === this.optionType.SLIDESHOWBEGIN.name) {
          delete this.cacheInfo[this.optionType.SLIDESHOWEND.name]
          await this._setMenusVisible(false)
        } else if (this.optionType[key].name === this.optionType.SLIDESHOWEND.name) {
          delete this.cacheInfo[this.optionType.SLIDESHOWBEGIN.name]
          await this._setMenusVisible(true)
        }
      })
    })
  }
  /**
   * プレゼンターの初期化メッセージを視聴者にプッシュします。
   * @param isPlay スライドショーモードを使用するかどうか。
   */
  async sendInitInfo(isPlay?: boolean) {
    // ファイルが初期化された後、自動的にスライドショーモードに入ります。
    const params = {}
    if (isPlay) {
      params[this.optionType.SLIDESHOWBEGIN.name] = {}
      this.cacheInfo[this.optionType.SLIDESHOWBEGIN.name] = {}
      await this.app.ActivePresentation.SlideShowSettings.Run()
    }

    // 初期化は、ページ番号とアニメーション設定を除き、キャッシュ設定に基づいています。
    const filterKey = [
      this.optionType.SLIDESHOWONNEXT.name,
      this.optionType.SLIDESHOWONPREVIOUS.name,
      this.optionType.SLIDEPLAYERCHANGE.name,
    ]
    Object.keys(this.cacheInfo).forEach(key => {
      if (filterKey.indexOf(key) < 0) {
        params[key] = this.cacheInfo[key]
      }
    })
    this.postMessage({ type: 'init', value: params })
  }

  // 視聴者の応答アクションを初期化します。
  async listenerInit(optionInfo: any) {
    switch (optionInfo.type) {
      case this.optionType.SLIDESHOWONNEXT.name: {
        await this.app.ActivePresentation.SlideShowWindow.View.GotoNextClick()
        break
      }
      case this.optionType.SLIDESHOWONPREVIOUS.name: {
        await this.app.ActivePresentation.SlideShowWindow.View.GotoPreClick()
        break
      }
      case this.optionType.SLIDEPLAYERCHANGE.name: {
        if (
          optionInfo.value.Data.action === 'switchTo' ||
          (optionInfo.value.Data.action === 'effectFinish' && optionInfo.value.Data.isLastEffect)
        ) {
          this._setPageAndAnimate(optionInfo)
        }
        break
      }
      case this.optionType.SLIDESHOWBEGIN.name:
        await this.app.ActivePresentation.SlideShowSettings.Run()
        await this._setMenusVisible(false)
        break
      case this.optionType.SLIDESHOWEND.name:
        await this.app.ActivePresentation.SlideShowWindow.View.Exit()
        await this._setMenusVisible(true)
        break
      case this.optionType.SLIDESELECTIONCHANGED.name: {
        const playMode = await this.app.ActivePresentation.SlideShowWindow.View.State
        if (playMode !== 'play') {
          await this.app.ActivePresentation.SlideShowWindow.View.GotoSlide(
            optionInfo.value,
          )
        }
        break
      }
      case this.optionType.SLIDEINKVISIBLE.name:
        this.app.ActivePresentation.SlideShowWindow.View.PointerVisible =
          optionInfo.value.Data.showmark
        break
      case this.optionType.SLIDELASERPENINKPOINTSCHANGED.name:
        // システムがレーザーポインターインクイベントを検出すると、コールバックデータを受信した後に直接呼び出しを行います。
        await this.app.ActivePresentation.SlideShowWindow.View.SetLaserPenData({
          Data: optionInfo.value.Data,
        })
        break
      case this.optionType.SLIDEINKTOOLBARVISIBLE.name:
        this.app.ActivePresentation.SlideShowWindow.View.MarkerEditVisible =
          optionInfo.value.Data.show
        break
      case this.optionType.SLIDEMEDIACHANGED.name:
        await this.app.ActivePresentation.SlideShowWindow.View.SetMediaObj({
          Data: optionInfo.value.Data,
        })
        break
      default:
        break
    }
  }

  /**
   * メッセージをプッシュします。
   * @param data メッセージ。
   */
  postMessage = (data: any) => {
    const { meetId, user } = this.meetingInfo
    this.wsServer.send(JSON.stringify({ id: user.id, meetId, data }))
  }

  // スライドショーモードで右クリックメニュー、ホバー効果、リンクを表示するかどうかを指定します。
  async _setMenusVisible(flag: boolean) {
    const linkTip = app.Enum.PpToolType.pcPlayHoverLink // ホバーリンクツールを表します。
    const imageTip = app.Enum.PpToolType.pcImageHoverTip // ホバーイメージツールを表します。
    const menu = this.app.Enum.PpToolType.pcPlayingMenu // 右クリックメニューを表します。
    await this.app.ActivePresentation.SlideShowWindow.View.SetToolVisible(linkTip, flag)
    await this.app.ActivePresentation.SlideShowWindow.View.SetToolVisible(imageTip, flag)
    await this.app.ActivePresentation.SlideShowWindow.View.SetToolVisible(menu, flag)
  }

  /**
   * スライドとアニメーション間の同期を設定します。
   * @param optionInfo 同期情報。
   */
  async _setPageAndAnimate(optionInfo) {
    // try {
    const slideIndex =
      await this.app.ActivePresentation.SlideShowWindow.View.Slide.SlideIndex
    const clickIndex =
      await this.app.ActivePresentation.SlideShowWindow.View.GetClickIndex()
    if (slideIndex !== optionInfo.value.Data.slideIndex + 1) {
      await this.app.ActivePresentation.SlideShowWindow.View.GotoSlide(
        optionInfo.value.Data.slideIndex + 1,
      )
    }
    if (clickIndex !== optionInfo.value.Data.animateIndex + 1) {
      await this.app.ActivePresentation.SlideShowWindow.View.GotoClick(
        optionInfo.value.Data.animateIndex + 2,
      )
    }
    // } catch (e) {
    //   console.error(e)
    // }
  }
}
// PDF オブジェクト
class Pdf {
  wsServer: WebSocket
  ins: any
  meetingInfo: any
  app: any
  optionType = {
    ZOOM: { name: 'ZoomUpdated' }, // ズーム
    SCROLL: { name: 'Scroll' }, // スクロール
    PAGECHANGE: { name: 'CurrentPageChange' }, // ページ切り替え
    PAGESTARTPLAY: { name: 'StartPlay' }, // 再生開始
    PAGEENDPLAY: { name: 'EndPlay' }, // 再生終了
  }
  cacheScroll: any

  constructor(wsServer: WebSocket, meetingInfo: any, app: any, ins: any) {
    this.wsServer = wsServer
    this.meetingInfo = meetingInfo
    this.app = app
    this.ins = ins
  }

  // プレゼンターによる PDF イベントのイベントリスナーを初期化します。
  speakerInit() {
    Object.keys(this.optionType).forEach(key => {
      this.ins.ApiEvent.AddApiEventListener(this.optionType[key].name, async (e: any) => {
        let message = e
        if (this.optionType[key].name === this.optionType.SCROLL.name) {
          const zoom = await this.app.ActivePDF.Zoom
          message = { scroll: e, zoom }
        } else if (this.optionType[key].name === this.optionType.ZOOM.name) {
          const scroll = await this.app.ActivePDF.Scroll
          message = { scroll, zoom: e }
        }
        this.postMessage({ type: this.optionType[key].name, value: message })
      })
    })
  }

  async sendInitInfo() {
    const zoom = await this.app.ActivePDF.Zoom
    const scroll = await this.app.ActivePDF.Scroll
    const playMode = await this.app.ActivePDF.PlayMode
    this.postMessage({
      type: 'init',
      value: {
        [this.optionType.ZOOM.name]: { zoom, scroll },
        ...(playMode
          ?  { [this.optionType.PAGESTARTPLAY.name]: true }
          : { [this.optionType.PAGEENDPLAY.name]: true }),
      },
    })
  }

  // 視聴者の応答アクションを初期化します。
  async listenerInit(optionInfo: any) {
    switch (optionInfo.type) {
      case this.optionType.SCROLL.name: {
        const zoom = await this.app.ActivePDF.Zoom
        const x = (optionInfo.value.scroll.ScrollX / optionInfo.value.zoom) * zoom
        const y = (optionInfo.value.scroll.ScrollY / optionInfo.value.zoom) * zoom
        this.app.ActivePDF.ScrollTo(x, y)
        this.cacheScroll = { x, y }
        break
      }
      case this.optionType.ZOOM.name:
        this.app.ActivePDF.Zoom = optionInfo.value.zoom
        // ズーム後にスクロール位置を設定します。
        this.app.ActivePDF.ScrollTo(optionInfo.value.scroll.x, optionInfo.value.scroll.y)
        break
      case this.optionType.PAGECHANGE.name: {
        // スクロールに基づいてページ変更を判断します。これは、非連続ページモードでのみ有効です。
        setTimeout(async () => {
          const scroll = await this.app.ActivePDF.Scroll
          if (
            Math.abs(scroll.x - this.cacheScroll.x) < 1 &&
            Math.abs(scroll.y - this.cacheScroll.y) < 1
          ) {
            return
          }
          this.app.ActivePDF.JumpToPage({ PageNum: optionInfo.value + 1 })
        })
        break
      }
      case this.optionType.PAGESTARTPLAY.name:
        this.app.ActivePDF.StartPlay('active', true, true)
        break
      case this.optionType.PAGEENDPLAY.name:
        this.app.ActivePDF.EndPlay()
        break
      default:
        break
    }
  }

  // メッセージをプッシュします。
  postMessage = (data: any) => {
    const { meetId, user } = this.meetingInfo
    this.wsServer.send(JSON.stringify({ id: user.id, meetId, data }))
  }
}
// ドキュメントオブジェクト
class Writer {
  wsServer: WebSocket
  meetingInfo: any
  app: any
  ins: any
  cacheInfo = {} // ドキュメント操作をキャッシュします。
  optionType = {
    WINDOWSCROLLCHANGE: { name: 'WindowScrollChange' }, // スクロール通知
    WINDOWSELECTIONCHANGE: { name: 'WindowSelectionChange' }, // 選択変更通知
  }

  constructor(wsServer: WebSocket, meetingInfo: any, app: any, ins: any) {
    this.wsServer = wsServer
    this.meetingInfo = meetingInfo
    this.app = app
    this.ins = ins
  }

  // プレゼンターによるイベントのイベントリスナーを初期化します。
  speakerInit() {
    Object.keys(this.optionType).forEach(key => {
      this.ins.ApiEvent.AddApiEventListener(this.optionType[key].name, async (e: any) => {
        this.cacheInfo[this.optionType[key].name] = e
        this.postMessage({ type: this.optionType[key].name, value: e })
      })
    })
  }

  async sendInitInfo() {
    this.postMessage({
      type: 'init',
      value: this.cacheInfo,
    })
  }

  // 視聴者の応答アクションを初期化します。
  async listenerInit(optionInfo: any) {
    switch (optionInfo.type) {
      case this.optionType.WINDOWSCROLLCHANGE.name: {
        const range = await this.app.ActiveDocument.ActiveWindow.RangeFromPoint(
          optionInfo.value.Data.scrollLeft,
          optionInfo.value.Data.scrollTop,
        )
        this.app.ActiveDocument.ActiveWindow.ScrollIntoView(range)
        break
      }
      case this.optionType.WINDOWSELECTIONCHANGE.name: {
        // ポイントをクリックしたときに選択範囲をゼロに設定し、テキストが選択されていないことを示します。
        if (optionInfo.value.isPoint) {
          this.app.ActiveDocument.Range.SetRange(0, 0) // 開始: 数値、終了: 数値
        } else {
          this.app.ActiveDocument.Range.SetRange(
            optionInfo.value.begin,
            optionInfo.value.end,
          ) // 開始: 数値、終了: 数値
        }
        break
      }
      default:
        break
    }
  }

  // メッセージをプッシュします。
  postMessage = (data: any) => {
    const { meetId, user } = this.meetingInfo
    this.wsServer.send(JSON.stringify({ id: user.id, meetId, data }))
  }
}
// シートオブジェクト
class Sheet {
  wsServer: WebSocket
  meetingInfo: any
  app: any
  ins: any
  optionType = {
    WORKSHEET_SELECTIONCHANGE: { name: 'Worksheet_SelectionChange' }, // 選択範囲の変更
    WORKSHEET_SCROLLCHANGE: { name: 'Worksheet_ScrollChange' }, // スクロール
    WORKSHEET_FORCELANDSCAPE: { name: 'Worksheet_ForceLandscape' }, // 強制横長モードの通知
  }

  constructor(wsServer: WebSocket, meetingInfo: any, app: any, ins: any) {
    this.wsServer = wsServer
    this.meetingInfo = meetingInfo
    this.app = app
    this.ins = ins
  }

  // プレゼンターによるシートイベントのイベントリスナーを初期化します。
  speakerInit() {
    Object.keys(this.optionType).forEach(key => {
      this.ins.ApiEvent.AddApiEventListener(this.optionType[key].name, async (e: any) => {
        if (this.optionType[key].name === this.optionType.WORKSHEET_SCROLLCHANGE.name) {
          const scrollColumn = await this.app.ActiveWindow.ScrollColumn
          const scrollRow = await this.app.ActiveWindow.ScrollRow
          this.postMessage({
            type: this.optionType[key].name,
            value: { column: scrollColumn, row: scrollRow },
          })
        } else if (this.optionType[key].name === this.optionType.WORKSHEET_SELECTIONCHANGE.name) {
          // 構成要素の情報を取得します。
          const params = await this._getSelectionData()
          this.postMessage({ type: this.optionType[key].name, value: params })
        } else {
          this.postMessage({ type: this.optionType[key].name, value: e })
        }
      })
    })
  }

  async sendInitInfo() {
    const scrollColumn = await this.app.ActiveWindow.ScrollColumn
    const scrollRow = await this.app.ActiveWindow.ScrollRow
    const params = await this._getSelectionData()
    this.postMessage({
      type: 'init',
      value: {
        [this.optionType.WORKSHEET_SCROLLCHANGE.name]: { column: scrollColumn, row: scrollRow },
        [this.optionType.WORKSHEET_SELECTIONCHANGE.name]: params,
      },
    })
  }

  // 視聴者の応答アクションを初期化します。
  async listenerInit(optionInfo: any) {
    switch (optionInfo.type) {
      case this.optionType.WORKSHEET_SELECTIONCHANGE.name:
        await this.app.Sheets(optionInfo.value.index).Activate()
        if (optionInfo.value.selection.type === 'shape') {
          await this.app.ActiveSheet.Shapes.Item(1).Select()
        } else {
          await this.app.Range(optionInfo.value.selection.value).Select() // 複数のセルを選択します。
        }
        break
      case this.optionType.WORKSHEET_SCROLLCHANGE.name:
        this.app.ActiveWindow.ScrollColumn = optionInfo.value.column
        this.app.ActiveWindow.ScrollRow = optionInfo.value.row
        break
      case this.optionType.WORKSHEET_FORCELANDSCAPE.name:
        // await WPSOpenApi.Application.Range('C47:D54'). Select() // 複数のセルを選択します。
        this.app.ActiveDocument.Range.SetRange(
          optionInfo.value.begin,
          optionInfo.value.end,
        ) // 開始: 数値、終了: 数値
        break
      default:
        break
    }
  }

  async _getSelectionData() {
    const params: {
      value: string
      type: string
    } = {
      value: '',
      type: '',
    }
    const shapes = await this.app.Selection.Item(1)
    // 選択されたコンテンツが図形かセルかを判断します。
    if (shapes) {
      params.value = await shapes.ID
      params.type = 'shape'
    } else {
      const selection = await this.app.Selection.Address()
      params.value = selection
      params.type = 'cell'
    }

    // アクティブなシートのシート番号を取得します。
    const sheetIndex = await this.app.ActiveSheet.Index
    // アクティブなセルを取得します。
    const activitiItem = {
      row: await this.app.Selection.Row,
      col: await this.app.Selection.Column,
    }

    return { index: sheetIndex, selection: params, activitiItem }
  }

  // メッセージをプッシュします。
  postMessage = (data: any) => {
    const { meetId, user } = this.meetingInfo
    this.wsServer.send(JSON.stringify({ id: user.id, meetId, data }))
  }
}
export { Presentation, Pdf, Writer, Sheet }

プレゼンターは編集モードでドキュメントにアクセスします。次の行は主要なロジックを定義しています。

  // ins オブジェクトは aliyun.config から取得されます。
  // ...
  await ins.ready();
  let app = ins.Application

  let webSocket = {
    send: data => {
      console.log('=====syncOptを送信しました', data)
      // socket.emit('syncOpt', data)
    },
  }

  let meetingInfo = { meetId: meetingId, user: { id: userId } }
  
  let syncIns
  if (suffix == '.docx') {
    syncIns = new Writer(webSocket, meetingInfo, app, ins)
  } else if (suffix == '.pptx') {
    syncIns = new Presentation(webSocket, meetingInfo, app, ins)
  } else if (suffix == '.xlsx') {
    syncIns = new Sheet(webSocket, meetingInfo, app, ins)
  } else if (suffix == '.pdf') {
    syncIns = new Pdf(webSocket, meetingInfo, app, ins)
  }
  syncIns.speakerInit()

視聴者は読み取り専用モードでドキュメントにアクセスします。次の行は主要なロジックを定義しています。

  // ins オブジェクトは aliyun.config から取得されます。
  // ...
  await ins.ready()
  let app = ins.Application

  let webSocket = {
    send: data => {
      console.log('=====syncOptを送信しました', data)
      // socket.emit('syncOpt', data)
    },
  }

  let meetingInfo = { meetId: meetingId, user: { id: userId } }

  let syncIns
  if (suffix == '.docx') {
    syncIns = new Writer(webSocket, meetingInfo, app, ins)
  } else if (suffix == '.pptx') {
    syncIns = new Presentation(webSocket, meetingInfo, app, ins)
  } else if (suffix == '.xlsx') {
    syncIns = new Sheet(webSocket, meetingInfo, app, ins)
  } else if (suffix == '.pdf') {
    syncIns = new Pdf(webSocket, meetingInfo, app, ins)
  }

  socket.on('syncOpt', message => {
    let data = JSON.parse(message)
    console.log('=====syncOptを受信しました', data)
    console.log('----- リッスン前の app と syncIns', app, syncIns)
    syncIns.listenerInit(data)
  })