-
Notifications
You must be signed in to change notification settings - Fork 217
Expand file tree
/
Copy pathindex.ts
More file actions
340 lines (311 loc) · 15.4 KB
/
Copy pathindex.ts
File metadata and controls
340 lines (311 loc) · 15.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
import {
documentIsExpected,
healStaleController,
isUnseenBuild,
onWaitingWorker,
shouldReloadOnControllerChange,
wireServiceWorkerUpdates,
} from './lib/sw-update';
import { getAllQueryString } from 'ranuts/utils';
import { View } from 'ranui/builder';
import { initEmbedApi } from './lib/embed-api';
import { initEvents, setEventUICallbacks } from './lib/events';
import { onCreateNew, onOpenDocument, openDocumentFromUrl, openLocalFile, setUICallbacks } from './lib/document';
import { parseReadonly } from '@ranuts/shared/document-utils';
import { applyDocumentLanguage } from '@ranuts/shared/i18n';
import { getDocmentObj } from '@ranuts/shared/store';
import { initAnalytics } from './lib/analytics';
import { createControlPanel, hideControlPanel, hideLanding, showControlPanel } from './lib/ui';
import 'ranui/button';
import 'ranui/card';
import 'ranui/select';
import { initWebMcp } from './lib/web-mcp';
import { hasUnsavedChanges, installUnsavedChangesGuard } from './lib/unsaved-guard';
import { initDocumentHistory } from './lib/history';
import '@khmyznikov/pwa-install';
import './styles/base.css';
declare global {
interface Window {
onCreateNew: (ext: string) => Promise<void>;
onOpenDocument: () => void;
hideControlPanel?: () => void;
showControlPanel?: () => void;
DocsAPI: {
DocEditor: new (elementId: string, config: any) => any;
};
}
}
// Reflect the detected shell language on <html> (lang, and dir for RTL locales
// like fa). The static landing pages carry their own lang/dir in the HTML.
applyDocumentLanguage();
// Initialize events
initEvents();
initEmbedApi();
// WebMCP (browser-agent tools): no-op unless the browser exposes
// document/navigator.modelContext and this is a top-level window.
initWebMcp();
// Warn before an accidental close/reload throws away edits that never reached
// the user's disk. No-op in embed mode -- the host page owns that UX.
installUnsavedChangesGuard();
// Local history: keep the recovery points in step with what reaches the disk.
initDocumentHistory();
// Privacy-friendly analytics (no-op unless VITE_CF_BEACON_TOKEN is set; never in embed mode)
initAnalytics();
// Set up UI callbacks to avoid circular dependency. The landing hero is toggled
// inside hideControlPanel/showControlPanel themselves (see lib/ui.ts), so these
// raw functions already keep the hero in sync — no re-wrapping needed.
setUICallbacks({
hideControlPanel,
showControlPanel,
});
// Set up UI callbacks for events module. Opening a document over the desktop
// integration channel (RENDER_OFFICE) dismisses the landing hero via
// hideControlPanel's built-in hideLanding() call.
setEventUICallbacks({
hideControlPanel,
});
// Export onCreateNew to window
window.onCreateNew = onCreateNew;
// Expose the upload flow globally so the landing hero (and other host pages) can trigger it.
window.onOpenDocument = onOpenDocument;
// Export control panel functions for use in other modules
window.hideControlPanel = hideControlPanel;
window.showControlPanel = showControlPanel;
// If the URL already says what to open, the home-state panel must never be
// painted: it is built visible and only hidden once the document takes over,
// and on a cold load that gap is long enough to read (a `?saved=` restore adds
// two dynamic imports before it). Deciding here, before the panel exists,
// keeps "View/Edit Document / New Word / New Excel / New PowerPoint" off the
// loading screen instead of racing to remove it afterwards.
const params = getAllQueryString();
const opensSomething = Boolean(
params['file'] || params['src'] || params['new'] || params['saved'] || params['open'] === 'local',
);
if (opensSomething) document.body.classList.add('opening-document');
// Initialize UI components
createControlPanel();
// This bundle runs on /editor (editor.html). The homepage / is a static landing
// page whose CTAs navigate here (?new=, ?open=local); legacy deep links on /
// are redirected here by an inline script in index.html.
// Check for file or src parameter in URL
// Both parameters support opening document from URL
// Priority: file > src (for backward compatibility)
// Examples:
// ?file=https://example.com/doc.docx
// ?src=https://example.com/doc.docx
// ?file=doc1.docx&src=doc2.xlsx (will use file: doc1.docx)
const { file, src, readonly, agent } = params;
const documentUrl = file || src;
// Pure preview mode: ?readonly=true (also accepts ?readonly=1 or bare ?readonly).
// Opens the document with editing/download disabled (#25, #85, #87).
const isReadonly = parseReadonly(readonly);
// Experimental AI agent panel: opt-in via ?agent=1 (also ?agent=true or bare ?agent).
const agentEnabled = agent === '1' || agent === 'true' || agent === '';
// Expose the opt-in to the editor iframe (same-origin) so its injected patch only
// adds the "AI" button when the agent feature is enabled — otherwise the button
// stays hidden. See public/onlyoffice-v7-iframe-patch.js.
(window as unknown as { __agentEnabled?: boolean }).__agentEnabled = agentEnabled;
if (agentEnabled) {
void import('./lib/agent-plugin').then(({ createAgentPanel }) => createAgentPanel());
}
// Bridge: the AI button injected into OnlyOffice's left menu lives inside the
// (same-origin) editor iframe. It toggles the panel either by calling this
// global directly or, as a fallback, by posting `agent:toggle` to this window.
const toggleAgentPanelLazy = (): void => {
void import('./lib/agent-plugin').then(({ toggleAgentPanel }) => toggleAgentPanel());
};
(window as unknown as { __toggleAgentPanel?: () => void }).__toggleAgentPanel = toggleAgentPanelLazy;
window.addEventListener('message', (event: MessageEvent) => {
if (event.data?.type === 'agent:toggle') toggleAgentPanelLazy();
});
// Deep-link to a blank document: ?new=docx|xlsx|pptx opens the editor straight
// into a new file (skipping the landing hero). The localized homepages use it —
// e.g. /zh-CN/ links to `/?locale=zh-CN&new=docx`, so i18n (which reads ?locale)
// boots the editor UI in Chinese and drops the user directly into editing.
const newExtRaw = params['new'];
const newExt = typeof newExtRaw === 'string' ? newExtRaw.replace(/^\./, '').toLowerCase() : '';
const createNewOnLoad = ['docx', 'xlsx', 'pptx'].includes(newExt) && !documentUrl;
// `?open=local`: a static landing page (e.g. /zh-CN/) stashed a picked file in
// IndexedDB via public/open-local.js — take it out and open it on boot.
const openParam = params['open'];
const openLocalOnLoad = openParam === 'local' && !documentUrl && !createNewOnLoad;
// `?saved=<id>`: which of this browser's saved documents to open. Every
// editing session stamps its own id here (see lib/history/session.ts), so a
// reload comes back to the same document instead of a second blank one, and
// the Open link on /history is the URL the editor was already using.
//
// A query parameter rather than a path segment, deliberately. Google Docs and
// Figma put ids in the path because those ids name something on a server that
// anyone with the link can open; this one names a row in one browser's
// IndexedDB. In a path it would look shareable, and the person it was sent to
// would open an empty editor. It is also why it is not called `id`: what makes
// it meaningful is not that it is an identifier, it is that the document is
// saved on this device.
const savedParam = params['saved'] ?? '';
// Landing hero orchestration. Only the bare homepage (no ?file/?src/?new, not
// embedded) shows the crawlable hero. If a document is about to load or be
// created, or we're embedded, hide it immediately to avoid a flash before the
// editor takes over.
const isEmbedded = document.body.classList.contains('embed-mode');
if (documentUrl || isEmbedded || createNewOnLoad || openLocalOnLoad || savedParam) {
hideLanding();
} else {
// Bare /editor with nothing to open: the landing lives at / now.
window.location.replace('/');
}
void (async () => {
// A stored snapshot wins over every other way of opening: it is strictly
// newer than the file on disk or the blank document the other parameters
// would produce, and it is the copy nobody else has.
if (savedParam && !isEmbedded) {
const [{ getDoc }, { restoreDocument }] = await Promise.all([
import('./lib/history/store'),
import('./lib/history/recovery'),
]);
const doc = await getDoc(savedParam);
if (doc && (await restoreDocument(doc))) return;
// No snapshot yet (nothing was edited before the reload) or it expired:
// fall through and open the same document again under the same id.
}
if (documentUrl) {
try {
const decodedUrl = decodeURIComponent(documentUrl);
await openDocumentFromUrl(decodedUrl, undefined, { readonly: isReadonly, docId: savedParam || undefined });
} catch (error) {
// If decoding fails, try using original URL
console.warn('Failed to decode URL, using original:', error);
await openDocumentFromUrl(documentUrl, undefined, { readonly: isReadonly, docId: savedParam || undefined });
}
return;
}
if (createNewOnLoad && !isEmbedded) {
await onCreateNew(`.${newExt}`, { docId: savedParam || undefined });
return;
}
if (openLocalOnLoad && !isEmbedded) {
const { takePendingFile } = await import('./lib/pending-open');
const file = await takePendingFile();
// One-shot param: strip it so a reload lands on the plain homepage instead
// of hiding the hero again with nothing left to open.
const cleaned = new URL(window.location.href);
cleaned.searchParams.delete('open');
window.history.replaceState(null, '', cleaned);
if (file) {
await openLocalFile(file, { historyId: savedParam || undefined });
return;
}
// Stale deep link (reload, bookmarked URL): nothing pending -- back to the landing.
window.location.replace('/');
return;
}
// `?saved=` on its own and nothing stored under it: the document it names was
// deleted or has expired, so there is nothing here to show.
if (savedParam && !isEmbedded) window.location.replace('/');
})();
// No boot-time recovery offer here on purpose. /editor never opens empty (a
// bare visit is redirected to the landing above), so anything shown on boot
// interrupts a document the user is already working in to talk about a
// different one -- which is what it did, and it was wrong every time. Old work
// is offered where the user is not mid-task instead: the landing page's
// "continue last time" line (public/history-recent.js) and /history.
// Register Service Worker for PWA
if ('serviceWorker' in navigator) {
// Update policy lives in lib/sw-update.ts: a new build's worker waits until
// no document is open, then takes over and the page reloads once.
const hadController = !!navigator.serviceWorker.controller;
let reloadingForUpdate = false;
// "Is a document open?" is the wrong tense at boot: the store fills in a few
// hundred milliseconds after register() resolves, so a page opening a
// document answers "no" for exactly as long as it takes to promote a worker
// into the middle of its own load. The URL already knows.
const hasOpenDocument = () => Boolean(getDocmentObj().fileName) || documentIsExpected(window.location.search);
// The runtime caches as they were before anything could have changed them.
// Read at boot rather than when a swap happens: by then the incoming worker
// has created its own, and the question is which builds this browser was
// running BEFORE. Taken from the cache rather than by asking the outgoing
// worker, which a swap may already have terminated.
const cachesAtBoot = typeof caches === 'undefined' ? Promise.resolve([]) : caches.keys();
const bootCacheNames = { keys: () => cachesAtBoot };
navigator.serviceWorker.addEventListener('controllerchange', () => {
void (async () => {
const isNewBuild = await isUnseenBuild(navigator.serviceWorker.controller, bootCacheNames);
if (
!shouldReloadOnControllerChange({
hadController,
alreadyReloading: reloadingForUpdate,
isNewBuild,
hasUnsavedChanges: hasUnsavedChanges(),
})
) {
return;
}
reloadingForUpdate = true;
window.location.reload();
})();
});
// The script we register, absolute: the vendored editor registers one of its
// own into this same scope from inside the iframe, and only this URL says
// which waiting worker is a new build of ours.
const ownScriptURL = new URL('./sw.js', window.location.href).href;
window.addEventListener('load', () => {
navigator.serviceWorker
.register('./sw.js')
.then((registration) => {
console.log('SW registered: ', registration);
wireServiceWorkerUpdates(registration, hasOpenDocument, ownScriptURL);
// Promotion above is refused while a document is open, and this page
// is usually opened with one (?new=, ?file=, ?saved=). Without an
// offer, such a visitor never leaves the build their worker cached --
// reloading serves it again. Ask instead of deciding for them: the
// reload happens on click, so the mixed-version hazard the waiting
// exists to prevent cannot happen either.
// A tab whose worker is an older build heals itself, quietly. The
// navigation is network-first, so this page and its bundle are already
// the new ones; it is the vendored tree underneath that is still being
// served from the outgoing build's cache, and a registry that no longer
// matches the origin is how a reverted build kept rendering garbled
// text long after the revert had shipped. Promote and reload once --
// at boot there is nothing typed to lose, and no one has to be told.
onWaitingWorker(
registration,
(waiting) => {
void healStaleController({
registration,
waiting,
controller: navigator.serviceWorker.controller,
hadController,
storage: window.sessionStorage,
});
},
ownScriptURL,
);
// Check for updates on every page load. Firefox rejects the update
// when the registration changed since it was scheduled (a benign race
// right after register()); swallow it so it never surfaces as an
// unhandled rejection.
registration.update().catch(() => {});
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
});
}
// Initialize PWA install component — built with the ranui builder (ecosystem
// convention: no hand-rolled createElement/setAttribute chains).
const initPwaInstall = () => {
// Use the browser's native resolution from the existing link tags
const manifest = document.querySelector<HTMLLinkElement>('link[rel="manifest"]');
const icon = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
const builder = View('pwa-install')
.id('pwa-install')
// use-local-storage: avoid showing the prompt too often
.attr('use-local-storage', '')
.attr('name', 'Document Editor')
.attr('description', 'A privacy-focused, local web-based document editor.')
.attr('install-description', 'Install the App for a better offline experience and quick access.');
if (manifest?.href) builder.attr('manifest-url', manifest.href);
if (icon?.href) builder.attr('icon', icon.href);
document.body.appendChild(builder.build());
};
// Start PWA initialization after short delay to ensure everything is settled
setTimeout(initPwaInstall, 1000);