Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/字节形状小工具
Expand Down
50 changes: 50 additions & 0 deletions docs/explorations/2026-11-02-readonly-toolbar-state.md
Original file line number Diff line number Diff line change
@@ -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 升级改了这些会先红。
7 changes: 2 additions & 5 deletions lib/onlyoffice-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
71 changes: 56 additions & 15 deletions lib/onlyoffice/readonly.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) {
Expand All @@ -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 {
Expand Down
11 changes: 0 additions & 11 deletions lib/onlyoffice/sdk-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> }): 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);
}
}
70 changes: 70 additions & 0 deletions test/e2e/readonly-chrome.spec.ts
Original file line number Diff line number Diff line change
@@ -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<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' +
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\nxref\n0 4\n0000000000 65535 f \n' +
'0000000009 00000 n \n0000000052 00000 n \n0000000101 00000 n \ntrailer<</Size 4/Root 1 0 R>>\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<Chrome> =>
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<HTMLElement>(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 });
});
}
});
20 changes: 0 additions & 20 deletions test/unit/onlyoffice-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
12 changes: 12 additions & 0 deletions test/unit/vendor-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <themesPath>/themes.js (our stub relies on it)', () => {
const src = read('public/sdkjs/slide/sdk-all-min.js');
expect(src).toContain('SetThemesPath');
Expand Down
Loading