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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +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.
- Read-only documents now look read-only: the toolbar keeps only the File and View tabs, editing buttons such as paste and macros are hidden, and the side panels are locked, while search, zoom and navigation stay available. Switching read-only off restores the full toolbar.
- 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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ lib/ # 应用层(纯 TypeScript,只在本站点用)
viewport.ts # 紧凑视口判定与布局同步(#145)
sdk-api.ts # 同源 iframe 里的 Asc.editor 访问、restriction 常量
readonly.ts # 运行时只读(挂载永远可编辑,加载后加 restriction,并经 vendor 的
# editing:disable 锁界面;解锁时 viewMode 必须传 false,见 #298)
# editing:disable 锁面板、只留 File/View 页签并藏起编辑按钮,见 #298)
save-point.ts # embed 保存后移动 SDK 的 SavedIndex(导出期间历史没动才移),让之后的编辑重新触发 onDocumentStateChange
ui-theme.ts # 默认经典主题与站点主题跟随
file-helpers.ts # 文件名/MIME/字节形状小工具
Expand Down
44 changes: 44 additions & 0 deletions docs/explorations/2026-11-02-readonly-hide-edit-tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 只读时隐藏编辑页签(#298 第二步)

接 [2026-11-02-readonly-toolbar-state.md](2026-11-02-readonly-toolbar-state.md)。第一步用 vendor 的
`editing:disable` 给整条工具栏加遮罩,按钮变灰了,但还在。用户反馈里写的是"隐藏或明显禁用",
而遮罩还有一个副作用:它盖住每一个页签,连 View 页签里的缩放、导航也一起灰掉了。

## 做法

`lib/onlyoffice/readonly.ts` 往编辑器 frame 注入一条 CSS,只读时在 `<html>` 上挂 `readonly-chrome` 类:

- **页签只留 File 和 View**(`READONLY_TABS`)。四个编辑器实测页签:
- word:file / home / ins / draw / layout / links / review / view,外加 plugins、headerfooter;
- cell:file / home / ins / draw / layout / formula / data / review / view,外加 pivot、tabledesign;
- slide:file / home / ins / draw / design / transit / animate / review / view,外加 slideMaster;
- pdf:file / home / comment / review / view。

File 和 View 在四个编辑器里都有,所以用"保留名单"而不是"隐藏名单"。规则写成 CSS
(`.ribtab:not(:has(> a[data-tab="file"])):not(...)`)而不是调 vendor 的 `toolbar.setVisible`:
vendor 会在选区移动时自己把上下文页签(表格设计、页眉页脚)显示出来,CSS 不受这个影响。

- **隐藏页签之外的编辑控件**(`READONLY_HIDDEN_SLOTS`,按 vendor 的 `#slot-*` 容器 id):
- 各页签共用的剪贴板组里的粘贴、剪切、格式刷(复制保留);
- View 页签里的宏三件(宏、录制、暂停);
- 表格的新建/关闭工作表视图、冻结窗格(会改文件);
- 演示文稿的幻灯片母版;
- PDF 的 Edit PDF;
- word/pdf 顶栏的编辑模式切换。
- **切到 View 页签**:上锁时如果当前页签不在保留名单里,记下它并点 View;解锁时点回去。
- `editing:disable` 不再传 `toolbar`。遮罩会盖掉 View,而该藏的已经藏了。
右侧面板、右键菜单、修订、评论预览、单元格编辑栏、顶栏撤销/重做照旧由它锁。
`viewMode` 仍跟着开关走,因为顶栏的撤销/重做靠它锁。

只读状态下仍可见、可用的是:File、View 页签里的查看工具(缩放、适应页面/宽度、导航、主题、
标尺、网格线、备注、PDF 的抓手/选择)、复制、全选、左侧搜索/导航/缩略图、状态栏。
右侧面板那排图标是灰的,没有藏。

## 验证

- `test/e2e/readonly-chrome.spec.ts` 改为断言:只读时可见页签恰好是 `['file','view']`、当前页签是 view、
粘贴不可见、搜索和放大可用;运行时解锁后 home 回来、当前页签回到 home、粘贴可见;再锁回到只读状态。四种格式各一条。
- **反向验证**:注释掉 `syncReadonlyTabs` 后 4 条全红,多出 home / ins / review 等页签;
只清空 `READONLY_HIDDEN_SLOTS` 后 4 条全红(`paste: true`);恢复后全绿。
- 只读相关的已有用例(format-parity、embed-regression、pdf-roundtrip)加上新用例共 13 条全过(本地 vite dev)。
- `vendor-contract.test.ts` 去掉了对 `DisableToolbar` 的钉子(不再用工具栏遮罩),保留 `editing:disable` 与 `stackDisableActions`。
2 changes: 2 additions & 0 deletions docs/explorations/2026-11-02-readonly-toolbar-state.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# 只读模式下工具栏看起来仍可编辑(#298)

> 后续:工具栏遮罩已改为隐藏编辑页签与按钮,见 [2026-11-02-readonly-hide-edit-tabs.md](2026-11-02-readonly-hide-edit-tabs.md)。

## 现象

以只读打开文档(`?readonly=1`,或 embed 的 `readonly: true` / `document:set-readonly`)后,
Expand Down
79 changes: 73 additions & 6 deletions lib/onlyoffice/readonly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,10 @@ let isReadonlyMode = false;
* 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.
* shortcuts (copy, find). The toolbar is not masked here: the mask covers every
* tab, View included, so the tabs are hidden instead (READONLY_TABS).
*/
const READONLY_CHROME = {
toolbar: true,
rightMenu: { clear: false, disable: true },
documentHolder: { clear: false, disable: true },
review: true,
Expand All @@ -29,15 +28,82 @@ const READONLY_CHROME = {
};
const READONLY_CHROME_TYPE = 'readonly';

/**
* The toolbar tabs a readonly document keeps, the same in all four editors:
* File (download, print, info) and View (zoom, navigation, theme). A rule
* rather than vendor calls, because the vendor shows contextual tabs (table
* design, header and footer) on its own when the selection moves.
*/
const READONLY_TABS = ['file', 'view'];
/**
* Editing controls that live outside the hidden tabs: the clipboard group
* every tab shares (copy stays), View's macros, sheet views, freeze panes and
* slide master, the pdf editor's Edit PDF switch and the header's edit-mode
* switch.
*/
const READONLY_HIDDEN_SLOTS = [
'btn-paste',
'btn-cut',
'btn-copystyle',
'btn-macros',
'btn-macro-start',
'btn-macro-pause',
'createview',
'closeview',
'btn-freeze',
'btn-slide-master',
'btn-tb-edit-mode',
'btn-edit-mode',
];
const READONLY_CLASS = 'readonly-chrome';
const keptTab = READONLY_TABS.map((tab) => `:not(:has(> a[data-tab="${tab}"]))`).join('');
const hiddenSlots = READONLY_HIDDEN_SLOTS.map((slot) => `#slot-${slot}`).join(', ');
const READONLY_CSS = [
`html.${READONLY_CLASS} #toolbar .tabs .ribtab${keptTab} { display: none !important; }`,
`html.${READONLY_CLASS} :is(${hiddenSlots}) { display: none !important; }`,
].join('\n');

type ChromeFrame = Window & {
Common?: { NotificationCenter?: { trigger: (...args: unknown[]) => void } };
__readonlyChrome?: boolean;
/** The tab that was active when the document was locked, restored on unlock. */
__readonlyPreviousTab?: string;
};

function activeTab(doc: Document): string | undefined {
return doc.querySelector<HTMLElement>('#toolbar .tabs .ribtab.active > a[data-tab]')?.dataset.tab;
}

function openTab(doc: Document, tab: string): void {
doc.querySelector<HTMLElement>(`#toolbar .tabs .ribtab > a[data-tab="${tab}"]`)?.click();
}

function syncReadonlyTabs(win: ChromeFrame, readonly: boolean): void {
const doc = win.document;
if (!doc.getElementById('oo-readonly-css')) {
const style = doc.createElement('style');
style.id = 'oo-readonly-css';
style.textContent = READONLY_CSS;
(doc.head || doc.documentElement).appendChild(style);
}
doc.documentElement.classList.toggle(READONLY_CLASS, readonly);
if (readonly) {
const current = activeTab(doc);
if (current && !READONLY_TABS.includes(current)) {
win.__readonlyPreviousTab = current;
openTab(doc, 'view');
}
} else if (win.__readonlyPreviousTab) {
openTab(doc, win.__readonlyPreviousTab);
win.__readonlyPreviousTab = undefined;
}
}

/**
* 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.
* UI too, through the same event the vendor uses to lock it, and hide the tabs
* that only edit.
*/
function syncReadonlyChrome(readonly: boolean): void {
for (let i = 0; i < window.frames.length; i++) {
Expand All @@ -47,9 +113,10 @@ function syncReadonlyChrome(readonly: boolean): void {
// 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.
// viewMode must follow the flag: the header keeps undo/redo locked
// for as long as it is true.
center.trigger('editing:disable', readonly, { ...READONLY_CHROME, viewMode: readonly }, READONLY_CHROME_TYPE);
syncReadonlyTabs(win, readonly);
win.__readonlyChrome = readonly;
} catch {
// cross-origin frame -- not the editor, skip
Expand Down
39 changes: 25 additions & 14 deletions test/e2e/readonly-chrome.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ 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.
* every button seemed to work and did nothing. A readonly document keeps only
* the File and View tabs, opens on View, drops the editing controls outside
* the tabs (paste), and keeps the viewing tools (search, zoom) usable. The
* whole state 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' +
Expand All @@ -20,7 +21,9 @@ const DOCS = [
{ name: 'chrome.pdf', zoomIn: '#btn-zoom-up', b64: () => toBase64(new TextEncoder().encode(PDF)) },
] as const;

type Chrome = { masked: boolean; search: boolean; zoomIn: boolean } | null;
type Chrome = { tabs: string[]; active: string; paste: boolean; search: boolean; zoomIn: boolean } | null;

const READONLY = { tabs: ['file', 'view'], active: 'view', paste: false, search: true, zoomIn: true };

test.describe('readonly shows in the editor chrome (real editor)', () => {
test.describe.configure({ timeout: 120_000 });
Expand All @@ -31,19 +34,21 @@ test.describe('readonly shows in the editor chrome (real editor)', () => {
});

for (const doc of DOCS) {
test(`${doc.name}: toolbar is masked while readonly, viewing tools stay usable`, async ({ page }) => {
test(`${doc.name}: only File and View remain while readonly, and editing comes back`, async ({ page }) => {
const chrome = (): Promise<Chrome> =>
page.evaluate((zoomIn) => {
const win = window.__ooFrames.find((w) => !!w.document.querySelector('#toolbar .toolbar'));
const win = window.__ooFrames.find((w) => !!w.document.querySelector('#toolbar .tabs'));
if (!win) return null;
const shown = (el: HTMLElement | null): boolean => !!el && el.offsetParent !== 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 shown(el) && !el!.classList.contains('disabled') && !el!.hasAttribute('disabled');
};
const tabs = [...win.document.querySelectorAll<HTMLElement>('#toolbar .tabs .ribtab > a[data-tab]')];
return {
masked: !!win.document.querySelector('#toolbar .toolbar-mask'),
tabs: tabs.filter((a) => shown(a.parentElement)).map((a) => a.dataset.tab!),
active: win.document.querySelector<HTMLElement>('#toolbar .tabs .ribtab.active > a')?.dataset.tab ?? '',
paste: shown(win.document.querySelector<HTMLElement>('#slot-btn-paste')),
search: usable('#left-btn-searchbar'),
zoomIn: usable(zoomIn),
};
Expand All @@ -58,13 +63,19 @@ test.describe('readonly shows in the editor chrome (real editor)', () => {
},
{ name: doc.name, b64: doc.b64() },
);
await expect.poll(chrome, { timeout: 30_000 }).toEqual({ masked: true, search: true, zoomIn: true });
await expect.poll(chrome, { timeout: 30_000 }).toEqual(READONLY);

await page.evaluate(() => post('document:set-readonly', { readonly: false }));
await expect.poll(chrome, { timeout: 10_000 }).toEqual({ masked: false, search: true, zoomIn: true });
await expect.poll(chrome, { timeout: 10_000 }).toEqual(
expect.objectContaining({
tabs: expect.arrayContaining(['file', 'home', 'view']),
active: 'home',
paste: true,
}),
);

await page.evaluate(() => post('document:set-readonly', { readonly: true }));
await expect.poll(chrome, { timeout: 10_000 }).toEqual({ masked: true, search: true, zoomIn: true });
await expect.poll(chrome, { timeout: 10_000 }).toEqual(READONLY);
});
}
});
3 changes: 0 additions & 3 deletions test/unit/vendor-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,6 @@ describe('vendor contract sentinel', () => {
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/);
},
);

Expand Down
Loading