diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e619c8e..9f922471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ notes. Entries describe what users experience, not internal refactors. ### Fixed +- Read-only documents now show it: the editing toolbar and side panels are dimmed and locked, while search, zoom and navigation stay available. Switching read-only off restores them. - Exporting a PDF or another format keeps unsaved changes in the editable original visible until the original is saved. - Failed file opens restore usable file controls, including keyboard access. - Document history search keeps focus and ignores outdated results while typing. diff --git a/CLAUDE.md b/CLAUDE.md index a00ee35d..449dd5b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,8 @@ lib/ # 应用层(纯 TypeScript,只在本站点用) save-stream.ts # 保存通道:请求生命周期、asc_DownloadAs 触发、file-stream 回收、CSV 往返 viewport.ts # 紧凑视口判定与布局同步(#145) sdk-api.ts # 同源 iframe 里的 Asc.editor 访问、restriction 常量 - readonly.ts # 运行时只读(挂载永远可编辑,加载后加 restriction) + readonly.ts # 运行时只读(挂载永远可编辑,加载后加 restriction,并经 vendor 的 + # editing:disable 锁界面;解锁时 viewMode 必须传 false,见 #298) save-point.ts # embed 保存后移动 SDK 的 SavedIndex(导出期间历史没动才移),让之后的编辑重新触发 onDocumentStateChange ui-theme.ts # 默认经典主题与站点主题跟随 file-helpers.ts # 文件名/MIME/字节形状小工具 diff --git a/docs/explorations/2026-11-02-readonly-toolbar-state.md b/docs/explorations/2026-11-02-readonly-toolbar-state.md new file mode 100644 index 00000000..07fe7e49 --- /dev/null +++ b/docs/explorations/2026-11-02-readonly-toolbar-state.md @@ -0,0 +1,50 @@ +# 只读模式下工具栏看起来仍可编辑(#298) + +## 现象 + +以只读打开文档(`?readonly=1`,或 embed 的 `readonly: true` / `document:set-readonly`)后, +编辑确实被拦住了:加粗、输入都没有效果。但工具栏、右侧面板、右键菜单的外观和可编辑时完全 +一样,用户只会觉得"按钮坏了"。2026-11-02 在线上 `/embed-demo` 用示例 xlsx 复现。 + +## 根因 + +1. 只读走的是 `asc_setRestriction(128)`(`lib/onlyoffice/readonly.ts`)。它只在 SDK 层拦修改。 + SDK 改完会发 `asc_onChangeRestrictions`,但 vendor 里只有插件框架订阅它, + 四个 web-apps(word / cell / slide / pdf)的界面一个都没听,所以 UI 不知道。 +2. `readonly.ts` 里那条 `processRightsChange` "回退"从来没发出去:`editorSendCommand` 调的是 + DocEditor 的 `serviceCommand`,它把命令包进 `internalCommand` + (`public/web-apps/apps/api/documents/api.js` 的 `_serviceCommand`),编辑器不认。 + 就算发出去也不能用:vendor 处理它的方式是 `asc_coAuthoringDisconnect()`,断开之后回不来, + 正是这套"运行时可逆只读"要避开的那扇单向门。 + +## 修法 + +不改挂载方式(仍是 edit 挂载 + restriction,运行时双向切换)。在设置/解除 restriction 的同时, +向编辑器 frame 触发 vendor 自己的 `Common.NotificationCenter.trigger('editing:disable', 开关, options, 'readonly')`。 +这是 vendor 用来处理签名、表单、断线的同一套机制,**按 type 入栈**:解锁时只弹出自己这一层, +弹完会把栈顶剩下的那层重新应用,不会误开别人锁住的东西。 + +- options 一个对象四个编辑器通用(各自只读自己认识的键):锁工具栏、右侧面板、右键菜单、 + 修订、评论/左侧栏进入预览、单元格编辑栏预览、顶栏编辑模式切换(`header.docmode`)。 + **不锁**:左侧搜索与导航、顶栏搜索、状态栏(缩放和工作表页签在里面)、快捷键(复制、查找)。 +- **`viewMode` 必须跟着开关走**:`DisableToolbar(disable, viewMode)` 会把它存成 + `this.editMode = !viewMode`,解锁时若仍传 `true`,`disable || !editMode` 恒为真,遮罩永远不掉。 + vendor 自己的签名流程也是传 `viewMode: disable`。 +- 每个 frame 记一个 `__readonlyChrome`,没锁过就不发解锁,避免在正常状态上跑一遍 vendor 的重新启用路径。 +- `onDocumentReady` 原来只补 restriction,现在改成调 `setReadonlyMode(true)`,界面和 restriction 一起上。 +- 删掉 `processRightsChange` 回退和 `editorSendCommand`(`rg` 确认零引用),以及钉住这条死路径的两条单测。 + +## 取舍 + +遮罩是整条工具栏一起盖。PDF 编辑器 Home 页签上本来就有翻页、缩放、抓手工具,它们也被盖住了; +底部状态栏的缩放、适应页面/宽度、翻页与左侧搜索/导航/缩略图仍然可用。 + +## 验证 + +- 新增 `test/e2e/readonly-chrome.spec.ts`:docx / xlsx / pptx / pdf 各一条,只读打开 → + `#toolbar .toolbar-mask` 存在且左侧搜索、放大按钮可用 → 运行时解锁 → 遮罩消失 → 再锁 → 遮罩回来。 +- **反向验证**:注释掉 `syncReadonlyChrome(readonly)` 后 4 条全红(`masked: false`),恢复后全绿。 +- 已有的只读相关用例(format-parity、embed-regression、pdf-roundtrip)与新用例合计 13 条全过 + (本地 vite dev)。 +- `vendor-contract.test.ts` 新增:四个 app.js 仍订阅 `editing:disable`、仍有 `stackDisableActions`、 + `DisableToolbar` 仍把 viewMode 存成 editMode。vendor 升级改了这些会先红。 diff --git a/lib/onlyoffice-editor.ts b/lib/onlyoffice-editor.ts index 3c4098f3..223b9862 100644 --- a/lib/onlyoffice-editor.ts +++ b/lib/onlyoffice-editor.ts @@ -23,8 +23,7 @@ import { setOpenRunner, } from './onlyoffice/open-failure'; import { markDocumentDirty, markDocumentSaved, resetUnsavedChanges } from './unsaved-guard'; -import { getSdkEditorApi, ASC_RESTRICTION_VIEW } from './onlyoffice/sdk-api'; -import { getReadonlyMode, setReadonlyState } from './onlyoffice/readonly'; +import { getReadonlyMode, setReadonlyMode, setReadonlyState } from './onlyoffice/readonly'; import { resolveUiTheme } from './onlyoffice/ui-theme'; import { describeMemoryVerdict, @@ -264,9 +263,7 @@ function createPersonalEditorInstance(config: { // here instead (asc_setRestriction only works once the document is // loaded). Read the live flag rather than the captured config value: // setReadonlyMode may have been called while the document loaded. - if (getReadonlyMode()) { - getSdkEditorApi()?.asc_setRestriction?.(ASC_RESTRICTION_VIEW); - } + if (getReadonlyMode()) setReadonlyMode(true); console.log(`${t('documentLoaded')}${fileName}`); }, // The editor's modified flag. This is the only signal the app has that diff --git a/lib/onlyoffice/readonly.ts b/lib/onlyoffice/readonly.ts index 5022e829..313d38df 100644 --- a/lib/onlyoffice/readonly.ts +++ b/lib/onlyoffice/readonly.ts @@ -1,4 +1,4 @@ -import { ASC_RESTRICTION_NONE, ASC_RESTRICTION_VIEW, editorSendCommand, getSdkEditorApi } from './sdk-api'; +import { ASC_RESTRICTION_NONE, ASC_RESTRICTION_VIEW, getSdkEditorApi } from './sdk-api'; /** * Readonly is a runtime restriction, never a mount-time permission: the editor @@ -7,14 +7,63 @@ import { ASC_RESTRICTION_NONE, ASC_RESTRICTION_VIEW, editorSendCommand, getSdkEd */ let isReadonlyMode = false; +/** + * The vendor's own stacked "disable editing" request, entered under its own + * type so it unwinds without touching the ones the vendor pushes itself + * (disconnect, signature, forms): popping a type re-applies whatever is left on + * the stack. Every app reads only the keys it knows, so one object serves all + * four. Left alone on purpose: the left menu's search and navigation, the + * header search, the status bar (zoom and sheet tabs live there) and keyboard + * shortcuts (copy, find). The mask covers the whole toolbar, so the pdf + * editor's toolbar zoom and paging go with it; the status bar keeps both. + */ +const READONLY_CHROME = { + toolbar: true, + rightMenu: { clear: false, disable: true }, + documentHolder: { clear: false, disable: true }, + review: true, + leftMenu: { disable: false, previewMode: true }, + comments: { disable: false, previewMode: true }, + celleditor: { previewMode: true }, + header: { search: false, docmode: true }, +}; +const READONLY_CHROME_TYPE = 'readonly'; + +type ChromeFrame = Window & { + Common?: { NotificationCenter?: { trigger: (...args: unknown[]) => void } }; + __readonlyChrome?: boolean; +}; + +/** + * asc_setRestriction blocks edits in the SDK only; no vendor app listens for + * asc_onChangeRestrictions, so the toolbar keeps looking live (#298). Tell the + * UI too, through the same event the vendor uses to lock it. + */ +function syncReadonlyChrome(readonly: boolean): void { + for (let i = 0; i < window.frames.length; i++) { + try { + const win = window.frames[i] as ChromeFrame; + const center = win.Common?.NotificationCenter; + // An unlock that was never locked would still run the vendor's + // re-enable path over whatever this frame's state is. + if (!center || Boolean(win.__readonlyChrome) === readonly) continue; + // viewMode must follow the flag: the toolbar stores it as its own + // edit mode, and an unlock that keeps it true leaves the mask on. + center.trigger('editing:disable', readonly, { ...READONLY_CHROME, viewMode: readonly }, READONLY_CHROME_TYPE); + win.__readonlyChrome = readonly; + } catch { + // cross-origin frame -- not the editor, skip + } + } +} + export function setReadonlyMode(readonly: boolean): void { isReadonlyMode = readonly; - // Primary path: the SDK restriction API switches the live editor between - // view and edit in place, no rebuild. The editor must be mounted with full - // edit permissions for this to be reversible (see - // createPersonalEditorInstance: restriction is applied after load, never - // via permissions.edit=false at mount). + // The SDK restriction API switches the live editor between view and edit in + // place, no rebuild. The editor must be mounted with full edit permissions + // for this to be reversible (see createPersonalEditorInstance: restriction + // is applied after load, never via permissions.edit=false at mount). const api = getSdkEditorApi(); if (api) { if (readonly) { @@ -24,15 +73,7 @@ export function setReadonlyMode(readonly: boolean): void { api.asc_setRestriction?.(ASC_RESTRICTION_NONE); } } - - // Fallback/legacy path; harmless no-op on builds that ignore the command. - editorSendCommand({ - command: 'processRightsChange', - data: { - enabled: !readonly, - message: readonly ? 'Readonly mode' : '', - } as any, - }); + syncReadonlyChrome(readonly); } export function getReadonlyMode(): boolean { diff --git a/lib/onlyoffice/sdk-api.ts b/lib/onlyoffice/sdk-api.ts index b81ad567..a8654312 100644 --- a/lib/onlyoffice/sdk-api.ts +++ b/lib/onlyoffice/sdk-api.ts @@ -64,14 +64,3 @@ export function getSdkEditorApi(): SdkEditorApi | null { } return null; } - -// v9.3.0 renamed sendCommand -> serviceCommand; keep the fallback for safety. -export function editorSendCommand(params: { command: string; data: Record }): void { - const editor = window.editor as any; - if (!editor) return; - if (typeof editor.serviceCommand === 'function') { - editor.serviceCommand(params); - } else if (typeof editor.sendCommand === 'function') { - editor.sendCommand(params); - } -} diff --git a/test/e2e/readonly-chrome.spec.ts b/test/e2e/readonly-chrome.spec.ts new file mode 100644 index 00000000..266d338f --- /dev/null +++ b/test/e2e/readonly-chrome.spec.ts @@ -0,0 +1,70 @@ +import { buildDocx, buildPptx, buildXlsx, toBase64 } from './lib/ooxml'; +import { expect, test } from './lib/l0'; + +/** + * #298: readonly locked the document but left the toolbar looking live, so + * every button seemed to work and did nothing. The editor must show the lock -- + * the toolbar masked -- while the viewing tools (search, zoom) stay usable, and + * the mask must come and go with runtime toggles, which are the reason readonly + * is a restriction rather than a view-mode mount. + */ +const PDF = + '%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n' + + '3 0 obj<>endobj\nxref\n0 4\n0000000000 65535 f \n' + + '0000000009 00000 n \n0000000052 00000 n \n0000000101 00000 n \ntrailer<>\nstartxref\n160\n%%EOF'; + +const DOCS = [ + { name: 'chrome.docx', zoomIn: '#btn-zoom-up', b64: () => toBase64(buildDocx('readonly docx')) }, + { name: 'chrome.xlsx', zoomIn: '#status-btn-zoomup', b64: () => toBase64(buildXlsx({ rows: [['readonly', 1]] })) }, + { name: 'chrome.pptx', zoomIn: '#btn-zoom-up', b64: () => toBase64(buildPptx('readonly pptx')) }, + { name: 'chrome.pdf', zoomIn: '#btn-zoom-up', b64: () => toBase64(new TextEncoder().encode(PDF)) }, +] as const; + +type Chrome = { masked: boolean; search: boolean; zoomIn: boolean } | null; + +test.describe('readonly shows in the editor chrome (real editor)', () => { + test.describe.configure({ timeout: 120_000 }); + + test.beforeEach(async ({ page }) => { + await page.goto('/embed-demo.html'); + await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 }); + }); + + for (const doc of DOCS) { + test(`${doc.name}: toolbar is masked while readonly, viewing tools stay usable`, async ({ page }) => { + const chrome = (): Promise => + page.evaluate((zoomIn) => { + const win = window.__ooFrames.find((w) => !!w.document.querySelector('#toolbar .toolbar')); + if (!win) return null; + const usable = (selector: string): boolean => { + const el = win.document.querySelector(selector); + return ( + !!el && el.offsetParent !== null && !el.classList.contains('disabled') && !el.hasAttribute('disabled') + ); + }; + return { + masked: !!win.document.querySelector('#toolbar .toolbar-mask'), + search: usable('#left-btn-searchbar'), + zoomIn: usable(zoomIn), + }; + }, doc.zoomIn); + + await page.evaluate( + async ({ name, b64 }) => { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + await post('document:open-buffer', { fileName: name, buffer: bytes.buffer, readonly: true }); + }, + { name: doc.name, b64: doc.b64() }, + ); + await expect.poll(chrome, { timeout: 30_000 }).toEqual({ masked: true, search: true, zoomIn: true }); + + await page.evaluate(() => post('document:set-readonly', { readonly: false })); + await expect.poll(chrome, { timeout: 10_000 }).toEqual({ masked: false, search: true, zoomIn: true }); + + await page.evaluate(() => post('document:set-readonly', { readonly: true })); + await expect.poll(chrome, { timeout: 10_000 }).toEqual({ masked: true, search: true, zoomIn: true }); + }); + } +}); diff --git a/test/unit/onlyoffice-editor.test.ts b/test/unit/onlyoffice-editor.test.ts index 2dcdc7fe..e3027452 100644 --- a/test/unit/onlyoffice-editor.test.ts +++ b/test/unit/onlyoffice-editor.test.ts @@ -73,26 +73,6 @@ describe('onlyoffice-editor', () => { 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('prefers serviceCommand over sendCommand when the editor exposes both (v9 renamed it)', () => { - const serviceCommand = vi.fn(); - const editor = makeEditor({ serviceCommand }); - (window as any).editor = editor; - - setReadonlyMode(true); - - expect(serviceCommand).toHaveBeenCalledWith(expect.objectContaining({ command: 'processRightsChange' })); - expect(editor.sendCommand).not.toHaveBeenCalled(); - }); - it('does not throw when no editor is present', () => { expect(() => setReadonlyMode(true)).not.toThrow(); }); diff --git a/test/unit/vendor-contract.test.ts b/test/unit/vendor-contract.test.ts index c62c30c7..fb41c40d 100644 --- a/test/unit/vendor-contract.test.ts +++ b/test/unit/vendor-contract.test.ts @@ -50,6 +50,18 @@ describe('vendor contract sentinel', () => { } }); + it.each(['documenteditor', 'spreadsheeteditor', 'presentationeditor', 'pdfeditor'])( + 'web-apps %s still locks its chrome through the stacked editing:disable event (readonly UI, #298)', + (app) => { + const src = read(`public/web-apps/apps/${app}/main/app.js`); + expect(src).toContain('Common.NotificationCenter.on("editing:disable",_.bind(this.onEditingDisable,this)'); + expect(src).toContain('stackDisableActions'); + // DisableToolbar(disable, viewMode) stores viewMode as editMode; the + // unlock in lib/onlyoffice/readonly.ts depends on passing it back false. + expect(src).toMatch(/DisableToolbar:function\(t,e[,)][^}]*this\.editMode=!e/); + }, + ); + it('slide SDK still loads the theme catalog from /themes.js (our stub relies on it)', () => { const src = read('public/sdkjs/slide/sdk-all-min.js'); expect(src).toContain('SetThemesPath');