@casualoffice/sheets 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chrome.cjs +2 -1
- package/dist/chrome.cjs.map +1 -1
- package/dist/chrome.js +2 -1
- package/dist/chrome.js.map +1 -1
- package/dist/collab.cjs +8 -3
- package/dist/collab.cjs.map +1 -1
- package/dist/collab.d.cts +27 -2
- package/dist/collab.d.ts +27 -2
- package/dist/collab.js +8 -3
- package/dist/collab.js.map +1 -1
- package/dist/embed/embed-runtime.js +104 -104
- package/dist/embed/embed.html +21 -7
- package/dist/index.cjs +50 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +50 -1
- package/dist/index.js.map +1 -1
- package/dist/sheets.cjs +51 -2
- package/dist/sheets.cjs.map +1 -1
- package/dist/sheets.d.cts +30 -4
- package/dist/sheets.d.ts +30 -4
- package/dist/sheets.js +51 -2
- package/dist/sheets.js.map +1 -1
- package/package.json +1 -1
- package/src/chrome/fonts.ts +6 -1
- package/src/collab/attachCollab.ts +9 -3
- package/src/collab/index.ts +3 -0
- package/src/collab/ws-url.ts +35 -5
- package/src/collab/ws-url.unit.test.ts +55 -0
- package/src/embed-runtime/embed.html +21 -7
- package/src/embed-runtime/index.tsx +44 -0
- package/src/embed-runtime/locale.ts +38 -0
- package/src/sheets/CasualSheetsIframe.tsx +5 -3
- package/src/sheets/index.ts +1 -0
- package/src/sheets/read-only.ts +106 -0
|
@@ -25,7 +25,7 @@ import type { IWorkbookData } from '@univerjs/core';
|
|
|
25
25
|
import type { FUniver } from '@univerjs/core/facade';
|
|
26
26
|
import type { CasualSheetsAPI } from '../sheets/api';
|
|
27
27
|
import { startBridge, type BridgeHandle } from './bridge';
|
|
28
|
-
import { buildWsUrl } from './ws-url';
|
|
28
|
+
import { buildWsUrl, type WsUrlShare } from './ws-url';
|
|
29
29
|
|
|
30
30
|
/** Either the SDK's imperative API (`onReady`) or the bare FUniver facade.
|
|
31
31
|
* Collab only needs the facade, so a host that holds the raw FUniver (the
|
|
@@ -64,8 +64,14 @@ export interface AttachCollabOptions {
|
|
|
64
64
|
* hook keep the connection queued without it. Defaults to `'anon'` (the
|
|
65
65
|
* reference server's hook only reads the `role` query param). */
|
|
66
66
|
token?: string;
|
|
67
|
-
/** `view` joins read-only. Defaults to `'write'`.
|
|
67
|
+
/** `view` joins read-only. Defaults to `'write'`. Ignored by the server
|
|
68
|
+
* when a `share` token is supplied — the token is authoritative. */
|
|
68
69
|
role?: CollabRole;
|
|
70
|
+
/** Secure share-link capability (sharing-model §6.1). When `share.share`
|
|
71
|
+
* is set, the role is omitted from the WS URL and the server resolves it
|
|
72
|
+
* from the token (bound to this room at mint time). `share.sp` carries
|
|
73
|
+
* the optional join password for a password-protected token. */
|
|
74
|
+
share?: WsUrlShare;
|
|
69
75
|
/**
|
|
70
76
|
* Called when a peer's compaction snapshot arrives — the host swaps the
|
|
71
77
|
* workbook (typically `api.loadSnapshot(wb)`). MAY return a promise; the
|
|
@@ -103,7 +109,7 @@ export function attachCollab(api: CollabAttachable, opts: AttachCollabOptions):
|
|
|
103
109
|
// Match the reference host: drop the reconnect timeout to 10 s so a dropped
|
|
104
110
|
// socket surfaces as `offline` quickly rather than after the 30 s default.
|
|
105
111
|
const ws = new HocuspocusProviderWebsocket({
|
|
106
|
-
url: buildWsUrl(opts.server, opts.room, role, opts.password),
|
|
112
|
+
url: buildWsUrl(opts.server, opts.room, role, opts.password, opts.share),
|
|
107
113
|
messageReconnectTimeout: 10_000,
|
|
108
114
|
});
|
|
109
115
|
const provider = new HocuspocusProvider({
|
package/src/collab/index.ts
CHANGED
|
@@ -18,6 +18,9 @@ export {
|
|
|
18
18
|
type CollabRole,
|
|
19
19
|
type CollabConnectionStatus,
|
|
20
20
|
} from './attachCollab';
|
|
21
|
+
// Secure share-link capability shape (sharing-model §6.1) — referenced by
|
|
22
|
+
// AttachCollabOptions.share.
|
|
23
|
+
export { type WsUrlShare } from './ws-url';
|
|
21
24
|
|
|
22
25
|
// The mutation bridge — the framework-agnostic core (subscribes to
|
|
23
26
|
// onMutationExecutedForCollab, replays with fromCollab, guards __splitChunk__).
|
package/src/collab/ws-url.ts
CHANGED
|
@@ -4,17 +4,47 @@
|
|
|
4
4
|
* `node:test` without dragging in the Univer ESM graph.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/** Extra capability params carried on the WS upgrade for secure share
|
|
8
|
+
* links (sharing-model §6.1). */
|
|
9
|
+
export type WsUrlShare = {
|
|
10
|
+
/** Secure share-link token. When present the server is authoritative
|
|
11
|
+
* for the role — it resolves the token to a role + a bound room, so
|
|
12
|
+
* the client must NOT also assert `role=` (the server ignores a
|
|
13
|
+
* client `?role=` when a token is present; sending one is just
|
|
14
|
+
* spoofable noise). */
|
|
15
|
+
share?: string;
|
|
16
|
+
/** Optional join password paired with a password-protected share
|
|
17
|
+
* token (`?sp=`). Distinct from the anonymous-room `?p=` password. */
|
|
18
|
+
sp?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the room WS URL.
|
|
23
|
+
*
|
|
24
|
+
* - No share token: `<server>?room=<id>[&p=<pw>]&role=<role>` — the
|
|
25
|
+
* anonymous-room path; byte-identical to before.
|
|
26
|
+
* - With a `share` token: `<server>?room=<id>[&p=<pw>]&share=<token>[&sp=<pw>]`.
|
|
27
|
+
* `role=` is deliberately omitted — the server resolves the role
|
|
28
|
+
* from the token, so shipping a spoofable client role is pointless.
|
|
29
|
+
*/
|
|
8
30
|
export function buildWsUrl(
|
|
9
31
|
server: string,
|
|
10
32
|
room: string,
|
|
11
33
|
role: 'view' | 'write',
|
|
12
34
|
password?: string,
|
|
35
|
+
share?: WsUrlShare,
|
|
13
36
|
): string {
|
|
14
37
|
const sep = server.includes('?') ? '&' : '?';
|
|
15
|
-
|
|
38
|
+
const base =
|
|
16
39
|
`${server}${sep}room=${encodeURIComponent(room)}` +
|
|
17
|
-
`${password ? `&p=${encodeURIComponent(password)}` : ''}
|
|
18
|
-
|
|
19
|
-
)
|
|
40
|
+
`${password ? `&p=${encodeURIComponent(password)}` : ''}`;
|
|
41
|
+
const token = share?.share;
|
|
42
|
+
if (token) {
|
|
43
|
+
return (
|
|
44
|
+
base +
|
|
45
|
+
`&share=${encodeURIComponent(token)}` +
|
|
46
|
+
`${share?.sp ? `&sp=${encodeURIComponent(share.sp)}` : ''}`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return base + `&role=${encodeURIComponent(role)}`;
|
|
20
50
|
}
|
|
@@ -33,3 +33,58 @@ test('buildWsUrl percent-encodes the room id', () => {
|
|
|
33
33
|
'wss://h/yjs?room=a%20b%2Fc&role=write',
|
|
34
34
|
);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
// ── Secure share-link tokens (sharing-model §6.1) ──────────────────────────
|
|
38
|
+
|
|
39
|
+
test('buildWsUrl with a share token forwards share= and OMITS role=', () => {
|
|
40
|
+
// The server is authoritative for the role when a token is present, so the
|
|
41
|
+
// spoofable client role must not be on the URL — even though `role` is
|
|
42
|
+
// still passed positionally. A `write` here must NOT leak through.
|
|
43
|
+
assert.equal(
|
|
44
|
+
buildWsUrl('wss://h/yjs', 'room1', 'write', undefined, { share: 'tok123' }),
|
|
45
|
+
'wss://h/yjs?room=room1&share=tok123',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('buildWsUrl with a share token forwards sp= (join password) when set', () => {
|
|
50
|
+
assert.equal(
|
|
51
|
+
buildWsUrl('wss://h/yjs', 'room1', 'view', undefined, { share: 'tok123', sp: 'p@ss/word' }),
|
|
52
|
+
'wss://h/yjs?room=room1&share=tok123&sp=p%40ss%2Fword',
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('buildWsUrl percent-encodes the share token', () => {
|
|
57
|
+
assert.equal(
|
|
58
|
+
buildWsUrl('wss://h/yjs', 'room1', 'view', undefined, { share: 'a b/c' }),
|
|
59
|
+
'wss://h/yjs?room=room1&share=a%20b%2Fc',
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('buildWsUrl keeps a room password alongside a share token', () => {
|
|
64
|
+
assert.equal(
|
|
65
|
+
buildWsUrl('wss://h/yjs', 'room1', 'write', 'roompw', { share: 'tok' }),
|
|
66
|
+
'wss://h/yjs?room=room1&p=roompw&share=tok',
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('buildWsUrl is byte-identical to the no-token form when share is undefined', () => {
|
|
71
|
+
// The explicit-undefined call site (e.g. attachCollab forwarding an absent
|
|
72
|
+
// opts.share) must produce exactly what the old 4-arg call produced.
|
|
73
|
+
assert.equal(
|
|
74
|
+
buildWsUrl('wss://h/yjs', 'room1', 'write', undefined, undefined),
|
|
75
|
+
buildWsUrl('wss://h/yjs', 'room1', 'write'),
|
|
76
|
+
);
|
|
77
|
+
assert.equal(
|
|
78
|
+
buildWsUrl('wss://h/yjs', 'room1', 'view', 'pw', undefined),
|
|
79
|
+
buildWsUrl('wss://h/yjs', 'room1', 'view', 'pw'),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('buildWsUrl ignores an empty share token (falls back to role=)', () => {
|
|
84
|
+
// A defensive case: `{ share: '' }` is not a real token, so we must not
|
|
85
|
+
// emit `share=` with an empty value and drop the role.
|
|
86
|
+
assert.equal(
|
|
87
|
+
buildWsUrl('wss://h/yjs', 'room1', 'write', undefined, { share: '' }),
|
|
88
|
+
'wss://h/yjs?room=room1&role=write',
|
|
89
|
+
);
|
|
90
|
+
});
|
|
@@ -5,16 +5,30 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Casual Sheets (embed)</title>
|
|
7
7
|
<style>
|
|
8
|
-
html,
|
|
9
|
-
|
|
8
|
+
html,
|
|
9
|
+
body {
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 0;
|
|
12
|
+
height: 100%;
|
|
13
|
+
background: #fff;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
}
|
|
16
|
+
#casual-embed-root {
|
|
17
|
+
width: 100%;
|
|
18
|
+
height: 100%;
|
|
19
|
+
}
|
|
10
20
|
/* Preview-mode chrome hiding — v0.5.x ties these to component
|
|
11
21
|
props; v0.5.0 lands the wire end-to-end and uses CSS gates. */
|
|
12
|
-
[data-view-mode=
|
|
13
|
-
[data-view-mode=
|
|
14
|
-
[data-view-mode=
|
|
15
|
-
[data-view-mode=
|
|
22
|
+
[data-view-mode='preview'] [data-toolbar],
|
|
23
|
+
[data-view-mode='preview'] [data-formula-bar],
|
|
24
|
+
[data-view-mode='preview'] [data-status-bar],
|
|
25
|
+
[data-view-mode='preview'] [data-sheet-tabs] {
|
|
26
|
+
display: none !important;
|
|
27
|
+
}
|
|
16
28
|
</style>
|
|
17
|
-
|
|
29
|
+
<!-- No external stylesheet: tsup's `injectStyle` inlines Univer's CSS
|
|
30
|
+
into embed-runtime.js, which appends it as a <style> tag at runtime.
|
|
31
|
+
A <link> here would just 404 (no .css is emitted). -->
|
|
18
32
|
</head>
|
|
19
33
|
<body>
|
|
20
34
|
<div id="casual-embed-root"></div>
|
|
@@ -23,7 +23,9 @@ import { createRoot } from 'react-dom/client';
|
|
|
23
23
|
import { EmbedTransport } from '../embed/EmbedTransport';
|
|
24
24
|
import type { CasualApp } from '../embed/protocol';
|
|
25
25
|
import { CasualSheets } from '../sheets/CasualSheets';
|
|
26
|
+
import { applyReadOnly, getEditable } from '../sheets/read-only';
|
|
26
27
|
import { xlsxToWorkbookData } from '../xlsx';
|
|
28
|
+
import { EMBED_LOCALES } from './locale';
|
|
27
29
|
import type { IWorkbookData } from '@univerjs/core';
|
|
28
30
|
|
|
29
31
|
interface EmbedUrlConfig {
|
|
@@ -224,6 +226,13 @@ function EmbeddedSheets({
|
|
|
224
226
|
key={viewMode}
|
|
225
227
|
initialData={data}
|
|
226
228
|
ui={ui}
|
|
229
|
+
// Seed the en-US string bundle. Without `locales`, Univer's
|
|
230
|
+
// LocaleService throws "Locale not initialized" and the workbench
|
|
231
|
+
// canvas never paints (data still loads, so a snapshot round-trips,
|
|
232
|
+
// but the grid is blank). The React `<CasualSheets>` host path gets
|
|
233
|
+
// this from the host's `locales` prop; the iframe has no host to
|
|
234
|
+
// pass one, so the self-contained runtime bundles a minimal set.
|
|
235
|
+
locales={EMBED_LOCALES}
|
|
227
236
|
// The embed runtime is bundled as ONE self-contained file (tsup
|
|
228
237
|
// `noExternal: /.*/`). Lazy plugins would emit per-feature dynamic
|
|
229
238
|
// chunks into dist/embed/, breaking that single-file deployment, so the
|
|
@@ -239,6 +248,41 @@ function EmbeddedSheets({
|
|
|
239
248
|
onSave={(snapshot) => transport.sendSaveNotify({ snapshot, reason: 'shortcut' })}
|
|
240
249
|
onExit={(snapshot) => transport.sendExit({ snapshot })}
|
|
241
250
|
onReady={(api) => {
|
|
251
|
+
// Expose the imperative API on the iframe window so hosts can
|
|
252
|
+
// introspect/debug the embedded editor (and e2e can drive it). Mirrors
|
|
253
|
+
// the React component's `window.__univerAPI` seam.
|
|
254
|
+
(globalThis as unknown as { __casualEmbedApi?: unknown }).__casualEmbedApi = api;
|
|
255
|
+
// Debug seam: report the live workbook-editable permission so hosts/e2e
|
|
256
|
+
// can confirm preview is genuinely read-only (not just chromeless).
|
|
257
|
+
(
|
|
258
|
+
globalThis as unknown as { __casualEmbedEditable?: () => boolean | undefined }
|
|
259
|
+
).__casualEmbedEditable = () => {
|
|
260
|
+
const uid = api.getSnapshot()?.id;
|
|
261
|
+
return uid ? getEditable(api.univer, uid) : undefined;
|
|
262
|
+
};
|
|
263
|
+
// Preview = genuinely READ-ONLY. Hiding the chrome (above) isn't
|
|
264
|
+
// enough — Univer's cell editor still opens on dbl-click / F2. Flip the
|
|
265
|
+
// workbook's edit permission off so preview can't be typed into. The
|
|
266
|
+
// mount remounts on viewMode change (key={viewMode}), so editor mode
|
|
267
|
+
// simply never applies this.
|
|
268
|
+
//
|
|
269
|
+
// Defer to a rAF: applied synchronously in onReady the flag loses a
|
|
270
|
+
// race — sheets-ui initialises WorkbookEditablePermission to `true`
|
|
271
|
+
// during unit setup AFTER onReady fires, clobbering our `false` (a
|
|
272
|
+
// behavioural embed test caught preview still accepting keystrokes).
|
|
273
|
+
// The app's collab view-only path waits a rAF for the same reason
|
|
274
|
+
// (CollabDriver.tsx), so the permission point exists and we
|
|
275
|
+
// updatePermissionPoint(false) over the settled default.
|
|
276
|
+
if (viewMode === 'preview') {
|
|
277
|
+
requestAnimationFrame(() => {
|
|
278
|
+
const unitId = api.getSnapshot()?.id;
|
|
279
|
+
if (unitId)
|
|
280
|
+
applyReadOnly(api.univer, unitId, () => {
|
|
281
|
+
const g = globalThis as unknown as { __casualEmbedBlocked?: number };
|
|
282
|
+
g.__casualEmbedBlocked = (g.__casualEmbedBlocked ?? 0) + 1;
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
}
|
|
242
286
|
// Wire host → editor command.execute (Drive's custom toolbar
|
|
243
287
|
// calls this for bold / italic / undo / …). Maps the small
|
|
244
288
|
// protocol union to the Univer command id the FUniver facade
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal en-US locale bundle for the in-iframe runtime.
|
|
3
|
+
*
|
|
4
|
+
* The embed runtime mounts the MINIMAL editor (`lazyPlugins={false}`):
|
|
5
|
+
* render + formula engines, UI, docs/docs-ui, sheets, sheets-ui,
|
|
6
|
+
* sheets-formula-ui, numfmt (+ ui). Univer's `LocaleService` throws
|
|
7
|
+
* `Locale not initialized` and the workbench canvas never paints if the
|
|
8
|
+
* `locales` map is empty — so we MUST seed the string bundle for exactly
|
|
9
|
+
* those plugins. (The React `<CasualSheets>` host path supplies its own
|
|
10
|
+
* `locales` prop; the iframe has no host to pass one, so it bundles this.)
|
|
11
|
+
*
|
|
12
|
+
* Kept narrow on purpose: only the plugins the minimal runtime registers.
|
|
13
|
+
* Adding strings for plugins it never loads is dead weight in the
|
|
14
|
+
* self-contained iframe bundle.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { LocaleType, Tools } from '@univerjs/core';
|
|
18
|
+
|
|
19
|
+
import UniverSheetsEnUS from '@univerjs/sheets/locale/en-US';
|
|
20
|
+
import UniverSheetsUIEnUS from '@univerjs/sheets-ui/locale/en-US';
|
|
21
|
+
import UniverSheetsFormulaUIEnUS from '@univerjs/sheets-formula-ui/locale/en-US';
|
|
22
|
+
import UniverSheetsNumfmtUIEnUS from '@univerjs/sheets-numfmt-ui/locale/en-US';
|
|
23
|
+
import UniverDocsUIEnUS from '@univerjs/docs-ui/locale/en-US';
|
|
24
|
+
import UniverUIEnUS from '@univerjs/ui/locale/en-US';
|
|
25
|
+
|
|
26
|
+
const enUS = Tools.deepMerge(
|
|
27
|
+
{},
|
|
28
|
+
UniverUIEnUS,
|
|
29
|
+
UniverDocsUIEnUS,
|
|
30
|
+
UniverSheetsEnUS,
|
|
31
|
+
UniverSheetsUIEnUS,
|
|
32
|
+
UniverSheetsFormulaUIEnUS,
|
|
33
|
+
UniverSheetsNumfmtUIEnUS,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export const EMBED_LOCALES = {
|
|
37
|
+
[LocaleType.EN_US]: enUS,
|
|
38
|
+
};
|
|
@@ -44,8 +44,9 @@ export interface CasualSheetsIframeRef {
|
|
|
44
44
|
setViewMode(mode: 'preview' | 'editor'): void;
|
|
45
45
|
iframe(): HTMLIFrameElement | null;
|
|
46
46
|
/** Dispatch a formatting / navigation command (bold, italic, undo, …)
|
|
47
|
-
* against the iframe's active selection.
|
|
48
|
-
|
|
47
|
+
* against the iframe's active selection. `args` carries command payloads
|
|
48
|
+
* (e.g. font family/size, colour) — forwarded over the protocol. v0.6+. */
|
|
49
|
+
executeCommand(command: CommandExecuteData['command'], args?: CommandExecuteData['args']): void;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export interface CasualSheetsIframeProps {
|
|
@@ -194,7 +195,8 @@ export const CasualSheetsIframe = forwardRef<CasualSheetsIframeRef, CasualSheets
|
|
|
194
195
|
apiRef.current = {
|
|
195
196
|
setViewMode: (mode) => transportRef.current?.sendSetViewMode({ viewMode: mode }),
|
|
196
197
|
iframe: () => iframeRef.current,
|
|
197
|
-
executeCommand: (command) =>
|
|
198
|
+
executeCommand: (command, args) =>
|
|
199
|
+
transportRef.current?.sendCommandExecute({ command, args }),
|
|
198
200
|
};
|
|
199
201
|
}
|
|
200
202
|
|
package/src/sheets/index.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export { CasualSheets, type CasualSheetsProps } from './CasualSheets';
|
|
5
5
|
export { createCasualSheetsAPI, type CasualSheetsAPI, type RangeRef } from './api';
|
|
6
|
+
export { applyReadOnly, getEditable } from './read-only';
|
|
6
7
|
export {
|
|
7
8
|
CasualSheetsIframe,
|
|
8
9
|
type CasualSheetsIframeProps,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { CustomCommandExecutionError, ICommandService, IPermissionService } from '@univerjs/core';
|
|
2
|
+
import type { FUniver } from '@univerjs/core/facade';
|
|
3
|
+
import { WorkbookEditablePermission } from '@univerjs/sheets';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Command ids that MUTATE a sheet — opening the cell editor, writing values,
|
|
7
|
+
* styling, structural edits, clipboard paste. The read-only veto cancels any
|
|
8
|
+
* command whose id matches. Navigation (selection, scroll, zoom, sheet switch),
|
|
9
|
+
* copy, and undo/redo deliberately fall through so preview stays usable.
|
|
10
|
+
*
|
|
11
|
+
* `set-cell-edit-visible` / `set-activate-cell-edit` are the editor-open
|
|
12
|
+
* operations — blocking them is what actually stops keyboard typing, since the
|
|
13
|
+
* cell editor never opens. The rest stop programmatic / paste / menu mutations.
|
|
14
|
+
*/
|
|
15
|
+
const READONLY_BLOCK =
|
|
16
|
+
/(set-cell-edit-visible|set-activate-cell-edit|set-range-values|set-style|set-bold|set-italic|set-underline|set-strike|set-font|set-background|set-text|set-horizontal|set-vertical|set-wrap|set-rotation|set-border|set-number-format|insert-|delete-|remove-|clear-selection|cut-content|paste|move-range|move-rows|move-cols|merge|split|add-worksheet|set-worksheet-name|set-worksheet-row|set-worksheet-col|auto-fill|reorder|set-defined-name|set-tab-color|set-frozen-cancel)/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Make a workbook genuinely READ-ONLY.
|
|
20
|
+
*
|
|
21
|
+
* Two layers, because they cover different host setups:
|
|
22
|
+
*
|
|
23
|
+
* 1. **Command veto** (`beforeCommandExecuted` → throw
|
|
24
|
+
* `CustomCommandExecutionError`) — cancels every mutating command. This is
|
|
25
|
+
* the load-bearing layer for the iframe embed, whose minimal plugin set does
|
|
26
|
+
* NOT enforce `WorkbookEditablePermission` (verified: the editor still
|
|
27
|
+
* accepts edits with the permission flipped off).
|
|
28
|
+
* 2. **Permission flip** (`WorkbookEditablePermission` → false) — on the full
|
|
29
|
+
* `<CasualSheets>` host path this also greys out mutating menu items and
|
|
30
|
+
* stops the editor opening; harmless where unenforced.
|
|
31
|
+
*
|
|
32
|
+
* Returns a disposer that removes the veto and restores the prior editable
|
|
33
|
+
* state (for callers that toggle a live unit between preview/editor).
|
|
34
|
+
*/
|
|
35
|
+
export function applyReadOnly(
|
|
36
|
+
univerApi: FUniver,
|
|
37
|
+
unitId: string,
|
|
38
|
+
onBlock?: (commandId: string) => void,
|
|
39
|
+
): () => void {
|
|
40
|
+
const injector = (univerApi as unknown as { _injector?: { get(t: unknown): unknown } })._injector;
|
|
41
|
+
|
|
42
|
+
// Layer 1: veto mutating commands — the only layer the minimal embed enforces.
|
|
43
|
+
const cmd = injector?.get(ICommandService) as
|
|
44
|
+
| { beforeCommandExecuted(l: (info: { id: string }) => void): { dispose(): void } }
|
|
45
|
+
| undefined;
|
|
46
|
+
const vetoDisposable = cmd?.beforeCommandExecuted((info) => {
|
|
47
|
+
if (READONLY_BLOCK.test(info.id)) {
|
|
48
|
+
onBlock?.(info.id);
|
|
49
|
+
throw new CustomCommandExecutionError(`read-only: blocked ${info.id}`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Layer 2: permission flip (best-effort; load-bearing only on full hosts).
|
|
54
|
+
const svc = injector?.get(IPermissionService) as
|
|
55
|
+
| {
|
|
56
|
+
addPermissionPoint(p: unknown): boolean;
|
|
57
|
+
updatePermissionPoint(id: string, value: unknown): void;
|
|
58
|
+
getPermissionPoint(id: string): { value: unknown } | undefined;
|
|
59
|
+
}
|
|
60
|
+
| undefined;
|
|
61
|
+
|
|
62
|
+
// Constructing the point yields the deterministic id Univer derives for the
|
|
63
|
+
// workbook-edit permission; we don't keep the instance otherwise.
|
|
64
|
+
const id = new WorkbookEditablePermission(unitId).id;
|
|
65
|
+
let prev: unknown;
|
|
66
|
+
if (svc) {
|
|
67
|
+
try {
|
|
68
|
+
const existing = svc.getPermissionPoint(id);
|
|
69
|
+
if (existing) {
|
|
70
|
+
prev = existing.value;
|
|
71
|
+
svc.updatePermissionPoint(id, false);
|
|
72
|
+
} else {
|
|
73
|
+
const point = new WorkbookEditablePermission(unitId);
|
|
74
|
+
point.value = false;
|
|
75
|
+
svc.addPermissionPoint(point);
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
/* best-effort — the veto above is the load-bearing layer */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
vetoDisposable?.dispose();
|
|
84
|
+
try {
|
|
85
|
+
svc?.updatePermissionPoint(id, prev === undefined ? true : prev);
|
|
86
|
+
} catch {
|
|
87
|
+
/* swallow */
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read the current `WorkbookEditablePermission` value for a unit — `true`
|
|
94
|
+
* (editable), `false` (read-only), or `undefined` if the point isn't
|
|
95
|
+
* registered yet. Lets hosts/tests confirm {@link applyReadOnly} took.
|
|
96
|
+
*/
|
|
97
|
+
export function getEditable(univerApi: FUniver, unitId: string): boolean | undefined {
|
|
98
|
+
const injector = (univerApi as unknown as { _injector?: { get(t: unknown): unknown } })._injector;
|
|
99
|
+
const svc = injector?.get(IPermissionService) as
|
|
100
|
+
| { getPermissionPoint(id: string): { value: unknown } | undefined }
|
|
101
|
+
| undefined;
|
|
102
|
+
if (!svc) return undefined;
|
|
103
|
+
const id = new WorkbookEditablePermission(unitId).id;
|
|
104
|
+
const point = svc.getPermissionPoint(id);
|
|
105
|
+
return point ? (point.value as boolean) : undefined;
|
|
106
|
+
}
|