Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6ef4f1d
test: add unit tests for embed-api and onlyoffice-editor
chaxus May 30, 2026
66b5073
chore: untrack .claude/ and add to .gitignore
chaxus May 30, 2026
92bfba7
docs: add CLAUDE.md with project architecture and dev guide
chaxus May 30, 2026
59a1679
test: complete coverage for embed-api and onlyoffice-editor
chaxus May 30, 2026
4ca3484
chore: ignore coverage and playwright report directories
chaxus May 30, 2026
477bd4e
docs: explain onlyoffice-editor.ts coverage limitations in CLAUDE.md
chaxus May 30, 2026
b88a695
docs: add WebMCP feasibility assessment to CLAUDE.md
chaxus May 30, 2026
7484246
docs: add OnlyOffice Agent collaborative editing evaluation to CLAUDE.md
chaxus May 30, 2026
994d00a
docs: add agent collaborative editing implementation plan
chaxus May 30, 2026
b83073a
docs: add WebLLM offline inference option to agent collab plan
chaxus May 30, 2026
a6cfce4
docs: add OnlyOffice 7.5→9.4 upgrade evaluation to CLAUDE.md
chaxus May 30, 2026
08af416
Refactor code formatting and improve readability across multiple files
chaxus May 30, 2026
bd04fba
docs: add iframe embed API section to Chinese readme
chaxus May 30, 2026
be1af30
docs: translate iframe section to English and fix pnpm/npm in both re…
chaxus May 30, 2026
80fc455
docs: restructure readmes and extract detailed docs to docs/
chaxus May 30, 2026
d73b414
docs: add GitHub Pages and static hosting deployment options
chaxus May 30, 2026
8026ea5
docs: improve formatting and readability in embed API and font manage…
chaxus May 30, 2026
e09dd3e
test: add type annotations to call arguments in expectMessagePosted f…
chaxus May 30, 2026
1cb181a
fix: pin static web server version to 2.42.0 in Dockerfile
chaxus May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .claude/settings.json

This file was deleted.

7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# claude code local settings
.claude/

# npm
node_modules
# common
Expand All @@ -11,4 +14,8 @@ cache
vite.config.ts.timestamp*
config.ts.timestamp*
dist
# test artifacts
coverage/
playwright-report/
test-results/
public/fonts/__fallback__/
404 changes: 404 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ RUN pnpm run build
#FROM nginxinc/nginx-unprivileged:stable-alpine
#COPY --from=builder /app/dist /usr/share/nginx/html

FROM joseluisq/static-web-server:latest
FROM joseluisq/static-web-server:2.42.0
COPY --from=builder /app/dist /public
181 changes: 181 additions & 0 deletions docs/embed-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# iframe Embed API

This project supports embedding into any web application via iframe. The recommended pattern is: **the parent system handles auth, file fetching, and upload; the iframe handles editing only.** Tokens, cookies, and business APIs stay in the parent — the editor never sees them.

A working demo is available at `/embed-demo.html` (includes sha256 logging for debugging).

---

## Embedding the editor

```html
<iframe
id="documentEditor"
src="https://your-deployment/?embed=1"
style="width: 100%; height: 720px; border: 0"
></iframe>
```

To restrict messages to a specific origin, add `embedOrigin`:

```html
<iframe id="documentEditor" src="https://your-deployment/?embed=1&embedOrigin=https://your-system.example.com"></iframe>
```

---

## Sending commands

Include an `id` on each command to match it to the response:

```js
const iframe = document.getElementById('documentEditor');
const editorOrigin = 'https://your-deployment';

function sendEditorCommand(type, payload = {}) {
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
iframe.contentWindow.postMessage({ id, type, payload }, editorOrigin);
return id;
}

window.addEventListener('message', (event) => {
if (event.origin !== editorOrigin) return;
const { id, type, payload } = event.data || {};
if (!type?.startsWith('document:')) return;

switch (type) {
case 'document:ready':
console.log('Editor ready');
break;
case 'document:opened':
console.log('Opened', id, payload);
break;
case 'document:saved':
console.log('Saved', payload.fileName, payload.file);
break;
case 'document:error':
console.error('Error', payload.message);
break;
}
});
```

---

## Opening a document

### From URL

```js
sendEditorCommand('document:open-url', {
url: 'https://example.com/files/demo.xlsx',
fileName: 'demo.xlsx',
readonly: false,
});
```

If the URL requires auth headers, pass `fetchOptions`. For protected files it is preferable to fetch in the parent system and pass the binary:

```js
sendEditorCommand('document:open-url', {
url: 'https://example.com/api/files/1',
fileName: 'demo.xlsx',
fetchOptions: { headers: { Authorization: `Bearer ${token}` } },
});
```

### From a file picker

```js
const input = document.createElement('input');
input.type = 'file';
input.accept = '.xlsx,.xls,.csv,.docx,.doc,.pptx,.ppt';
input.onchange = () => {
sendEditorCommand('document:open-file', { file: input.files[0], readonly: false });
};
input.click();
```

### From an authenticated fetch (recommended for protected files)

```js
const response = await fetch('/api/files/1', {
headers: { Authorization: `Bearer ${token}` },
});
const buffer = await response.arrayBuffer();
sendEditorCommand('document:open-buffer', { fileName: 'demo.xlsx', buffer, readonly: false });
```

---

## Read-only mode

Set at open time via the `readonly` field, or toggle at any time:

```js
sendEditorCommand('document:set-readonly', { readonly: true });
```

In read-only mode, editing is disabled and `document:save` returns `document:error`.

---

## Saving and uploading

The save command exports the current document and returns a `File` via `document:saved`. Default format is `XLSX`; pass `targetExt` to change it.

```js
sendEditorCommand('document:save', { targetExt: 'XLSX' }); // XLSX, DOCX, PPTX, CSV
```

By default the command waits for the editor to return the **edited** file. If it times out, `document:error` is returned — this prevents accidentally uploading the original unchanged file. To opt in to returning the original on timeout:

```js
sendEditorCommand('document:save', { targetExt: 'XLSX', returnOriginalOnTimeout: true });
```

Upload from the parent:

```js
window.addEventListener('message', async (event) => {
if (event.origin !== editorOrigin) return;
const { type, payload } = event.data || {};
if (type !== 'document:saved') return;

await fetch('/api/files/1', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: payload.file,
});
});
```

> **Note:** Do not rely on `file.size` alone to detect changes. `.xlsx` is a zip archive — a minor edit can produce the exact same byte count. The built-in `/embed-demo.html` logs a `sha256` hash on every save for easier debugging.

---

## Query current state

```js
sendEditorCommand('document:get-state');
// Response: { type: 'document:state', payload: { readonly: false, hasDocument: true } }
```

---

## Message reference

| Direction | Type | Description |
| --------------- | --------------------------- | ----------------------------------------------- |
| parent → iframe | `document:open-url` | Open document from URL |
| parent → iframe | `document:open-file` | Open document from `File` / `Blob` |
| parent → iframe | `document:open-buffer` | Open document from `ArrayBuffer` / `Uint8Array` |
| parent → iframe | `document:set-readonly` | Set read-only or editable |
| parent → iframe | `document:save` | Save and return `File` |
| parent → iframe | `document:get-state` | Query current state |
| iframe → parent | `document:ready` | Editor initialised |
| iframe → parent | `document:opened` | Document opened |
| iframe → parent | `document:readonly-changed` | Read-only state changed |
| iframe → parent | `document:saved` | Save complete, file returned |
| iframe → parent | `document:state` | Current state response |
| iframe → parent | `document:error` | Operation failed |
181 changes: 181 additions & 0 deletions docs/embed-api.zh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# iframe 嵌入 API

本项目支持通过 iframe 嵌入到任何 Web 应用中。推荐架构是:**父系统负责鉴权、下载文件和上传保存结果;iframe 只负责文档编辑**。Token、Cookie、业务接口都留在父系统内,编辑器不需要知道授权细节。

项目内置了完整示例页面 `/embed-demo.html`,包含保存时的 sha256 哈希日志,方便调试。

---

## 嵌入编辑器

```html
<iframe
id="documentEditor"
src="https://your-deployment/?embed=1"
style="width: 100%; height: 720px; border: 0"
></iframe>
```

如需限制只接受指定来源的消息,增加 `embedOrigin`:

```html
<iframe id="documentEditor" src="https://your-deployment/?embed=1&embedOrigin=https://your-system.example.com"></iframe>
```

---

## 发送命令

建议每条命令带上 `id`,便于匹配响应:

```js
const iframe = document.getElementById('documentEditor');
const editorOrigin = 'https://your-deployment';

function sendEditorCommand(type, payload = {}) {
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
iframe.contentWindow.postMessage({ id, type, payload }, editorOrigin);
return id;
}

window.addEventListener('message', (event) => {
if (event.origin !== editorOrigin) return;
const { id, type, payload } = event.data || {};
if (!type?.startsWith('document:')) return;

switch (type) {
case 'document:ready':
console.log('编辑器已就绪');
break;
case 'document:opened':
console.log('文档已打开', id, payload);
break;
case 'document:saved':
console.log('保存完成', payload.fileName, payload.file);
break;
case 'document:error':
console.error('操作失败', payload.message);
break;
}
});
```

---

## 打开文档

### 通过 URL

```js
sendEditorCommand('document:open-url', {
url: 'https://example.com/files/demo.xlsx',
fileName: 'demo.xlsx',
readonly: false,
});
```

如果 URL 需要授权头,可以传 `fetchOptions`。但对于需要鉴权的文件,更推荐由父系统自己 `fetch` 后传入二进制数据:

```js
sendEditorCommand('document:open-url', {
url: 'https://example.com/api/files/1',
fileName: 'demo.xlsx',
fetchOptions: { headers: { Authorization: `Bearer ${token}` } },
});
```

### 通过本地文件选择

```js
const input = document.createElement('input');
input.type = 'file';
input.accept = '.xlsx,.xls,.csv,.docx,.doc,.pptx,.ppt';
input.onchange = () => {
sendEditorCommand('document:open-file', { file: input.files[0], readonly: false });
};
input.click();
```

### 通过父系统授权请求(推荐用于受保护文件)

```js
const response = await fetch('/api/files/1', {
headers: { Authorization: `Bearer ${token}` },
});
const buffer = await response.arrayBuffer();
sendEditorCommand('document:open-buffer', { fileName: 'demo.xlsx', buffer, readonly: false });
```

---

## 只读模式

在打开文档时通过 `readonly` 字段设置,或随时切换:

```js
sendEditorCommand('document:set-readonly', { readonly: true });
```

只读模式下编辑权限关闭,`document:save` 会返回 `document:error`。

---

## 保存并上传

保存命令触发编辑器导出当前文档,通过 `document:saved` 返回 `File` 对象。默认保存为 `XLSX`,通过 `targetExt` 指定其他格式。

```js
sendEditorCommand('document:save', { targetExt: 'XLSX' }); // XLSX、DOCX、PPTX、CSV
```

默认情况下,命令会等待编辑器返回**编辑后**的文件。超时则返回 `document:error`,避免误上传原始文件。如需超时时回传原文件,显式开启:

```js
sendEditorCommand('document:save', { targetExt: 'XLSX', returnOriginalOnTimeout: true });
```

父页面拿到文件后上传:

```js
window.addEventListener('message', async (event) => {
if (event.origin !== editorOrigin) return;
const { type, payload } = event.data || {};
if (type !== 'document:saved') return;

await fetch('/api/files/1', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: payload.file,
});
});
```

> **注意:** 不要只用 `file.size` 判断文件是否变化。`.xlsx` 是 zip 压缩包格式,轻微编辑后文件大小可能完全相同。内置的 `/embed-demo.html` 每次保存都会在日志中打印 `sha256` 哈希值,方便调试。

---

## 查询当前状态

```js
sendEditorCommand('document:get-state');
// 响应:{ type: 'document:state', payload: { readonly: false, hasDocument: true } }
```

---

## 消息类型参考

| 方向 | 类型 | 说明 |
| --------------- | --------------------------- | ------------------------------------------ |
| 父页面 → iframe | `document:open-url` | 通过 URL 打开文档 |
| 父页面 → iframe | `document:open-file` | 通过 `File` / `Blob` 打开文档 |
| 父页面 → iframe | `document:open-buffer` | 通过 `ArrayBuffer` / `Uint8Array` 打开文档 |
| 父页面 → iframe | `document:set-readonly` | 设置只读或可编辑 |
| 父页面 → iframe | `document:save` | 保存并返回 `File` |
| 父页面 → iframe | `document:get-state` | 查询当前状态 |
| iframe → 父页面 | `document:ready` | 编辑器初始化完成 |
| iframe → 父页面 | `document:opened` | 文档打开完成 |
| iframe → 父页面 | `document:readonly-changed` | 只读状态已切换 |
| iframe → 父页面 | `document:saved` | 保存完成,返回文件 |
| iframe → 父页面 | `document:state` | 当前状态响应 |
| iframe → 父页面 | `document:error` | 操作失败 |
Loading