From 6ef4f1d5eb71fb27dcafced2c076a72cf6f05acc Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 30 May 2026 21:29:02 +0800 Subject: [PATCH 01/19] test: add unit tests for embed-api and onlyoffice-editor Cover initEmbedApi embed-mode detection, message dispatch/routing, origin filtering, readonly mode, and requestSaveDocument edge cases. Co-Authored-By: Claude Sonnet 4.6 --- test/unit/embed-api.test.ts | 204 ++++++++++++++++++++++++++++ test/unit/onlyoffice-editor.test.ts | 149 ++++++++++++++++++++ vitest.config.ts | 2 +- 3 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 test/unit/embed-api.test.ts create mode 100644 test/unit/onlyoffice-editor.test.ts diff --git a/test/unit/embed-api.test.ts b/test/unit/embed-api.test.ts new file mode 100644 index 00000000..84d1ff24 --- /dev/null +++ b/test/unit/embed-api.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockOpenDocumentFromUrl = vi.fn().mockResolvedValue(undefined); +const mockLoadEditorApi = vi.fn().mockResolvedValue(undefined); +const mockHandleDocumentOperation = vi.fn().mockResolvedValue(undefined); +const mockSetDocmentObj = vi.fn(); +const mockGetReadonlyMode = vi.fn().mockReturnValue(false); +const mockSetReadonlyMode = vi.fn(); +const mockRequestSaveDocument = vi.fn(); + +vi.mock('../../lib/document', () => ({ openDocumentFromUrl: mockOpenDocumentFromUrl })); +vi.mock('../../lib/converter', () => ({ + loadEditorApi: mockLoadEditorApi, + handleDocumentOperation: mockHandleDocumentOperation, +})); +vi.mock('../../store', () => ({ setDocmentObj: mockSetDocmentObj })); +vi.mock('../../lib/onlyoffice-editor', () => ({ + getReadonlyMode: mockGetReadonlyMode, + setReadonlyMode: mockSetReadonlyMode, + requestSaveDocument: mockRequestSaveDocument, +})); + +async function dispatchMessage(data: unknown, origin = 'https://parent.example.com') { + window.dispatchEvent(new MessageEvent('message', { data, origin })); + await new Promise((r) => setTimeout(r, 0)); +} + +// Verify a specific message type + id was posted (content-based, not count-based). +// Using unique IDs per test prevents false positives from accumulated old listeners. +function expectMessagePosted( + spy: ReturnType, + type: string, + id: string, + payloadMatch?: Record, +) { + const found = spy.mock.calls.find((call) => { + const msg = call[0] as { type?: string; id?: string }; + return msg?.type === type && msg?.id === id; + }); + expect(found, `Expected message type="${type}" id="${id}" to have been posted`).toBeDefined(); + if (payloadMatch) { + const msg = found![0] as { payload?: Record }; + expect(msg.payload).toMatchObject(payloadMatch); + } +} + +function expectMessageNotPosted(spy: ReturnType, id: string) { + const found = spy.mock.calls.find((call) => { + const msg = call[0] as { id?: string }; + return msg?.id === id; + }); + expect(found, `Expected no message with id="${id}" to be posted`).toBeUndefined(); +} + +describe('embed-api', () => { + let postMessageSpy: ReturnType; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + postMessageSpy = vi.spyOn(window, 'postMessage'); + document.body.classList.remove('embed-mode'); + window.history.pushState({}, '', '/'); + }); + + afterEach(() => { + postMessageSpy.mockRestore(); + delete (window as any).editor; + }); + + describe('detectEmbedMode via URL params', () => { + it.each([ + ['?embed=', true], + ['?embed=1', true], + ['?embed=true', true], + ['?embedded=true', true], + ['?embedded=1', true], + ['', false], + ['?other=1', false], + ])('URL "%s" → embed mode = %s', async (search, expected) => { + window.history.pushState({}, '', `/${search}`); + const { initEmbedApi } = await import('../../lib/embed-api'); + initEmbedApi(); + expect(document.body.classList.contains('embed-mode')).toBe(expected); + }); + }); + + describe('initEmbedApi', () => { + it('is idempotent - second call does not throw', async () => { + window.history.pushState({}, '', '/?embed=1'); + const { initEmbedApi } = await import('../../lib/embed-api'); + expect(() => initEmbedApi()).not.toThrow(); + expect(() => initEmbedApi()).not.toThrow(); + expect(document.body.classList.contains('embed-mode')).toBe(true); + }); + + it('posts document:ready when the load event fires', async () => { + window.history.pushState({}, '', '/?embed=1'); + const { initEmbedApi } = await import('../../lib/embed-api'); + initEmbedApi(); + + window.dispatchEvent(new Event('load')); + + expect(postMessageSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'document:ready' }), + expect.any(String), + ); + }); + }); + + describe('message handling', () => { + it('ignores messages that lack a document: prefix', async () => { + window.history.pushState({}, '', '/?embed=1'); + const { initEmbedApi } = await import('../../lib/embed-api'); + initEmbedApi(); + + await dispatchMessage({ type: 'other:ping', id: 'ignore-1' }); + await dispatchMessage(null); + await dispatchMessage('plain string'); + + expectMessageNotPosted(postMessageSpy, 'ignore-1'); + }); + + it('ignores messages from disallowed origins when embedOrigin is set', async () => { + window.history.pushState({}, '', '/?embed=1&embedOrigin=https://allowed.example.com'); + const { initEmbedApi } = await import('../../lib/embed-api'); + initEmbedApi(); + + // All active listeners (including accumulated ones) read the current URL's embedOrigin, + // so they will all reject this disallowed origin too. + await dispatchMessage({ type: 'document:get-state', id: 'origin-block-1' }, 'https://evil.example.com'); + + expectMessageNotPosted(postMessageSpy, 'origin-block-1'); + }); + + it('accepts messages from a matching embedOrigin', async () => { + window.history.pushState({}, '', '/?embed=1&embedOrigin=https://allowed.example.com'); + const { initEmbedApi } = await import('../../lib/embed-api'); + initEmbedApi(); + + await dispatchMessage( + { type: 'document:get-state', id: 'origin-allow-1' }, + 'https://allowed.example.com', + ); + + expectMessagePosted(postMessageSpy, 'document:state', 'origin-allow-1'); + }); + + it('responds to document:get-state with readonly and hasDocument flags', async () => { + window.history.pushState({}, '', '/?embed=1'); + mockGetReadonlyMode.mockReturnValue(false); + const { initEmbedApi } = await import('../../lib/embed-api'); + initEmbedApi(); + + await dispatchMessage({ type: 'document:get-state', id: 'state-1' }); + + expectMessagePosted(postMessageSpy, 'document:state', 'state-1', { + readonly: false, + hasDocument: false, + }); + }); + + it('responds to document:set-readonly and updates readonly mode', async () => { + window.history.pushState({}, '', '/?embed=1'); + const { initEmbedApi } = await import('../../lib/embed-api'); + initEmbedApi(); + + await dispatchMessage({ type: 'document:set-readonly', id: 'ro-1', payload: { readonly: true } }); + + expect(mockSetReadonlyMode).toHaveBeenCalledWith(true); + expectMessagePosted(postMessageSpy, 'document:readonly-changed', 'ro-1'); + }); + + it('posts document:error when a handler throws', async () => { + window.history.pushState({}, '', '/?embed=1'); + mockRequestSaveDocument.mockRejectedValueOnce(new Error('Save failed')); + const { initEmbedApi } = await import('../../lib/embed-api'); + initEmbedApi(); + + await dispatchMessage({ type: 'document:save', id: 'err-1', payload: {} }); + + expectMessagePosted(postMessageSpy, 'document:error', 'err-1', { message: 'Save failed' }); + }); + + it('opens a document from url payload', async () => { + window.history.pushState({}, '', '/?embed=1'); + const { initEmbedApi } = await import('../../lib/embed-api'); + initEmbedApi(); + + await dispatchMessage({ + type: 'document:open-url', + id: 'open-1', + payload: { url: 'https://example.com/test.xlsx' }, + }); + + expect(mockOpenDocumentFromUrl).toHaveBeenCalledWith( + 'https://example.com/test.xlsx', + undefined, + expect.objectContaining({ readonly: false }), + ); + expectMessagePosted(postMessageSpy, 'document:opened', 'open-1'); + }); + }); +}); diff --git a/test/unit/onlyoffice-editor.test.ts b/test/unit/onlyoffice-editor.test.ts new file mode 100644 index 00000000..efd320c1 --- /dev/null +++ b/test/unit/onlyoffice-editor.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('ranui/message', () => ({})); +vi.mock('ranuts/utils', () => ({ + createObjectURL: vi.fn().mockResolvedValue('blob:mock'), +})); +vi.mock('../../store', () => ({ + getDocmentObj: vi.fn().mockReturnValue({ fileName: 'test.xlsx', file: undefined }), +})); +vi.mock('../../lib/i18n', () => ({ + getOnlyOfficeLang: vi.fn().mockReturnValue('en'), + t: vi.fn((key: string) => key), +})); +vi.mock('../../lib/file-types', () => ({ c_oAscFileType2: { 65: 'XLSX', 43: 'DOCX' } })); +vi.mock('../../lib/document-utils', () => ({ getMimeTypeFromExtension: vi.fn().mockReturnValue('image/png') })); + +import { + getReadonlyMode, + requestSaveDocument, + setConverterCallbacks, + setReadonlyMode, +} from '../../lib/onlyoffice-editor'; + +function makeEditor(extra: Record = {}) { + return { sendCommand: vi.fn(), ...extra }; +} + +describe('onlyoffice-editor', () => { + beforeEach(() => { + setReadonlyMode(false); + delete (window as any).editor; + }); + + afterEach(() => { + delete (window as any).editor; + }); + + describe('getReadonlyMode / setReadonlyMode', () => { + it('defaults to false', () => { + expect(getReadonlyMode()).toBe(false); + }); + + it('returns true after setReadonlyMode(true)', () => { + setReadonlyMode(true); + expect(getReadonlyMode()).toBe(true); + }); + + it('returns false after toggling back', () => { + setReadonlyMode(true); + setReadonlyMode(false); + expect(getReadonlyMode()).toBe(false); + }); + + it('sends processRightsChange command to the editor when one exists', () => { + const editor = makeEditor(); + (window as any).editor = editor; + + setReadonlyMode(true); + + expect(editor.sendCommand).toHaveBeenCalledWith( + expect.objectContaining({ command: 'processRightsChange' }), + ); + }); + + it('does not throw when no editor is present', () => { + expect(() => setReadonlyMode(true)).not.toThrow(); + }); + }); + + describe('requestSaveDocument', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(async () => { + // Advance past the 60 s timeout to flush any pending embeddedSaveRequest, + // ensuring module state is clean for the next test. + vi.runAllTimers(); + await Promise.resolve(); + vi.useRealTimers(); + }); + + it('rejects immediately when no document is open', async () => { + await expect(requestSaveDocument()).rejects.toThrow('No document is open'); + }); + + it('rejects when the document is readonly', async () => { + (window as any).editor = makeEditor({ downloadAs: vi.fn() }); + setReadonlyMode(true); + + await expect(requestSaveDocument()).rejects.toThrow('readonly'); + }); + + it('rejects when editor does not support downloadAs', async () => { + (window as any).editor = makeEditor(); // no downloadAs + + await expect(requestSaveDocument()).rejects.toThrow('downloadAs'); + }); + + it('rejects when a save request is already in progress', async () => { + const downloadAs = vi.fn(); + (window as any).editor = makeEditor({ downloadAs }); + + const first = requestSaveDocument().catch(() => {}); + await expect(requestSaveDocument()).rejects.toThrow('already in progress'); + + vi.runAllTimers(); + await first; + }); + + it('normalises the target extension to uppercase', () => { + const downloadAs = vi.fn(); + (window as any).editor = makeEditor({ downloadAs }); + + void requestSaveDocument('xlsx').catch(() => {}); + + expect(downloadAs).toHaveBeenCalledWith('XLSX'); + }); + + it('defaults target extension to XLSX', () => { + const downloadAs = vi.fn(); + (window as any).editor = makeEditor({ downloadAs }); + + void requestSaveDocument().catch(() => {}); + + expect(downloadAs).toHaveBeenCalledWith('XLSX'); + }); + + it('rejects after 60 s timeout if no save event arrives', async () => { + const downloadAs = vi.fn(); + (window as any).editor = makeEditor({ downloadAs }); + + const promise = requestSaveDocument(); + vi.advanceTimersByTime(60_001); + await expect(promise).rejects.toThrow('timed out'); + }); + }); + + describe('setConverterCallbacks', () => { + it('accepts converter and convertAndDownload functions without throwing', () => { + expect(() => + setConverterCallbacks({ + convert: vi.fn(), + convertAndDownload: vi.fn(), + }), + ).not.toThrow(); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index ef18fef3..b3ada8e4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ functions: 35, statements: 35, }, - include: ['lib/document-utils.ts', 'lib/i18n.ts'], + include: ['lib/document-utils.ts', 'lib/i18n.ts', 'lib/embed-api.ts', 'lib/onlyoffice-editor.ts'], }, }, }); From 66b507363f6203b02650d24238bcdfe2574f77c1 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 30 May 2026 21:31:22 +0800 Subject: [PATCH 02/19] chore: untrack .claude/ and add to .gitignore .claude/settings.json contains local absolute paths and permission allowlists that are machine-specific and should not be committed. Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.json | 5 ----- .gitignore | 3 +++ 2 files changed, 3 insertions(+), 5 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 4ab25ca5..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "permissions": { - "allow": ["Bash(gh pr *)", "Bash(pnpm --version)", "Bash(rtk pnpm *)"] - } -} diff --git a/.gitignore b/.gitignore index b4e71a05..c3fa8a02 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# claude code local settings +.claude/ + # npm node_modules # common From 92bfba73ceb7bedb19063eb3322ca3514d047045 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 30 May 2026 21:35:00 +0800 Subject: [PATCH 03/19] docs: add CLAUDE.md with project architecture and dev guide Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..9849f894 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,188 @@ +# CLAUDE.md — document 项目指南 + +## 项目概述 + +基于 OnlyOffice 的本地 Web 文档编辑器,所有处理在浏览器端完成,无需服务器,保护用户隐私。支持 docx、xlsx、pptx、csv 等格式。 + +- **线上地址**:https://ranuts.github.io/document/ +- **GitHub**:https://github.com/ranuts/document +- **技术栈**:TypeScript + Vite + Tailwind CSS + OnlyOffice Web Apps + +--- + +## 开发命令 + +```bash +pnpm install --frozen-lockfile # 安装依赖 +pnpm run dev # 启动开发服务器(含热更新) +pnpm run build # 生产构建(执行 bin/build.sh) +pnpm run build:single # 打包为单个 HTML 文件 +pnpm run lint:ts # oxlint + tsc --noEmit(CI 必跑) +pnpm run format:check # prettier 格式检查(CI 必跑) +pnpm run test # 单元测试(Vitest) +pnpm run test:coverage # 带覆盖率的单元测试 +pnpm run test:e2e # E2E 测试(Playwright,需先 build) +pnpm run lint # lint:ts + lint:docker +``` + +--- + +## 目录结构 + +``` +lib/ # 核心业务逻辑(纯 TypeScript) + converter.ts # 加载 OnlyOffice API / x2t 转换器 + document.ts # 文件打开、新建、URL 加载 + document-converter.ts # 格式转换(docx/xlsx/pptx/csv 互转) + document-types.ts # 共享类型定义 + document-utils.ts # 纯工具函数(类型判断、MIME、路径) + embed-api.ts # iframe 嵌入 API(postMessage 协议) + events.ts # MessageCodec 事件处理(桌面端集成) + file-types.ts # OnlyOffice 文件类型常量映射 + i18n.ts # 国际化(中/英/日/韩/德/法/西/葡/俄) + loading.ts # 加载状态 UI + onlyoffice-editor.ts # 编辑器实例生命周期、保存、只读模式 + ui.ts # 控制面板、菜单、FAB 等 UI 组件 + empty_bin.ts # 新建文档时使用的空文档二进制数据 +store/ + index.ts # 全局状态(当前文档对象),基于 ranuts/utils createSignal +types/ + editor.d.ts # OnlyOffice DocEditor 类型声明 + assets.d.ts # CSS 模块类型声明(declare module '*.css') +styles/ + base.css # 全局样式(含 embed-mode 布局) +index.ts # 应用入口(初始化事件、UI、PWA) +index.html # HTML 入口 +``` + +--- + +## 核心模块说明 + +### embed-api.ts — iframe 嵌入 API + +允许父页面通过 `postMessage` 控制编辑器。触发条件: +- URL 含 `?embed=`、`?embed=1`、`?embed=true`、`?embedded=1` 等参数 +- 或页面被嵌入 iframe(`window.parent !== window`) + +支持的消息类型: + +| 消息类型 | 说明 | +|---|---| +| `document:open` / `document:open-url` / `document:open-file` / `document:open-buffer` | 打开文档(支持 url / File / Blob / ArrayBuffer / Uint8Array) | +| `document:set-readonly` | 切换只读模式 | +| `document:save` | 触发保存,父页面收到带 File 的 `document:saved` 响应 | +| `document:get-state` | 查询当前状态(readonly、hasDocument) | + +使用 `?embedOrigin=https://example.com` 可限制消息来源。 + +### onlyoffice-editor.ts — 编辑器生命周期 + +- `createEditorInstance(config)` — 创建/重建编辑器,内部有操作队列防并发 +- `setReadonlyMode(bool)` / `getReadonlyMode()` — 只读模式 +- `requestSaveDocument(targetExt, options)` — 触发编辑器保存并返回 File,60s 超时 +- `setConverterCallbacks(...)` — 注入转换器(解耦循环依赖) + +### store/index.ts — 全局状态 + +```ts +const [getDocmentObj, setDocmentObj] = createSignal<{ + fileName: string; + file?: File; + url?: string | URL; +}>({ fileName: '' }); +``` + +--- + +## 测试体系 + +### 单元测试(Vitest + jsdom) + +配置文件:`vitest.config.ts` + +``` +test/unit/ + vitest-smoke.test.ts # 基础冒烟 + document-utils.test.ts # lib/document-utils.ts + i18n.test.ts # lib/i18n.ts + embed-api.test.ts # lib/embed-api.ts(initEmbedApi、消息路由、来源过滤) + onlyoffice-editor.test.ts # lib/onlyoffice-editor.ts(只读模式、requestSaveDocument) +test/setup/vitest.ts # 全局 mock:matchMedia、URL.createObjectURL、localStorage +``` + +**当前覆盖率(coverage include 范围内):** + +| 文件 | 语句 | 分支 | 函数 | +|---|---|---|---| +| document-utils.ts | 89% | 87% | 100% | +| embed-api.ts | 75% | 56% | 85% | +| i18n.ts | 92% | 65% | 93% | +| onlyoffice-editor.ts | 22% | 16% | 31% | + +覆盖率阈值(全局):语句 35%、分支 25%、函数 35%、行 35%。 + +**注意事项:** +- `embed-api.ts` 有模块级 `initialized` 单例,测试需用 `vi.resetModules()` + 动态 `import()` 获取新实例 +- 旧模块实例的 `window.message` 监听器在 `resetModules` 后仍残留,**不要用 `toHaveBeenCalledTimes` 断言次数**,改用 `toHaveBeenCalledWith` 匹配消息内容或用唯一 ID 定向检索 +- `requestSaveDocument` 有内部超时状态,测试需配合 `vi.useFakeTimers()` + `vi.runAllTimers()` 清理 + +### E2E 测试(Playwright) + +配置文件:`playwright.config.ts`,使用 Chromium,baseURL `http://127.0.0.1:4173`。 + +``` +test/e2e/ + app-smoke.spec.ts # 应用加载、PWA manifest 冒烟测试 +``` + +E2E 在 CI 中依赖 `lint` job 成功后才运行(`needs: lint`)。本地运行前需先 `pnpm run build`。 + +--- + +## CI 流程(.github/workflows/ci.yml) + +两个 job,触发条件:push/PR 到 main/master。 + +**lint job(串行步骤):** +1. `pnpm/action-setup@v6 version: latest` — 不锁定 pnpm 版本 +2. `actions/setup-node@v6 node-version: lts/*` — 不锁定 Node 版本 +3. `pnpm install --frozen-lockfile` +4. `pnpm run format:check` +5. `pnpm run lint:ts` +6. `pnpm run test:coverage` +7. `docker compose config --quiet`(验证 Docker Compose 文件) +8. `hadolint/hadolint-action@v3.3.0`(Dockerfile 检查) + +**e2e job(需 lint 通过):** +1. 同上安装步骤 +2. `playwright install --with-deps chromium` +3. `pnpm run test:e2e` +4. 失败时上传 `playwright-report/` artifact + +--- + +## 代码规范 + +- **Lint**:oxlint(规则见 `.oxlintrc.json`)+ TypeScript 6 严格模式 +- **格式化**:prettier(配置见 `.prettierrc.json`) +- **TypeScript**:`strict: true`,`noImplicitAny: true`,目标 ESNext,模块解析 bundler +- `baseUrl` 已移除(TypeScript 6 废弃),路径别名使用 `paths` + `@/*` 前缀 +- CSS 副作用导入需在 `types/assets.d.ts` 中有 `declare module '*.css' {}` + +--- + +## 重要约定 + +1. **不锁定工具版本**:CI 中 pnpm 用 `latest`,Node 用 `lts/*`,保持自动跟随最新 +2. **循环依赖处理**:`onlyoffice-editor.ts` 与 `converter.ts` 之间通过回调注入(`setConverterCallbacks`)解耦;`ui.ts` 与 `document.ts` 之间通过 `setUICallbacks` 解耦 +3. **编辑器操作队列**:`createEditorInstance` 内部有 `editorOperationQueue`,防止并发创建/销毁编辑器 +4. **.claude/ 目录**:已加入 `.gitignore`,不提交本地 Claude Code 配置 + +--- + +## 待完善的测试覆盖 + +- `embed-api.ts`:`makeFileFromPayload` 的 File/Blob/ArrayBuffer/Uint8Array 四条分支(第 73–109 行) +- `onlyoffice-editor.ts`:`getSavedFileMimeType`、`getNormalizedFile`、`toUint8Array` 等纯函数目前未导出,如需提升覆盖率可将其导出并单独测试 +- E2E:文档上传、编辑、保存流程(当前只有冒烟测试) From 59a1679d0e878806f893713a3de52961f9a7d7a3 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 30 May 2026 21:42:14 +0800 Subject: [PATCH 04/19] test: complete coverage for embed-api and onlyoffice-editor - Export getSavedFileMimeType, getNormalizedFile, toUint8Array for direct testing - Add unit tests: all getSavedFileMimeType/getNormalizedFile/toUint8Array branches - Add unit tests: makeFileFromPayload File/Blob/ArrayBuffer/Uint8Array/error paths - Add E2E tests: embed-mode activation, postMessage round-trips, embedOrigin guard Co-Authored-By: Claude Sonnet 4.6 --- lib/onlyoffice-editor.ts | 6 +- test/e2e/embed-api.spec.ts | 130 ++++++++++++++++++++++++++++ test/unit/embed-api.test.ts | 71 +++++++++++++++ test/unit/onlyoffice-editor.test.ts | 79 +++++++++++++++++ 4 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 test/e2e/embed-api.spec.ts diff --git a/lib/onlyoffice-editor.ts b/lib/onlyoffice-editor.ts index 14338368..dca5a177 100644 --- a/lib/onlyoffice-editor.ts +++ b/lib/onlyoffice-editor.ts @@ -39,7 +39,7 @@ type EmbeddedSaveRequest = { let embeddedSaveRequest: EmbeddedSaveRequest | null = null; -function getSavedFileMimeType(fileName: string): string { +export function getSavedFileMimeType(fileName: string): string { const extension = fileName.split('.').pop()?.toLowerCase() || ''; const mimeMap: Record = { docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', @@ -54,12 +54,12 @@ function getSavedFileMimeType(fileName: string): string { return mimeMap[extension] || 'application/octet-stream'; } -function getNormalizedFile(file: File): File { +export function getNormalizedFile(file: File): File { const mimeType = !file.type || file.type === 'application/octet-stream' ? getSavedFileMimeType(file.name) : file.type; return new File([file], file.name, { type: mimeType }); } -function toUint8Array(data: BlobPart): Uint8Array { +export function toUint8Array(data: BlobPart): Uint8Array { if (data instanceof Uint8Array) { return data; } diff --git a/test/e2e/embed-api.spec.ts b/test/e2e/embed-api.spec.ts new file mode 100644 index 00000000..44efecd4 --- /dev/null +++ b/test/e2e/embed-api.spec.ts @@ -0,0 +1,130 @@ +import { expect, test } from '@playwright/test'; + +// --------------------------------------------------------------------------- +// Embed-mode E2E tests +// +// In non-iframe context window.parent === window, so postToParent() dispatches +// to the same window. page.evaluate() Promises and addInitScript() can +// observe these messages. +// --------------------------------------------------------------------------- + +test.describe('embed mode activation', () => { + test('adds embed-mode class to body when ?embed=1 is present', async ({ page }) => { + await page.goto('/?embed=1'); + await expect(page.locator('body')).toHaveClass(/embed-mode/); + }); + + test('does NOT add embed-mode class without embed query param', async ({ page }) => { + await page.goto('/'); + const classes = await page.locator('body').getAttribute('class'); + expect(classes ?? '').not.toContain('embed-mode'); + }); + + test('activates embed mode with ?embed=true', async ({ page }) => { + await page.goto('/?embed=true'); + await expect(page.locator('body')).toHaveClass(/embed-mode/); + }); + + test('activates embed mode with ?embedded=1', async ({ page }) => { + await page.goto('/?embedded=1'); + await expect(page.locator('body')).toHaveClass(/embed-mode/); + }); +}); + +test.describe('postMessage API', () => { + test('document:ready is posted after the page loads', async ({ page }) => { + // addInitScript runs before page scripts, so it captures messages + // dispatched during and after page load. + await page.addInitScript(() => { + (window as any).__capturedMessages = [] as unknown[]; + window.addEventListener('message', (e: MessageEvent) => { + (window as any).__capturedMessages.push(e.data); + }); + }); + + await page.goto('/?embed=1'); + // Give a tick for async postMessage dispatch + await page.waitForTimeout(200); + + const messages = await page.evaluate( + () => (window as any).__capturedMessages as Array<{ type?: string }>, + ); + expect(messages.some((m) => m.type === 'document:ready')).toBe(true); + }); + + test('responds to document:get-state with readonly and hasDocument flags', async ({ page }) => { + await page.goto('/?embed=1'); + + const response = await page.evaluate( + () => + new Promise<{ type: string; payload: Record }>((resolve) => { + window.addEventListener('message', (e: MessageEvent) => { + const msg = e.data as { type?: string; id?: string; payload?: Record }; + // Filter for the response only (type differs from the request we sent) + if (msg?.type === 'document:state' && msg?.id === 'e2e-state-1') resolve(msg as any); + }); + window.postMessage({ type: 'document:get-state', id: 'e2e-state-1' }, '*'); + }), + ); + + expect(response.type).toBe('document:state'); + expect(typeof response.payload.readonly).toBe('boolean'); + expect(typeof response.payload.hasDocument).toBe('boolean'); + }); + + test('document:set-readonly changes readonly state', async ({ page }) => { + await page.goto('/?embed=1'); + + // Query initial state + const before = await page.evaluate( + () => + new Promise((resolve) => { + window.addEventListener('message', (e: MessageEvent) => { + const msg = e.data as { type?: string; id?: string; payload?: { readonly?: boolean } }; + if (msg?.type === 'document:state' && msg?.id === 'e2e-before') + resolve(msg.payload?.readonly ?? false); + }); + window.postMessage({ type: 'document:get-state', id: 'e2e-before' }, '*'); + }), + ); + expect(before).toBe(false); + + // Set readonly = true + const after = await page.evaluate( + () => + new Promise((resolve) => { + window.addEventListener('message', (e: MessageEvent) => { + const msg = e.data as { type?: string; id?: string; payload?: { readonly?: boolean } }; + if (msg?.type === 'document:readonly-changed' && msg?.id === 'e2e-ro-set') + resolve(msg.payload?.readonly ?? false); + }); + window.postMessage({ type: 'document:set-readonly', id: 'e2e-ro-set', payload: { readonly: true } }, '*'); + }), + ); + expect(after).toBe(true); + }); + + test('embedOrigin param blocks messages from a mismatched origin', async ({ page }) => { + // page.evaluate postMessage has origin http://127.0.0.1:4173 (same origin), + // which does NOT match the allowed 'https://allowed.example.com', + // so the message handler should be skipped and no response received. + await page.goto('/?embed=1&embedOrigin=https://allowed.example.com'); + + const blocked = await page.evaluate( + () => + new Promise((resolve) => { + let received = false; + window.addEventListener('message', (e: MessageEvent) => { + const msg = e.data as { type?: string; id?: string }; + // Only count the *response* from the app, not the request we sent + if (msg?.type === 'document:state' && msg?.id === 'e2e-blocked') received = true; + }); + window.postMessage({ type: 'document:get-state', id: 'e2e-blocked' }, '*'); + // Wait 500 ms; if no matching response arrives the message was blocked. + setTimeout(() => resolve(!received), 500); + }), + ); + + expect(blocked).toBe(true); + }); +}); diff --git a/test/unit/embed-api.test.ts b/test/unit/embed-api.test.ts index 84d1ff24..66a397ea 100644 --- a/test/unit/embed-api.test.ts +++ b/test/unit/embed-api.test.ts @@ -201,4 +201,75 @@ describe('embed-api', () => { expectMessagePosted(postMessageSpy, 'document:opened', 'open-1'); }); }); + + describe('makeFileFromPayload branches', () => { + // makeFileFromPayload is internal; we exercise it via document:open-buffer messages. + // mockHandleDocumentOperation lets us inspect the File that was constructed. + + async function openWithPayload(payload: Record, id: string) { + window.history.pushState({}, '', '/?embed=1'); + const { initEmbedApi } = await import('../../lib/embed-api'); + initEmbedApi(); + await dispatchMessage({ type: 'document:open-buffer', id, payload }); + } + + it('passes a File payload through unchanged', async () => { + const file = new File([new Uint8Array([1, 2, 3])], 'original.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + + await openWithPayload({ file }, 'file-1'); + + const [callArg] = mockHandleDocumentOperation.mock.calls.at(-1)!; + expect(callArg.fileName).toBe('original.xlsx'); + expectMessagePosted(postMessageSpy, 'document:opened', 'file-1'); + }); + + it('wraps a Blob payload into a File', async () => { + const blob = new Blob([new Uint8Array([10, 20])], { type: 'text/csv' }); + + await openWithPayload({ blob, fileName: 'data.csv' }, 'blob-1'); + + const [callArg] = mockHandleDocumentOperation.mock.calls.at(-1)!; + expect(callArg.fileName).toBe('data.csv'); + expectMessagePosted(postMessageSpy, 'document:opened', 'blob-1'); + }); + + it('wraps an ArrayBuffer payload into a File', async () => { + const buffer = new Uint8Array([7, 8, 9]).buffer; + + await openWithPayload({ buffer, fileName: 'report.docx' }, 'arraybuffer-1'); + + const [callArg] = mockHandleDocumentOperation.mock.calls.at(-1)!; + expect(callArg.fileName).toBe('report.docx'); + expectMessagePosted(postMessageSpy, 'document:opened', 'arraybuffer-1'); + }); + + it('wraps a Uint8Array payload (via "bytes" key) into a File', async () => { + const bytes = new Uint8Array([0xff, 0xfe]); + + await openWithPayload({ bytes, fileName: 'slide.pptx' }, 'uint8-1'); + + const [callArg] = mockHandleDocumentOperation.mock.calls.at(-1)!; + expect(callArg.fileName).toBe('slide.pptx'); + expectMessagePosted(postMessageSpy, 'document:opened', 'uint8-1'); + }); + + it('uses default filename "document.xlsx" when no name is supplied', async () => { + const buffer = new Uint8Array([1]).buffer; + + await openWithPayload({ buffer }, 'default-name-1'); + + const [callArg] = mockHandleDocumentOperation.mock.calls.at(-1)!; + expect(callArg.fileName).toBe('document.xlsx'); + }); + + it('posts document:error for an empty payload (no file/blob/buffer/url)', async () => { + await openWithPayload({}, 'invalid-1'); + + expectMessagePosted(postMessageSpy, 'document:error', 'invalid-1', { + message: expect.stringContaining('document:open requires'), + }); + }); + }); }); diff --git a/test/unit/onlyoffice-editor.test.ts b/test/unit/onlyoffice-editor.test.ts index efd320c1..f2f43cf7 100644 --- a/test/unit/onlyoffice-editor.test.ts +++ b/test/unit/onlyoffice-editor.test.ts @@ -15,10 +15,13 @@ vi.mock('../../lib/file-types', () => ({ c_oAscFileType2: { 65: 'XLSX', 43: 'DOC vi.mock('../../lib/document-utils', () => ({ getMimeTypeFromExtension: vi.fn().mockReturnValue('image/png') })); import { + getNormalizedFile, getReadonlyMode, + getSavedFileMimeType, requestSaveDocument, setConverterCallbacks, setReadonlyMode, + toUint8Array, } from '../../lib/onlyoffice-editor'; function makeEditor(extra: Record = {}) { @@ -146,4 +149,80 @@ describe('onlyoffice-editor', () => { ).not.toThrow(); }); }); + + describe('getSavedFileMimeType', () => { + it.each([ + ['report.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ['report.doc', 'application/msword'], + ['data.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + ['data.xls', 'application/vnd.ms-excel'], + ['data.csv', 'text/csv'], + ['deck.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'], + ['deck.ppt', 'application/vnd.ms-powerpoint'], + ['document.pdf', 'application/pdf'], + ['archive.zip', 'application/octet-stream'], + ['no-extension', 'application/octet-stream'], + ])('%s → %s', (fileName, expected) => { + expect(getSavedFileMimeType(fileName)).toBe(expected); + }); + + it('is case-insensitive for the extension', () => { + expect(getSavedFileMimeType('REPORT.DOCX')).toBe( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ); + }); + }); + + describe('getNormalizedFile', () => { + it('preserves an already-typed file unchanged', () => { + const file = new File(['data'], 'report.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + const result = getNormalizedFile(file); + expect(result.type).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + expect(result.name).toBe('report.docx'); + }); + + it('infers MIME type when file has no type', () => { + const file = new File(['data'], 'data.xlsx', { type: '' }); + const result = getNormalizedFile(file); + expect(result.type).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + }); + + it('infers MIME type when file has generic octet-stream type', () => { + const file = new File(['data'], 'deck.pptx', { type: 'application/octet-stream' }); + const result = getNormalizedFile(file); + expect(result.type).toBe('application/vnd.openxmlformats-officedocument.presentationml.presentation'); + }); + + it('preserves the original file name', () => { + const file = new File(['data'], 'my-document.csv', { type: '' }); + expect(getNormalizedFile(file).name).toBe('my-document.csv'); + }); + }); + + describe('toUint8Array', () => { + it('returns the same Uint8Array instance when given a Uint8Array', () => { + const arr = new Uint8Array([1, 2, 3]); + expect(toUint8Array(arr)).toBe(arr); + }); + + it('wraps an ArrayBuffer in a Uint8Array', () => { + const buf = new Uint8Array([4, 5, 6]).buffer; + const result = toUint8Array(buf); + expect(result).toBeInstanceOf(Uint8Array); + expect(Array.from(result)).toEqual([4, 5, 6]); + }); + + it('handles a typed-array view (e.g. Int16Array) correctly', () => { + const int16 = new Int16Array([256, 512]); + const result = toUint8Array(int16); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.byteLength).toBe(4); // 2 × 2 bytes + }); + + it('throws for unsupported types', () => { + expect(() => toUint8Array('string data' as unknown as BlobPart)).toThrow('Unsupported saved data type'); + }); + }); }); From 4ca3484023d2954a6ec90607836ebbebf3d7deb7 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 30 May 2026 21:43:14 +0800 Subject: [PATCH 05/19] chore: ignore coverage and playwright report directories --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index c3fa8a02..cea1db93 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,8 @@ cache vite.config.ts.timestamp* config.ts.timestamp* dist +# test artifacts +coverage/ +playwright-report/ +test-results/ public/fonts/__fallback__/ From 477bd4edeccd3b930df08a36dfa3f384e521351b Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 30 May 2026 21:45:04 +0800 Subject: [PATCH 06/19] docs: explain onlyoffice-editor.ts coverage limitations in CLAUDE.md --- CLAUDE.md | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9849f894..0c1140da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,8 +181,33 @@ E2E 在 CI 中依赖 `lint` job 成功后才运行(`needs: lint`)。本地 --- -## 待完善的测试覆盖 +## 测试覆盖说明 -- `embed-api.ts`:`makeFileFromPayload` 的 File/Blob/ArrayBuffer/Uint8Array 四条分支(第 73–109 行) -- `onlyoffice-editor.ts`:`getSavedFileMimeType`、`getNormalizedFile`、`toUint8Array` 等纯函数目前未导出,如需提升覆盖率可将其导出并单独测试 -- E2E:文档上传、编辑、保存流程(当前只有冒烟测试) +### 当前覆盖率(coverage include 范围内) + +| 文件 | 语句 | 分支 | 函数 | 备注 | +|---|---|---|---|---| +| `embed-api.ts` | 97% | 91% | 100% | 接近完整覆盖 | +| `document-utils.ts` | 89% | 87% | 100% | 接近完整覆盖 | +| `i18n.ts` | 92% | 65% | 93% | 未覆盖部分语言的特定翻译键 | +| `onlyoffice-editor.ts` | ~28% | ~25% | ~41% | 见下方说明 | + +### 为什么 onlyoffice-editor.ts 覆盖率低 + +这是预期行为,**不需要强行提升**。该文件 542 行中约 400 行是 OnlyOffice 编辑器的事件回调,必须有真实编辑器运行才能触发: + +| 函数 | 无法单测的原因 | +|---|---| +| `createEditorInstance` (~120 行) | 依赖 `window.DocsAPI`,该对象由外部脚本动态注入,jsdom 不执行外部脚本 | +| `handleSaveDocument` (~55 行) | 由编辑器 `onSave` 事件触发,需真实编辑器实例 | +| `handleWriteFile` (~75 行) | 由编辑器 `writeFile` 事件触发(粘贴图片时) | +| `handleDownloadAs` (~35 行) | 由编辑器 `onDownloadAs` 事件触发 | +| `queueEditorOperation` (~40 行) | `createEditorInstance` 内部队列,连带未覆盖 | +| `loadEditorApi` (~20 行) | 动态创建 ` + - - - -
- - -
-
- Document Editor - loading -
- -
-
- - - + document.getElementById('openUrlBtn').addEventListener('click', async () => { + const url = document.getElementById('urlInput').value.trim(); + if (!url) { + log('请先输入文件 URL'); + return; + } + await post('document:open-url', { + url, + readonly: readonlyInput.checked, + }); + }); + readonlyInput.addEventListener('change', async () => { + await post('document:set-readonly', { readonly: readonlyInput.checked }); + saveBtn.disabled = readonlyInput.checked; + }); + + saveBtn.addEventListener('click', async () => { + const result = await post('document:save', { targetExt: 'XLSX' }); + const file = result.file; + window.lastSavedFile = file; + log('父页面已拿到 File,可在这里 fetch 上传', { + fileName: file.name, + size: file.size, + type: file.type, + sha256: await hashFile(file), + firstSheetB3: await readFirstSheetCell(file, 'B3'), + }); + + // 示例上传写法: + // await fetch('/api/upload', { + // method: 'POST', + // headers: { Authorization: `Bearer ${token}` }, + // body: file, + // }); + }); + + diff --git a/public/sdkjs/common/AllFonts.js b/public/sdkjs/common/AllFonts.js index a24dfc39..64f9ea65 100644 --- a/public/sdkjs/common/AllFonts.js +++ b/public/sdkjs/common/AllFonts.js @@ -358,7 +358,8 @@ if (!/^c:\\windows\\fonts\\/i.test(fontPath)) return fontPath; const fileName = fontPath.split('\\').pop().toLowerCase(); - const cjkFontPattern = /^(deng|dengb|dengl|fz|javanese|malgun|mingliu|msgothic|msyh|msyhbd|msyhl|sim|st|yu|yugoth|yumin)/i; + const cjkFontPattern = + /^(deng|dengb|dengl|fz|javanese|malgun|mingliu|msgothic|msyh|msyhbd|msyhl|sim|st|yu|yugoth|yumin)/i; const fallbackFile = cjkFontPattern.test(fileName) ? 'NotoSansTC-VF.ttf' : 'LiberationSans-Bold.ttf'; return window.__document_font_base_path + 'fonts/' + fallbackFile; }), diff --git a/readme.md b/readme.md index d2078f0d..a20c270c 100644 --- a/readme.md +++ b/readme.md @@ -98,20 +98,13 @@ http://127.0.0.1:8082/embed-demo.html ### 1. 嵌入编辑器 ```html - + ``` 如果需要限制只接收指定父页面来源,可以增加 `embedOrigin`: ```html - + ``` ### 2. 发送命令 @@ -281,20 +274,20 @@ window.addEventListener('message', async (event) => { ### 6. 支持的消息 -| 方向 | 类型 | 说明 | -| ---- | ---- | ---- | -| 父页面 → 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 初始化完成 | -| iframe → 父页面 | `document:opened` | 文档打开完成 | -| iframe → 父页面 | `document:readonly-changed` | 只读状态已切换 | -| iframe → 父页面 | `document:saved` | 保存完成,返回文件 | -| iframe → 父页面 | `document:state` | 返回当前状态 | -| iframe → 父页面 | `document:error` | 操作失败 | +| 方向 | 类型 | 说明 | +| --------------- | --------------------------- | ------------------------------------------ | +| 父页面 → 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 初始化完成 | +| iframe → 父页面 | `document:opened` | 文档打开完成 | +| iframe → 父页面 | `document:readonly-changed` | 只读状态已切换 | +| iframe → 父页面 | `document:saved` | 保存完成,返回文件 | +| iframe → 父页面 | `document:state` | 返回当前状态 | +| iframe → 父页面 | `document:error` | 操作失败 | ## 🛠️ Technical Architecture diff --git a/test/e2e/embed-api.spec.ts b/test/e2e/embed-api.spec.ts index 44efecd4..f87e9385 100644 --- a/test/e2e/embed-api.spec.ts +++ b/test/e2e/embed-api.spec.ts @@ -46,9 +46,7 @@ test.describe('postMessage API', () => { // Give a tick for async postMessage dispatch await page.waitForTimeout(200); - const messages = await page.evaluate( - () => (window as any).__capturedMessages as Array<{ type?: string }>, - ); + const messages = await page.evaluate(() => (window as any).__capturedMessages as Array<{ type?: string }>); expect(messages.some((m) => m.type === 'document:ready')).toBe(true); }); @@ -81,8 +79,7 @@ test.describe('postMessage API', () => { new Promise((resolve) => { window.addEventListener('message', (e: MessageEvent) => { const msg = e.data as { type?: string; id?: string; payload?: { readonly?: boolean } }; - if (msg?.type === 'document:state' && msg?.id === 'e2e-before') - resolve(msg.payload?.readonly ?? false); + if (msg?.type === 'document:state' && msg?.id === 'e2e-before') resolve(msg.payload?.readonly ?? false); }); window.postMessage({ type: 'document:get-state', id: 'e2e-before' }, '*'); }), diff --git a/test/unit/embed-api.test.ts b/test/unit/embed-api.test.ts index 66a397ea..6c7b2738 100644 --- a/test/unit/embed-api.test.ts +++ b/test/unit/embed-api.test.ts @@ -138,10 +138,7 @@ describe('embed-api', () => { const { initEmbedApi } = await import('../../lib/embed-api'); initEmbedApi(); - await dispatchMessage( - { type: 'document:get-state', id: 'origin-allow-1' }, - 'https://allowed.example.com', - ); + await dispatchMessage({ type: 'document:get-state', id: 'origin-allow-1' }, 'https://allowed.example.com'); expectMessagePosted(postMessageSpy, 'document:state', 'origin-allow-1'); }); diff --git a/test/unit/onlyoffice-editor.test.ts b/test/unit/onlyoffice-editor.test.ts index f2f43cf7..433ed867 100644 --- a/test/unit/onlyoffice-editor.test.ts +++ b/test/unit/onlyoffice-editor.test.ts @@ -60,9 +60,7 @@ describe('onlyoffice-editor', () => { setReadonlyMode(true); - expect(editor.sendCommand).toHaveBeenCalledWith( - expect.objectContaining({ command: 'processRightsChange' }), - ); + expect(editor.sendCommand).toHaveBeenCalledWith(expect.objectContaining({ command: 'processRightsChange' })); }); it('does not throw when no editor is present', () => { From bd04fbacdeb86ec15b4643b2aca59ccf80e1d9ad Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 30 May 2026 22:18:32 +0800 Subject: [PATCH 13/19] docs: add iframe embed API section to Chinese readme --- readme.zh.md | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/readme.zh.md b/readme.zh.md index 238a7f49..0052c935 100644 --- a/readme.zh.md +++ b/readme.zh.md @@ -79,6 +79,156 @@ 📚 **预览组件文档**: [https://chaxus.github.io/ran/src/ranui/preview/](https://chaxus.github.io/ran/src/ranui/preview/) +## 🧩 iframe 嵌入使用方式 + +本项目支持通过 iframe 嵌入到其他业务系统中。推荐架构是:**父系统负责鉴权、下载文件和上传保存结果;iframe 只负责文档编辑**。这样 token、cookie、业务接口都留在父系统内,编辑器不需要知道业务系统的授权细节。 + +项目内置了一个示例页面: + +```text +/embed-demo.html +``` + +本地启动后可以访问: + +```text +http://127.0.0.1:8082/embed-demo.html +``` + +### 1. 嵌入编辑器 + +```html + +``` + +如果需要限制只接收指定父页面来源,可以增加 `embedOrigin`: + +```html + +``` + +### 2. 发送命令 + +建议每条命令带上 `id`,便于父页面匹配响应: + +```js +const iframe = document.getElementById('documentEditor'); +const editorOrigin = 'http://127.0.0.1:8082'; + +function sendEditorCommand(type, payload = {}) { + const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + iframe.contentWindow.postMessage({ id, type, payload }, editorOrigin); + return id; +} +``` + +监听 iframe 响应: + +```js +window.addEventListener('message', (event) => { + if (event.origin !== editorOrigin) return; + + const { id, type, payload } = event.data || {}; + if (!type || !type.startsWith('document:')) return; + + if (type === 'document:ready') { + console.log('编辑器已就绪'); + } + + if (type === 'document:opened') { + console.log('文档已打开', id, payload); + } + + if (type === 'document:saved') { + console.log('保存完成', payload.fileName, payload.file); + } + + if (type === 'document:error') { + console.error('编辑器错误', payload.message); + } +}); +``` + +### 3. 打开文档 + +通过 URL 打开: + +```js +sendEditorCommand('document:open-url', { + url: 'https://example.com/files/demo.xlsx', + fileName: 'demo.xlsx', + readonly: false, +}); +``` + +通过本地文件对话框打开: + +```js +const input = document.createElement('input'); +input.type = 'file'; +input.accept = '.xlsx,.xls,.csv,.docx,.doc,.pptx,.ppt'; +input.onchange = () => { + const file = input.files[0]; + sendEditorCommand('document:open-file', { file, 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, +}); +``` + +### 4. 设置只读 + +```js +sendEditorCommand('document:set-readonly', { readonly: true }); +``` + +### 5. 保存并上传到服务端 + +```js +sendEditorCommand('document:save', { targetExt: 'XLSX' }); + +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, + }); +}); +``` + +### 6. 支持的消息 + +| 方向 | 类型 | 说明 | +| --- | --- | --- | +| 父页面 → 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 初始化完成 | +| iframe → 父页面 | `document:opened` | 文档打开完成 | +| iframe → 父页面 | `document:readonly-changed` | 只读状态已切换 | +| iframe → 父页面 | `document:saved` | 保存完成,返回文件 | +| iframe → 父页面 | `document:state` | 返回当前状态 | +| iframe → 父页面 | `document:error` | 操作失败 | + ## 🛠️ 技术架构 - **OnlyOffice SDK**: 提供强大的文档编辑能力 From be1af300aabcde74d572dcc4c333fcda2c84ab22 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 30 May 2026 22:20:46 +0800 Subject: [PATCH 14/19] docs: translate iframe section to English and fix pnpm/npm in both readmes --- readme.md | 162 +++++++++++++++++---------------------------------- readme.zh.md | 29 ++++++++- 2 files changed, 81 insertions(+), 110 deletions(-) diff --git a/readme.md b/readme.md index a20c270c..2e53be67 100644 --- a/readme.md +++ b/readme.md @@ -79,37 +79,31 @@ This project provides foundational services for document preview components in t 📚 **Preview Component Documentation**: [https://chaxus.github.io/ran/src/ranui/preview/](https://chaxus.github.io/ran/src/ranui/preview/) -## 🧩 iframe 嵌入使用方式 +## 🧩 Embedding via iframe -本项目支持通过 iframe 嵌入到其他业务系统中。推荐架构是:**父系统负责鉴权、下载文件和上传保存结果;iframe 只负责文档编辑**。这样 token、cookie、业务接口都留在父系统内,编辑器不需要知道业务系统的授权细节。 +This project supports embedding into other systems via iframe. The recommended architecture is: **the parent system handles authentication, file fetching, and upload; the iframe handles document editing only**. This keeps tokens, cookies, and business APIs inside the parent system — the editor never needs to know about your auth details. -项目内置了一个示例页面: - -```text -/embed-demo.html -``` - -本地启动后可以访问: +A built-in demo page is included: ```text http://127.0.0.1:8082/embed-demo.html ``` -### 1. 嵌入编辑器 +### 1. Embed the editor ```html - + ``` -如果需要限制只接收指定父页面来源,可以增加 `embedOrigin`: +To restrict messages to a specific parent origin, add `embedOrigin`: ```html - + ``` -### 2. 发送命令 +### 2. Send commands -建议每条命令带上 `id`,便于父页面匹配响应: +Include an `id` on each command so you can match it to the response: ```js const iframe = document.getElementById('documentEditor'); @@ -122,7 +116,7 @@ function sendEditorCommand(type, payload = {}) { } ``` -监听 iframe 响应: +Listen for responses: ```js window.addEventListener('message', (event) => { @@ -131,27 +125,16 @@ window.addEventListener('message', (event) => { const { id, type, payload } = event.data || {}; if (!type || !type.startsWith('document:')) return; - if (type === 'document:ready') { - console.log('编辑器已就绪'); - } - - if (type === 'document:opened') { - console.log('文档已打开', id, payload); - } - - if (type === 'document:saved') { - console.log('保存完成', payload.fileName, payload.file); - } - - if (type === 'document:error') { - console.error('编辑器错误', payload.message); - } + if (type === 'document:ready') console.log('Editor ready'); + if (type === 'document:opened') console.log('Document opened', id, payload); + if (type === 'document:saved') console.log('Saved', payload.fileName, payload.file); + if (type === 'document:error') console.error('Error', payload.message); }); ``` -### 3. 打开文档 +### 3. Open a document -通过 URL 打开: +From URL: ```js sendEditorCommand('document:open-url', { @@ -161,133 +144,96 @@ sendEditorCommand('document:open-url', { }); ``` -如果 URL 接口需要授权,可以传 `fetchOptions`,但更推荐由父系统自己 `fetch` 后传入文件对象: +If the URL requires auth headers, pass `fetchOptions` — though it's preferable to have the parent system fetch the file and pass it as a buffer: ```js sendEditorCommand('document:open-url', { url: 'https://example.com/api/files/1', fileName: 'demo.xlsx', - readonly: false, - fetchOptions: { - headers: { - Authorization: `Bearer ${token}`, - }, - }, + fetchOptions: { headers: { Authorization: `Bearer ${token}` } }, }); ``` -通过本地文件对话框打开: +From a local file picker: ```js const input = document.createElement('input'); input.type = 'file'; input.accept = '.xlsx,.xls,.csv,.docx,.doc,.pptx,.ppt'; input.onchange = () => { - const file = input.files[0]; - sendEditorCommand('document:open-file', { - file, - readonly: false, - }); + 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}`, - }, + headers: { Authorization: `Bearer ${token}` }, }); - const buffer = await response.arrayBuffer(); - -sendEditorCommand('document:open-buffer', { - fileName: 'demo.xlsx', - buffer, - readonly: false, -}); +sendEditorCommand('document:open-buffer', { fileName: 'demo.xlsx', buffer, readonly: false }); ``` -### 4. 设置只读 - -打开文档时可以直接设置: - -```js -sendEditorCommand('document:open-buffer', { - fileName: 'demo.xlsx', - buffer, - readonly: true, -}); -``` +### 4. Read-only mode -文档打开后也可以切换: +Set at open time, or toggle afterwards: ```js -sendEditorCommand('document:set-readonly', { - readonly: true, -}); +sendEditorCommand('document:set-readonly', { readonly: true }); ``` -只读模式下编辑权限会关闭,保存命令会返回 `document:error`。 +In read-only mode editing is disabled and `document:save` returns `document:error`. -### 5. 保存并上传到服务端 +### 5. Save and upload -保存命令会触发编辑器导出当前正在编辑的内容,并通过 `document:saved` 返回一个新的 `File` 对象。默认保存为 `XLSX`,也可以传入其他格式,例如 `DOCX`、`PPTX`、`CSV`。 +The save command exports the current document and returns a `File` via `document:saved`. Default format is `XLSX`; pass `targetExt` to change it (`DOCX`, `PPTX`, `CSV`). ```js -sendEditorCommand('document:save', { - targetExt: 'XLSX', -}); +sendEditorCommand('document:save', { targetExt: 'XLSX' }); ``` -默认情况下,保存命令必须等到编辑器返回当前编辑后的文件数据;如果超时会返回 `document:error`,避免误把原始文件上传到服务端。如果你的业务确实希望“没有修改时也回传原文件”,可以显式开启: +By default the command waits for the editor to return the edited file — if it times out, `document:error` is returned instead of silently uploading the original. To opt in to returning the original on timeout: ```js -sendEditorCommand('document:save', { - targetExt: 'XLSX', - returnOriginalOnTimeout: true, -}); +sendEditorCommand('document:save', { targetExt: 'XLSX', returnOriginalOnTimeout: true }); ``` -父页面拿到文件后自行上传: +Upload the returned file 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}`, - }, + headers: { Authorization: `Bearer ${token}` }, body: payload.file, }); }); ``` -注意:不要只用 `size` 判断文件是否变化,`xlsx` 是压缩包格式,轻微编辑后文件大小可能刚好不变。建议在调试时对返回的 `File` 计算 hash,项目内置的 `/embed-demo.html` 已经会在保存日志里打印 `sha256`。 - -### 6. 支持的消息 - -| 方向 | 类型 | 说明 | -| --------------- | --------------------------- | ------------------------------------------ | -| 父页面 → 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 初始化完成 | -| iframe → 父页面 | `document:opened` | 文档打开完成 | -| iframe → 父页面 | `document:readonly-changed` | 只读状态已切换 | -| iframe → 父页面 | `document:saved` | 保存完成,返回文件 | -| iframe → 父页面 | `document:state` | 返回当前状态 | -| iframe → 父页面 | `document:error` | 操作失败 | +> **Note:** Do not rely on `file.size` alone to detect changes — `.xlsx` files are zip archives and a minor edit may produce the same byte count. The built-in `/embed-demo.html` prints a `sha256` hash in the save log for debugging. + +### 6. 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 | ## 🛠️ Technical Architecture @@ -347,8 +293,8 @@ services: ```bash git clone https://github.com/ranuts/document.git cd document -npm install -npm run dev +pnpm install +pnpm run dev ``` ## 🔤 Font Management diff --git a/readme.zh.md b/readme.zh.md index 0052c935..9f468ef0 100644 --- a/readme.zh.md +++ b/readme.zh.md @@ -161,6 +161,16 @@ sendEditorCommand('document:open-url', { }); ``` +如果 URL 接口需要授权,可以传 `fetchOptions`,但更推荐由父系统自己 `fetch` 后传入文件对象: + +```js +sendEditorCommand('document:open-url', { + url: 'https://example.com/api/files/1', + fileName: 'demo.xlsx', + fetchOptions: { headers: { Authorization: `Bearer ${token}` } }, +}); +``` + 通过本地文件对话框打开: ```js @@ -196,9 +206,21 @@ sendEditorCommand('document:set-readonly', { readonly: true }); ### 5. 保存并上传到服务端 +保存命令会触发编辑器导出当前正在编辑的内容,并通过 `document:saved` 返回一个新的 `File` 对象。默认保存为 `XLSX`,也可以传入其他格式,例如 `DOCX`、`PPTX`、`CSV`。 + ```js sendEditorCommand('document:save', { targetExt: 'XLSX' }); +``` +默认情况下,保存命令必须等到编辑器返回当前编辑后的文件数据;如果超时会返回 `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 || {}; @@ -212,6 +234,9 @@ window.addEventListener('message', async (event) => { }); ``` +> **注意:** 不要只用 `size` 判断文件是否变化,`xlsx` 是压缩包格式,轻微编辑后文件大小可能刚好不变。建议在调试时对返回的 `File` 计算 hash,项目内置的 `/embed-demo.html` 已经会在保存日志里打印 `sha256`。 +``` + ### 6. 支持的消息 | 方向 | 类型 | 说明 | @@ -287,8 +312,8 @@ services: ```bash git clone https://github.com/ranuts/document.git cd document -npm install -npm run dev +pnpm install +pnpm run dev ``` ## 🔤 字体管理 From 80fc455234a8869479f896a691f894dd7fb93689 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 30 May 2026 22:24:40 +0800 Subject: [PATCH 15/19] docs: restructure readmes and extract detailed docs to docs/ --- docs/embed-api.md | 176 +++++++++++++++++++++ docs/embed-api.zh.md | 176 +++++++++++++++++++++ docs/fonts.md | 28 ++++ docs/fonts.zh.md | 28 ++++ readme.md | 343 ++++++++++------------------------------ readme.zh.md | 364 ++++++++++--------------------------------- 6 files changed, 575 insertions(+), 540 deletions(-) create mode 100644 docs/embed-api.md create mode 100644 docs/embed-api.zh.md create mode 100644 docs/fonts.md create mode 100644 docs/fonts.zh.md diff --git a/docs/embed-api.md b/docs/embed-api.md new file mode 100644 index 00000000..77806aac --- /dev/null +++ b/docs/embed-api.md @@ -0,0 +1,176 @@ +# 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 + +``` + +To restrict messages to a specific origin, add `embedOrigin`: + +```html + +``` + +--- + +## 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 | diff --git a/docs/embed-api.zh.md b/docs/embed-api.zh.md new file mode 100644 index 00000000..c38d3f38 --- /dev/null +++ b/docs/embed-api.zh.md @@ -0,0 +1,176 @@ +# iframe 嵌入 API + +本项目支持通过 iframe 嵌入到任何 Web 应用中。推荐架构是:**父系统负责鉴权、下载文件和上传保存结果;iframe 只负责文档编辑**。Token、Cookie、业务接口都留在父系统内,编辑器不需要知道授权细节。 + +项目内置了完整示例页面 `/embed-demo.html`,包含保存时的 sha256 哈希日志,方便调试。 + +--- + +## 嵌入编辑器 + +```html + +``` + +如需限制只接受指定来源的消息,增加 `embedOrigin`: + +```html + +``` + +--- + +## 发送命令 + +建议每条命令带上 `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` | 操作失败 | diff --git a/docs/fonts.md b/docs/fonts.md new file mode 100644 index 00000000..5affc394 --- /dev/null +++ b/docs/fonts.md @@ -0,0 +1,28 @@ +# Font Management + +## Why fonts are not included + +This project does not ship proprietary font files such as Arial, Times New Roman, Microsoft YaHei, or SimSun. These fonts are subject to copyright restrictions. Font name references remain in the configuration for document compatibility, but the actual files have been removed to comply with open-source licensing. + +## Adding fonts + +Font files go in `public/fonts/` and must be named by their numeric index in the `__fonts_files` array in `public/sdkjs/common/AllFonts.js`. + +**Example: Adding Arial** + +1. Open `AllFonts.js` and find the index for Arial regular — it is `223` +2. Place your font file at `public/fonts/223` (no extension) +3. The app loads it automatically when index `223` is requested + +Other Arial variants: + +| Variant | Index | Path | +| ----------- | ----- | ----------------- | +| Regular | 223 | `public/fonts/223` | +| Italic | 224 | `public/fonts/224` | +| Bold | 226 | `public/fonts/226` | +| Bold Italic | 225 | `public/fonts/225` | + +To find the index for any font, look up its entry in the `__fonts_infos` array in `AllFonts.js`. + +> Only use open-source fonts or fonts you have a valid license for. diff --git a/docs/fonts.zh.md b/docs/fonts.zh.md new file mode 100644 index 00000000..67db7580 --- /dev/null +++ b/docs/fonts.zh.md @@ -0,0 +1,28 @@ +# 字体管理 + +## 为什么不包含字体文件 + +本项目不包含 Arial、Times New Roman、微软雅黑、宋体等受版权保护的字体文件。这些字体名称的引用保留在配置文件中以确保文档兼容性,但实际字体文件已移除,以符合开源许可要求。 + +## 添加字体 + +字体文件放在 `public/fonts/` 目录下,文件名为 `public/sdkjs/common/AllFonts.js` 中 `__fonts_files` 数组的对应数字索引(无需扩展名)。 + +**示例:添加 Arial 字体** + +1. 打开 `AllFonts.js`,找到 Arial 常规字体的索引 — 是 `223` +2. 将字体文件放置为 `public/fonts/223` +3. 应用程序引用索引 `223` 时会自动加载该文件 + +Arial 其他变体: + +| 变体 | 索引 | 路径 | +| ------ | ---- | ------------------ | +| 常规 | 223 | `public/fonts/223` | +| 斜体 | 224 | `public/fonts/224` | +| 粗体 | 226 | `public/fonts/226` | +| 粗斜体 | 225 | `public/fonts/225` | + +查找任意字体的索引,请查阅 `AllFonts.js` 中的 `__fonts_infos` 数组。 + +> 请仅使用开源字体或拥有合法授权的字体。 diff --git a/readme.md b/readme.md index 2e53be67..0236976a 100644 --- a/readme.md +++ b/readme.md @@ -19,325 +19,148 @@ English | 中文

-A local web-based document editor based on OnlyOffice, allowing you to edit documents directly in your browser without server-side processing, ensuring your privacy and security. +A privacy-first, browser-based document editor powered by OnlyOffice. Edit DOCX, XLSX, PPTX, and CSV files directly in your browser — no server, no uploads, no account required. -## ✨ Key Features +--- -- 🔒 **Privacy-First**: All document processing happens locally in your browser, with no uploads to any server -- 📝 **Multi-Format Support**: Supports DOCX, XLSX, PPTX, CSV, and many other document formats -- ⚡ **Real-Time Editing**: Provides smooth real-time document editing experience -- 🚀 **No Server Required**: Pure frontend implementation with no server-side processing needed -- 🎯 **Ready to Use**: Start editing documents immediately by opening the webpage -- 🌐 **Open from URL**: Load documents directly from remote URLs via URL parameters -- 🌍 **Multi-Language**: Supports multiple languages (English, Chinese) with easy switching +## ✨ Features -## 📖 Usage - -### Basic Usage - -1. Visit the [Online Editor](https://ranuts.github.io/document/) -2. Upload your document files or open from URL -3. Edit directly in your browser -4. Download the edited documents - -### Offline Usage (PWA) - -This application supports offline usage via PWA (Progressive Web App) technology. - -1. Visit the editor using a supported browser (Chrome, Edge, etc.) over **HTTPS** (or localhost). -2. Click the **Install** icon in the address bar to install the app. -3. Once installed, the editor can be launched from your application menu and will work without an internet connection. +- 🔒 **Privacy-first** — all processing happens locally, nothing is uploaded +- 📝 **Multi-format** — DOCX, XLSX, PPTX, CSV and more +- 🚀 **No server required** — pure frontend, deploy anywhere +- 🌐 **Open from URL** — load documents via `?src=` or `?file=` parameters +- 📦 **PWA support** — install and use offline +- 🌍 **Multi-language** — English, Chinese, and more +- 🧩 **Embeddable** — full postMessage API for iframe integration -**Note**: Due to browser security restrictions, Service Workers (required for offline support) do not work when opening `index.html` directly from the filesystem (`file://` protocol). You must use a local server or the installed PWA. +--- -### URL Parameters +## 🚀 Quick Start -| Parameter | Description | Values/Type | Priority | -| --------- | -------------------------------------------- | ----------- | -------- | -| `locale` | Set interface language | `en`, `zh` | - | -| `src` | Open document from URL (recommended) | URL string | Low | -| `file` | Open document from URL (backward compatible) | URL string | High | +**Try it online:** [ranuts.github.io/document](https://ranuts.github.io/document/) -**Examples:** +**Run with Docker:** ```bash -# Set language -?locale=zh - -# Open document from URL -?src=https://example.com/document.docx - -# Combine parameters -?locale=zh&src=https://example.com/doc.docx -``` - -**Note**: When both `file` and `src` are provided, `file` takes priority. Remote URLs must support CORS. - -### As a Component Library - -This project provides foundational services for document preview components in the [@ranui/preview](https://www.npmjs.com/package/@ranui/preview) WebComponent library. - -📚 **Preview Component Documentation**: [https://chaxus.github.io/ran/src/ranui/preview/](https://chaxus.github.io/ran/src/ranui/preview/) - -## 🧩 Embedding via iframe - -This project supports embedding into other systems via iframe. The recommended architecture is: **the parent system handles authentication, file fetching, and upload; the iframe handles document editing only**. This keeps tokens, cookies, and business APIs inside the parent system — the editor never needs to know about your auth details. - -A built-in demo page is included: - -```text -http://127.0.0.1:8082/embed-demo.html -``` - -### 1. Embed the editor - -```html - -``` - -To restrict messages to a specific parent origin, add `embedOrigin`: - -```html - -``` - -### 2. Send commands - -Include an `id` on each command so you can match it to the response: - -```js -const iframe = document.getElementById('documentEditor'); -const editorOrigin = 'http://127.0.0.1:8082'; - -function sendEditorCommand(type, payload = {}) { - const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`; - iframe.contentWindow.postMessage({ id, type, payload }, editorOrigin); - return id; -} +docker run -d --name document -p 8080:80 ghcr.io/ranuts/document:latest ``` -Listen for responses: - -```js -window.addEventListener('message', (event) => { - if (event.origin !== editorOrigin) return; +**Run locally:** - const { id, type, payload } = event.data || {}; - if (!type || !type.startsWith('document:')) return; - - if (type === 'document:ready') console.log('Editor ready'); - if (type === 'document:opened') console.log('Document opened', id, payload); - if (type === 'document:saved') console.log('Saved', payload.fileName, payload.file); - if (type === 'document:error') console.error('Error', payload.message); -}); +```bash +git clone https://github.com/ranuts/document.git +cd document +pnpm install +pnpm run dev ``` -### 3. Open a document +--- -From URL: +## 📖 Usage -```js -sendEditorCommand('document:open-url', { - url: 'https://example.com/files/demo.xlsx', - fileName: 'demo.xlsx', - readonly: false, -}); -``` +### Open a document -If the URL requires auth headers, pass `fetchOptions` — though it's preferable to have the parent system fetch the file and pass it as a buffer: +1. Click the upload button to open a local file, or +2. Pass a URL via query parameter: `?src=https://example.com/document.docx` -```js -sendEditorCommand('document:open-url', { - url: 'https://example.com/api/files/1', - fileName: 'demo.xlsx', - fetchOptions: { headers: { Authorization: `Bearer ${token}` } }, -}); -``` +> Remote URLs must support CORS. -From a local file picker: +### URL parameters -```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(); -``` +| Parameter | Description | Priority | +| --------- | ---------------------------------------- | -------- | +| `src` | Open document from URL (recommended) | Low | +| `file` | Open document from URL (legacy) | High | +| `locale` | Set interface language (`en`, `zh`) | — | -From an authenticated fetch (recommended for protected files): +When both `src` and `file` are present, `file` takes priority. -```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 }); -``` +### PWA offline usage -### 4. Read-only mode +Visit the editor over HTTPS (or localhost), then click the **Install** icon in the address bar. Once installed, the editor works without an internet connection. -Set at open time, or toggle afterwards: +> Service Workers don't work over `file://`. Use a local server or the installed PWA. -```js -sendEditorCommand('document:set-readonly', { readonly: true }); -``` +### As a component library -In read-only mode editing is disabled and `document:save` returns `document:error`. +This project powers the document preview component in [@ranui/preview](https://www.npmjs.com/package/@ranui/preview). -### 5. Save and upload +📚 [Preview component docs](https://chaxus.github.io/ran/src/ranui/preview/) -The save command exports the current document and returns a `File` via `document:saved`. Default format is `XLSX`; pass `targetExt` to change it (`DOCX`, `PPTX`, `CSV`). +--- -```js -sendEditorCommand('document:save', { targetExt: 'XLSX' }); -``` +## 🧩 Embedding via iframe -By default the command waits for the editor to return the edited file — if it times out, `document:error` is returned instead of silently uploading the original. To opt in to returning the original on timeout: +Embed the editor in your application and control it via postMessage. The recommended pattern is: the parent system handles auth and file upload; the iframe handles editing only. -```js -sendEditorCommand('document:save', { targetExt: 'XLSX', returnOriginalOnTimeout: true }); +```html + ``` -Upload the returned file 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, - }); +// Open a document +iframe.contentWindow.postMessage( + { id: '1', type: 'document:open-url', payload: { url: 'https://example.com/doc.xlsx' } }, + 'https://your-deployment' +); + +// Listen for the result +window.addEventListener('message', (e) => { + if (e.data?.type === 'document:opened') console.log('Ready to edit'); + if (e.data?.type === 'document:saved') uploadFile(e.data.payload.file); }); ``` -> **Note:** Do not rely on `file.size` alone to detect changes — `.xlsx` files are zip archives and a minor edit may produce the same byte count. The built-in `/embed-demo.html` prints a `sha256` hash in the save log for debugging. - -### 6. Message reference +→ **[Full API reference](docs/embed-api.md)** — all message types, options, and examples including auth, read-only mode, and save flow. -| 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 | - -## 🛠️ Technical Architecture - -- **OnlyOffice SDK**: Provides powerful document editing capabilities -- **WebAssembly**: Implements document format conversion through x2t-wasm -- **Pure Frontend Architecture**: All functionality runs in the browser +--- ## 🚀 Deployment ### Docker ```bash -# docker run +# Basic docker run -d --name document -p 8080:80 ghcr.io/ranuts/document:latest -# docker compose -services: - document: - image: ghcr.io/ranuts/document:latest - container_name: document - ports: - - 8080:80 -``` - -#### Advanced Configuration - -```yaml -name: document -services: - document: - image: ghcr.io/ranuts/document:latest - container_name: document - ports: - - 8080:80 - # Advanced Configuration - volumes: - # Add certificates - - certificate_path:/ssl - environment: - # Set account - # Format username:password, password must be encoded using BCrypt hash function. - # To get BCrypt encryption result, replace $ in the encrypted result with $$ for escaping. - SERVER_BASIC_AUTH: 'username:BCrypt_encrypted_password' - # Use certificate - SERVER_HTTP2_TLS: true - SERVER_HTTP2_TLS_CERT: certificate_path - SERVER_HTTP2_TLS_KEY: private_key_path -``` - -### Important Notes - -- **CORS**: Remote servers must support CORS when using `src` or `file` parameters -- **File Size**: Large files may take longer to load - -## 🔧 Local Development - -```bash -git clone https://github.com/ranuts/document.git -cd document -pnpm install -pnpm run dev +# With HTTPS and basic auth +docker run -d --name document -p 443:443 \ + -v /path/to/certs:/ssl \ + -e SERVER_BASIC_AUTH='user:$2y$...' \ + -e SERVER_HTTP2_TLS=true \ + -e SERVER_HTTP2_TLS_CERT=/ssl/cert.pem \ + -e SERVER_HTTP2_TLS_KEY=/ssl/key.pem \ + ghcr.io/ranuts/document:latest ``` -## 🔤 Font Management - -### Font Files in This Project - -This project is designed as an open-source solution, and therefore does not include proprietary font files such as **Arial**, **Times New Roman**, **Microsoft YaHei**, **SimSun**, and other Windows system fonts that are subject to copyright restrictions. These font references remain in the configuration files for compatibility with existing documents, but the actual font files have been removed to ensure compliance with open-source licensing requirements. - -### Adding Fonts - -To add fonts that are already configured in the project (such as Arial, Times New Roman, etc.), simply place the font files in the `public/fonts/` directory and rename them to match their corresponding index in the `__fonts_files` array in `public/sdkjs/common/AllFonts.js`. - -**Example: Adding Arial Font** - -If you want to add the Arial font to the project: +`SERVER_BASIC_AUTH` uses BCrypt-hashed passwords. Replace `$` with `$$` in the hash for shell escaping. -1. Check `AllFonts.js` and find that Arial regular font uses index `223` in the `__fonts_files` array -2. Place your Arial font file in `public/fonts/` and rename it to `223` (no extension needed) -3. The font file should be located at `public/fonts/223` -4. When the application references index `223`, it will automatically load the font file from `public/fonts/223` +--- -Similarly, for other Arial variants: +## 🔤 Fonts -- Arial Bold uses index `226` → place font file as `public/fonts/226` -- Arial Italic uses index `224` → place font file as `public/fonts/224` -- Arial Bold Italic uses index `225` → place font file as `public/fonts/225` +This project does not include proprietary fonts (Arial, Times New Roman, etc.) to comply with open-source licensing. Font name references are preserved for document compatibility. -You can find the index for any font by checking the `__fonts_infos` array in `AllFonts.js`, where each font entry specifies the indices for its regular, bold, italic, and bold-italic variants. +→ **[Font management guide](docs/fonts.md)** — how to add fonts by index. -**Note**: Only use open-source fonts or fonts for which you have proper licensing rights. Ensure compliance with font licensing terms before adding any font files. +--- ## 📚 References -- [onlyoffice-x2t-wasm](https://github.com/cryptpad/onlyoffice-x2t-wasm) - WebAssembly-based document converter -- [se-office](https://github.com/Qihoo360/se-office) - Secure document editor -- [web-apps](https://github.com/ONLYOFFICE/web-apps) - OnlyOffice web applications -- [sdkjs](https://github.com/ONLYOFFICE/sdkjs) - OnlyOffice JavaScript SDK -- [onlyoffice-web-local](https://github.com/sweetwisdom/onlyoffice-web-local) - Local web-based OnlyOffice implementation +- [onlyoffice-x2t-wasm](https://github.com/cryptpad/onlyoffice-x2t-wasm) — WASM document converter +- [web-apps](https://github.com/ONLYOFFICE/web-apps) — OnlyOffice web applications +- [sdkjs](https://github.com/ONLYOFFICE/sdkjs) — OnlyOffice JavaScript SDK +- [se-office](https://github.com/Qihoo360/se-office) — Secure document editor +- [onlyoffice-web-local](https://github.com/sweetwisdom/onlyoffice-web-local) — Local OnlyOffice implementation ## 🤝 Contributing -Issues and Pull Requests are welcome to help improve this project! +Issues and pull requests are welcome! ## 📄 License -See the [LICENSE](LICENSE) file for details. +[AGPL-3.0](LICENSE) diff --git a/readme.zh.md b/readme.zh.md index 9f468ef0..385770d3 100644 --- a/readme.zh.md +++ b/readme.zh.md @@ -19,344 +19,148 @@ English | 中文

-基于 OnlyOffice 的本地网页文档编辑器,让您直接在浏览器中编辑文档,无需服务器端处理,保护您的隐私安全。 +基于 OnlyOffice 的隐私优先浏览器文档编辑器。直接在浏览器中编辑 DOCX、XLSX、PPTX、CSV 文件——无需服务器、无需上传、无需注册账号。 -## ✨ 主要特性 - -- 🔒 **隐私优先**: 所有文档处理都在浏览器本地进行,不上传到任何服务器 -- 📝 **多格式支持**: 支持 DOCX、XLSX、PPTX、CSV 等多种文档格式 -- ⚡ **实时编辑**: 提供流畅的实时文档编辑体验 -- 🚀 **无需部署**: 纯前端实现,无需服务器端处理 -- 🎯 **即开即用**: 打开网页即可开始编辑文档 -- 🌐 **URL 打开**: 通过 URL 参数直接从远程地址加载文档 -- 🌍 **多语言支持**: 支持多种语言(英文、中文),轻松切换界面语言 - -## 📖 使用方法 - -### 基本使用 - -1. 访问 [在线编辑器](https://ranuts.github.io/document/) -2. 上传您的文档文件或从 URL 打开文档 -3. 直接在浏览器中编辑 -4. 下载编辑后的文档 +--- -### 离线使用 (PWA) - -本应用通过 PWA(渐进式 Web 应用)技术支持离线使用。 +## ✨ 主要特性 -1. 使用支持的浏览器(Chrome、Edge 等)通过 **HTTPS**(或 localhost)访问编辑器。 -2. 点击地址栏中的**安装**图标进行安装。 -3. 安装后,可以从应用程序菜单启动编辑器,且在断网状态下也能正常工作。 +- 🔒 **隐私优先** — 所有处理在本地完成,不上传任何数据 +- 📝 **多格式支持** — DOCX、XLSX、PPTX、CSV 等 +- 🚀 **无需服务器** — 纯前端实现,可部署到任意静态托管 +- 🌐 **URL 打开** — 通过 `?src=` 或 `?file=` 参数直接加载远程文档 +- 📦 **PWA 支持** — 可安装,支持离线使用 +- 🌍 **多语言** — 中文、英文及更多语言 +- 🧩 **可嵌入** — 完整的 postMessage API 支持 iframe 集成 -**注意**:由于浏览器安全限制,Service Worker(离线支持所需)在直接从文件系统打开 `index.html`(`file://` 协议)时无法工作。您必须使用本地服务器或已安装的 PWA。 +--- -### URL 参数 +## 🚀 快速开始 -| 参数 | 说明 | 值/类型 | 优先级 | -| -------- | --------------------------- | ---------- | ------ | -| `locale` | 设置界面语言 | `en`, `zh` | - | -| `src` | 从 URL 打开文档(推荐) | URL 字符串 | 低 | -| `file` | 从 URL 打开文档(向后兼容) | URL 字符串 | 高 | +**在线体验:** [ranuts.github.io/document](https://ranuts.github.io/document/) -**示例:** +**Docker 运行:** ```bash -# 设置语言 -?locale=zh - -# 从 URL 打开文档 -?src=https://example.com/document.docx - -# 组合使用 -?locale=zh&src=https://example.com/doc.docx -``` - -**注意**: 当同时提供 `file` 和 `src` 参数时,`file` 参数优先。远程 URL 必须支持 CORS。 - -### 作为组件库使用 - -本项目为 [@ranui/preview](https://www.npmjs.com/package/@ranui/preview) WebComponent 组件库提供文档预览组件的基础服务支持。 - -📚 **预览组件文档**: [https://chaxus.github.io/ran/src/ranui/preview/](https://chaxus.github.io/ran/src/ranui/preview/) - -## 🧩 iframe 嵌入使用方式 - -本项目支持通过 iframe 嵌入到其他业务系统中。推荐架构是:**父系统负责鉴权、下载文件和上传保存结果;iframe 只负责文档编辑**。这样 token、cookie、业务接口都留在父系统内,编辑器不需要知道业务系统的授权细节。 - -项目内置了一个示例页面: - -```text -/embed-demo.html -``` - -本地启动后可以访问: - -```text -http://127.0.0.1:8082/embed-demo.html -``` - -### 1. 嵌入编辑器 - -```html - -``` - -如果需要限制只接收指定父页面来源,可以增加 `embedOrigin`: - -```html - +docker run -d --name document -p 8080:80 ghcr.io/ranuts/document:latest ``` -### 2. 发送命令 - -建议每条命令带上 `id`,便于父页面匹配响应: +**本地开发:** -```js -const iframe = document.getElementById('documentEditor'); -const editorOrigin = 'http://127.0.0.1:8082'; - -function sendEditorCommand(type, payload = {}) { - const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`; - iframe.contentWindow.postMessage({ id, type, payload }, editorOrigin); - return id; -} +```bash +git clone https://github.com/ranuts/document.git +cd document +pnpm install +pnpm run dev ``` -监听 iframe 响应: - -```js -window.addEventListener('message', (event) => { - if (event.origin !== editorOrigin) return; - - const { id, type, payload } = event.data || {}; - if (!type || !type.startsWith('document:')) return; - - if (type === 'document:ready') { - console.log('编辑器已就绪'); - } - - if (type === 'document:opened') { - console.log('文档已打开', id, payload); - } - - if (type === 'document:saved') { - console.log('保存完成', payload.fileName, payload.file); - } +--- - if (type === 'document:error') { - console.error('编辑器错误', payload.message); - } -}); -``` +## 📖 使用方法 -### 3. 打开文档 +### 打开文档 -通过 URL 打开: +1. 点击上传按钮选择本地文件,或 +2. 通过 URL 参数传入:`?src=https://example.com/document.docx` -```js -sendEditorCommand('document:open-url', { - url: 'https://example.com/files/demo.xlsx', - fileName: 'demo.xlsx', - readonly: false, -}); -``` +> 远程 URL 需支持 CORS。 -如果 URL 接口需要授权,可以传 `fetchOptions`,但更推荐由父系统自己 `fetch` 后传入文件对象: +### URL 参数 -```js -sendEditorCommand('document:open-url', { - url: 'https://example.com/api/files/1', - fileName: 'demo.xlsx', - fetchOptions: { headers: { Authorization: `Bearer ${token}` } }, -}); -``` +| 参数 | 说明 | 优先级 | +| --------- | --------------------------------- | ------ | +| `src` | 从 URL 打开文档(推荐) | 低 | +| `file` | 从 URL 打开文档(向后兼容) | 高 | +| `locale` | 设置界面语言(`en`、`zh`) | — | -通过本地文件对话框打开: +同时提供 `src` 和 `file` 时,`file` 优先。 -```js -const input = document.createElement('input'); -input.type = 'file'; -input.accept = '.xlsx,.xls,.csv,.docx,.doc,.pptx,.ppt'; -input.onchange = () => { - const file = input.files[0]; - sendEditorCommand('document:open-file', { file, readonly: false }); -}; -input.click(); -``` +### 离线使用(PWA) -通过父系统授权请求后传入二进制数据: +通过 HTTPS(或 localhost)访问编辑器,点击地址栏中的**安装**图标。安装后可在无网络环境下正常使用。 -```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, -}); -``` +> Service Worker 在 `file://` 协议下无法工作,请使用本地服务器或已安装的 PWA。 -### 4. 设置只读 +### 作为组件库使用 -```js -sendEditorCommand('document:set-readonly', { readonly: true }); -``` +本项目为 [@ranui/preview](https://www.npmjs.com/package/@ranui/preview) WebComponent 组件库提供文档预览能力。 -### 5. 保存并上传到服务端 +📚 [预览组件文档](https://chaxus.github.io/ran/src/ranui/preview/) -保存命令会触发编辑器导出当前正在编辑的内容,并通过 `document:saved` 返回一个新的 `File` 对象。默认保存为 `XLSX`,也可以传入其他格式,例如 `DOCX`、`PPTX`、`CSV`。 +--- -```js -sendEditorCommand('document:save', { targetExt: 'XLSX' }); -``` +## 🧩 iframe 嵌入 -默认情况下,保存命令必须等到编辑器返回当前编辑后的文件数据;如果超时会返回 `document:error`,避免误把原始文件上传到服务端。如果业务确实希望"没有修改时也回传原文件",可以显式开启: +将编辑器嵌入到你的应用中,通过 postMessage 控制。推荐架构:父系统负责鉴权和文件上传,iframe 只负责编辑。 -```js -sendEditorCommand('document:save', { targetExt: 'XLSX', returnOriginalOnTimeout: true }); +```html + ``` -父页面拿到文件后自行上传: - ```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, - }); +// 打开文档 +iframe.contentWindow.postMessage( + { id: '1', type: 'document:open-url', payload: { url: 'https://example.com/doc.xlsx' } }, + 'https://your-deployment' +); + +// 监听结果 +window.addEventListener('message', (e) => { + if (e.data?.type === 'document:opened') console.log('可以开始编辑'); + if (e.data?.type === 'document:saved') uploadFile(e.data.payload.file); }); ``` -> **注意:** 不要只用 `size` 判断文件是否变化,`xlsx` 是压缩包格式,轻微编辑后文件大小可能刚好不变。建议在调试时对返回的 `File` 计算 hash,项目内置的 `/embed-demo.html` 已经会在保存日志里打印 `sha256`。 -``` - -### 6. 支持的消息 +→ **[完整 API 文档](docs/embed-api.zh.md)** — 所有消息类型、参数说明及示例,包含鉴权、只读模式、保存流程等。 -| 方向 | 类型 | 说明 | -| --- | --- | --- | -| 父页面 → 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 初始化完成 | -| iframe → 父页面 | `document:opened` | 文档打开完成 | -| iframe → 父页面 | `document:readonly-changed` | 只读状态已切换 | -| iframe → 父页面 | `document:saved` | 保存完成,返回文件 | -| iframe → 父页面 | `document:state` | 返回当前状态 | -| iframe → 父页面 | `document:error` | 操作失败 | +--- -## 🛠️ 技术架构 - -- **OnlyOffice SDK**: 提供强大的文档编辑能力 -- **WebAssembly**: 通过 x2t-wasm 实现文档格式转换 -- **纯前端架构**: 所有功能都在浏览器中运行 - -## 🚀 部署说明 +## 🚀 部署 ### Docker ```bash -# docker run +# 基础部署 docker run -d --name document -p 8080:80 ghcr.io/ranuts/document:latest -# docker compose -services: - document: - image: ghcr.io/ranuts/document:latest - container_name: document - ports: - - 8080:80 -``` - -#### 进阶配置 - -```yaml -name: document -services: - document: - image: ghcr.io/ranuts/document:latest - container_name: document - ports: - - 8080:80 - # 进阶配置 - volumes: - # 添加证书 - - 证书路径:/ssl - environment: - # 设置账号 - # 格式用户名:密码,必须使用 BCrypt 密码哈希函数对密码进行编码。 - # 获取 BCrypt 加密的结果,把加密结果中的$替换成$$转义。 - SERVER_BASIC_AUTH: '用户名:BCrypt 加密密码' - # 使用证书 - SERVER_HTTP2_TLS: true - SERVER_HTTP2_TLS_CERT: 证书路径 - SERVER_HTTP2_TLS_KEY: 私钥路径 +# 启用 HTTPS 和基础认证 +docker run -d --name document -p 443:443 \ + -v /证书路径:/ssl \ + -e SERVER_BASIC_AUTH='用户名:BCrypt加密密码' \ + -e SERVER_HTTP2_TLS=true \ + -e SERVER_HTTP2_TLS_CERT=/ssl/cert.pem \ + -e SERVER_HTTP2_TLS_KEY=/ssl/key.pem \ + ghcr.io/ranuts/document:latest ``` -### 重要提示 - -- **CORS**: 使用 `src` 或 `file` 参数时,远程服务器必须支持 CORS -- **文件大小**: 大文件可能需要较长时间加载 - -## 🔧 本地开发 - -```bash -git clone https://github.com/ranuts/document.git -cd document -pnpm install -pnpm run dev -``` - -## 🔤 字体管理 - -### 项目中的字体文件 - -本项目作为开源项目,为了符合开源许可要求,**不包含**受版权保护的字体文件,如 **Arial**、**Times New Roman**、**微软雅黑**、**宋体** 等 Windows 系统字体。这些字体的名称引用仍保留在配置文件中,以确保与现有文档的兼容性,但实际的字体文件已被移除,以符合开源许可要求。 - -### 添加字体 - -要为项目中已配置的字体(如 Arial、Times New Roman 等)添加字体文件,只需将字体文件放置在 `public/fonts/` 目录下,并重命名为对应的数字索引。该索引对应 `public/sdkjs/common/AllFonts.js` 文件中 `__fonts_files` 数组的索引位置。 - -**示例:添加 Arial 字体** - -如果您想为项目添加 Arial 字体: +`SERVER_BASIC_AUTH` 使用 BCrypt 加密密码,加密结果中的 `$` 需替换为 `$$` 进行转义。 -1. 查看 `AllFonts.js` 文件,找到 Arial 常规字体在 `__fonts_files` 数组中使用的索引是 `223` -2. 将您的 Arial 字体文件放置在 `public/fonts/` 目录下,并重命名为 `223`(无需扩展名) -3. 字体文件应位于 `public/fonts/223` -4. 当应用程序引用索引 `223` 时,会自动从 `public/fonts/223` 加载该字体文件 +--- -其他 Arial 字体变体同样处理: +## 🔤 字体 -- Arial 粗体使用索引 `226` → 将字体文件放置为 `public/fonts/226` -- Arial 斜体使用索引 `224` → 将字体文件放置为 `public/fonts/224` -- Arial 粗斜体使用索引 `225` → 将字体文件放置为 `public/fonts/225` +本项目不包含 Arial、Times New Roman、微软雅黑等受版权保护的字体文件,以符合开源许可要求。字体名称引用保留以确保文档兼容性。 -您可以通过查看 `AllFonts.js` 文件中的 `__fonts_infos` 数组来查找任何字体的索引,每个字体条目都指定了其常规、粗体、斜体和粗斜体变体的索引。 +→ **[字体管理指南](docs/fonts.zh.md)** — 如何按索引添加字体。 -**注意**:请仅使用开源字体或您拥有合法使用许可的字体。在添加任何字体文件之前,请确保符合字体许可条款。 +--- ## 📚 参考资料 -- [onlyoffice-x2t-wasm](https://github.com/cryptpad/onlyoffice-x2t-wasm) - 基于 WebAssembly 的文档转换器 -- [se-office](https://github.com/Qihoo360/se-office) - 安全文档编辑器 -- [web-apps](https://github.com/ONLYOFFICE/web-apps) - OnlyOffice 网页应用 -- [sdkjs](https://github.com/ONLYOFFICE/sdkjs) - OnlyOffice JavaScript SDK -- [onlyoffice-web-local](https://github.com/sweetwisdom/onlyoffice-web-local) - 本地网页版 OnlyOffice 实现 +- [onlyoffice-x2t-wasm](https://github.com/cryptpad/onlyoffice-x2t-wasm) — 基于 WASM 的文档转换器 +- [web-apps](https://github.com/ONLYOFFICE/web-apps) — OnlyOffice 网页应用 +- [sdkjs](https://github.com/ONLYOFFICE/sdkjs) — OnlyOffice JavaScript SDK +- [se-office](https://github.com/Qihoo360/se-office) — 安全文档编辑器 +- [onlyoffice-web-local](https://github.com/sweetwisdom/onlyoffice-web-local) — 本地网页版 OnlyOffice ## 🤝 贡献 -欢迎提交 Issue 和 Pull Request 来帮助改进这个项目! +欢迎提交 Issue 和 Pull Request! ## 📄 许可证 -详情请参阅 [LICENSE](LICENSE) 文件。 +[AGPL-3.0](LICENSE) From d73b414c75233d5a3dd8079322c041fcb909ab62 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 30 May 2026 22:25:40 +0800 Subject: [PATCH 16/19] docs: add GitHub Pages and static hosting deployment options --- readme.md | 23 +++++++++++++++++++++++ readme.zh.md | 23 +++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/readme.md b/readme.md index 0236976a..2097ad7c 100644 --- a/readme.md +++ b/readme.md @@ -121,6 +121,29 @@ window.addEventListener('message', (e) => { ## 🚀 Deployment +This is a pure static app — build once, deploy anywhere. + +```bash +pnpm build # outputs to dist/ +``` + +### GitHub Pages + +Push to `main` and the included workflow (`.github/workflows/pages-build-site.yml`) builds and deploys automatically. Enable GitHub Pages in your repo settings and set the source to **GitHub Actions**. + +### Static hosting (Nginx, Vercel, Netlify, Cloudflare Pages…) + +Upload the contents of `dist/` to any static host. No server-side runtime needed. + +For Nginx, serve `index.html` as the fallback for all routes: + +```nginx +location / { + root /var/www/document; + try_files $uri $uri/ /index.html; +} +``` + ### Docker ```bash diff --git a/readme.zh.md b/readme.zh.md index 385770d3..4fa8b879 100644 --- a/readme.zh.md +++ b/readme.zh.md @@ -121,6 +121,29 @@ window.addEventListener('message', (e) => { ## 🚀 部署 +这是纯静态应用,构建一次即可部署到任意平台。 + +```bash +pnpm build # 输出到 dist/ +``` + +### GitHub Pages + +推送到 `main` 分支后,内置工作流(`.github/workflows/pages-build-site.yml`)会自动构建并部署。在仓库 Settings → Pages 中将 Source 设置为 **GitHub Actions** 即可。 + +### 静态托管(Nginx、Vercel、Netlify、Cloudflare Pages 等) + +将 `dist/` 目录上传到任意静态托管服务,无需服务端运行时。 + +Nginx 参考配置(将所有路由回退到 `index.html`): + +```nginx +location / { + root /var/www/document; + try_files $uri $uri/ /index.html; +} +``` + ### Docker ```bash From 8026ea5160b76726951e2568fda3292fb98fc889 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 30 May 2026 22:27:51 +0800 Subject: [PATCH 17/19] docs: improve formatting and readability in embed API and font management documentation --- docs/embed-api.md | 47 ++++++++++++++++++++++++-------------------- docs/embed-api.zh.md | 21 ++++++++++++-------- docs/fonts.md | 4 ++-- readme.md | 14 ++++++------- readme.zh.md | 14 ++++++------- 5 files changed, 55 insertions(+), 45 deletions(-) diff --git a/docs/embed-api.md b/docs/embed-api.md index 77806aac..4d11d264 100644 --- a/docs/embed-api.md +++ b/docs/embed-api.md @@ -19,10 +19,7 @@ A working demo is available at `/embed-demo.html` (includes sha256 logging for d To restrict messages to a specific origin, add `embedOrigin`: ```html - + ``` --- @@ -47,10 +44,18 @@ window.addEventListener('message', (event) => { 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; + 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; } }); ``` @@ -160,17 +165,17 @@ sendEditorCommand('document:get-state'); ## Message reference -| Direction | Type | Description | -| --------------- | --------------------------- | -------------------------------------------- | -| parent → iframe | `document:open-url` | Open document from URL | -| parent → iframe | `document:open-file` | Open document from `File` / `Blob` | +| 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 | +| 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 | diff --git a/docs/embed-api.zh.md b/docs/embed-api.zh.md index c38d3f38..e0f9d361 100644 --- a/docs/embed-api.zh.md +++ b/docs/embed-api.zh.md @@ -19,10 +19,7 @@ 如需限制只接受指定来源的消息,增加 `embedOrigin`: ```html - + ``` --- @@ -47,10 +44,18 @@ window.addEventListener('message', (event) => { 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; + 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; } }); ``` diff --git a/docs/fonts.md b/docs/fonts.md index 5affc394..c84ea938 100644 --- a/docs/fonts.md +++ b/docs/fonts.md @@ -16,8 +16,8 @@ Font files go in `public/fonts/` and must be named by their numeric index in the Other Arial variants: -| Variant | Index | Path | -| ----------- | ----- | ----------------- | +| Variant | Index | Path | +| ----------- | ----- | ------------------ | | Regular | 223 | `public/fonts/223` | | Italic | 224 | `public/fonts/224` | | Bold | 226 | `public/fonts/226` | diff --git a/readme.md b/readme.md index 2097ad7c..a31d6bb2 100644 --- a/readme.md +++ b/readme.md @@ -67,11 +67,11 @@ pnpm run dev ### URL parameters -| Parameter | Description | Priority | -| --------- | ---------------------------------------- | -------- | -| `src` | Open document from URL (recommended) | Low | -| `file` | Open document from URL (legacy) | High | -| `locale` | Set interface language (`en`, `zh`) | — | +| Parameter | Description | Priority | +| --------- | ------------------------------------ | -------- | +| `src` | Open document from URL (recommended) | Low | +| `file` | Open document from URL (legacy) | High | +| `locale` | Set interface language (`en`, `zh`) | — | When both `src` and `file` are present, `file` takes priority. @@ -105,13 +105,13 @@ Embed the editor in your application and control it via postMessage. The recomme // Open a document iframe.contentWindow.postMessage( { id: '1', type: 'document:open-url', payload: { url: 'https://example.com/doc.xlsx' } }, - 'https://your-deployment' + 'https://your-deployment', ); // Listen for the result window.addEventListener('message', (e) => { if (e.data?.type === 'document:opened') console.log('Ready to edit'); - if (e.data?.type === 'document:saved') uploadFile(e.data.payload.file); + if (e.data?.type === 'document:saved') uploadFile(e.data.payload.file); }); ``` diff --git a/readme.zh.md b/readme.zh.md index 4fa8b879..913c0fc5 100644 --- a/readme.zh.md +++ b/readme.zh.md @@ -67,11 +67,11 @@ pnpm run dev ### URL 参数 -| 参数 | 说明 | 优先级 | -| --------- | --------------------------------- | ------ | -| `src` | 从 URL 打开文档(推荐) | 低 | -| `file` | 从 URL 打开文档(向后兼容) | 高 | -| `locale` | 设置界面语言(`en`、`zh`) | — | +| 参数 | 说明 | 优先级 | +| -------- | --------------------------- | ------ | +| `src` | 从 URL 打开文档(推荐) | 低 | +| `file` | 从 URL 打开文档(向后兼容) | 高 | +| `locale` | 设置界面语言(`en`、`zh`) | — | 同时提供 `src` 和 `file` 时,`file` 优先。 @@ -105,13 +105,13 @@ pnpm run dev // 打开文档 iframe.contentWindow.postMessage( { id: '1', type: 'document:open-url', payload: { url: 'https://example.com/doc.xlsx' } }, - 'https://your-deployment' + 'https://your-deployment', ); // 监听结果 window.addEventListener('message', (e) => { if (e.data?.type === 'document:opened') console.log('可以开始编辑'); - if (e.data?.type === 'document:saved') uploadFile(e.data.payload.file); + if (e.data?.type === 'document:saved') uploadFile(e.data.payload.file); }); ``` From e09dd3eb77f6a24451bd309de934a059c02c173f Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 30 May 2026 22:29:38 +0800 Subject: [PATCH 18/19] test: add type annotations to call arguments in expectMessagePosted functions --- test/unit/embed-api.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/embed-api.test.ts b/test/unit/embed-api.test.ts index 6c7b2738..b75fff94 100644 --- a/test/unit/embed-api.test.ts +++ b/test/unit/embed-api.test.ts @@ -33,7 +33,7 @@ function expectMessagePosted( id: string, payloadMatch?: Record, ) { - const found = spy.mock.calls.find((call) => { + const found = spy.mock.calls.find((call: unknown[]) => { const msg = call[0] as { type?: string; id?: string }; return msg?.type === type && msg?.id === id; }); @@ -45,7 +45,7 @@ function expectMessagePosted( } function expectMessageNotPosted(spy: ReturnType, id: string) { - const found = spy.mock.calls.find((call) => { + const found = spy.mock.calls.find((call: unknown[]) => { const msg = call[0] as { id?: string }; return msg?.id === id; }); From 1cb181a2020b20e00995561e1514ea68d7805c11 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 30 May 2026 22:33:14 +0800 Subject: [PATCH 19/19] fix: pin static web server version to 2.42.0 in Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 94c5ea14..0acb58fb 100755 --- a/Dockerfile +++ b/Dockerfile @@ -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