このトピックでは、WebOffice のオンラインプレビューおよび編集機能を使用して、オンライン会議中にドキュメントファイルをプレゼンテーションする方法について説明します。
実装の概要
オンラインドキュメントのプレビューおよび編集機能を統合します。
WebSocket を使用して、プレゼンターによるリアルタイムのドキュメント操作の同期、視聴者へのドキュメントの初期化、プレゼンターによるドキュメントタイプの切り替えの同期など、同期されたドキュメント共有を実装します。
js-sdk
の API 操作を使用して、プレゼンター関連のイベントと視聴者による操作をリッスンして同期します。
例
Word ファイル、スプレッドシート、プレゼンテーション、 PDF ファイルの同期共有を実装します。
ページの切り替え、スクロール、再生、選択などのイベントの同期をサポートします。
会議リンクを共有して、ユーザーが会議にすばやく参加できるようにします。
フロントエンドロジック
サンプルファイル 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)
})