@copylee/dsh-office 0.1.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/lib/client.js ADDED
@@ -0,0 +1,534 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@copylee/dsh-office",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ //#region \0rolldown/runtime.js
8
+ var __create = Object.create;
9
+ var __defProp = Object.defineProperty;
10
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
11
+ var __getOwnPropNames = Object.getOwnPropertyNames;
12
+ var __getProtoOf = Object.getPrototypeOf;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __copyProps = (to, from, except, desc) => {
15
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
16
+ key = keys[i];
17
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
18
+ get: ((k) => from[k]).bind(null, key),
19
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
20
+ });
21
+ }
22
+ return to;
23
+ };
24
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
25
+ value: mod,
26
+ enumerable: true
27
+ }) : target, mod));
28
+ //#endregion
29
+ let react = require("react");
30
+ react = __toESM(react, 1);
31
+ //#region lib/types/settings.js
32
+ /**
33
+ * Plugin settings shared by the host and the settings page: defaults and the
34
+ * tolerant reader for the Loader entry config. No schemastery here so the
35
+ * browser bundle stays small.
36
+ */
37
+ /** The Loader entry id (settings namespace); see cordis.patch.yml. */
38
+ const ENTRY_ID = "copylee-office";
39
+ const DEFAULTS = {
40
+ showOnOpen: true,
41
+ renderWidth: 1100
42
+ };
43
+ /** Unwrap `.volatile()` refs (`{ get() }`) and fall back to defaults for bad values. */
44
+ function resolveConfig(raw) {
45
+ const out = {};
46
+ if (raw !== null && typeof raw === "object") for (const [key, value] of Object.entries(raw)) out[key] = value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
47
+ const width = out.renderWidth;
48
+ return {
49
+ showOnOpen: typeof out.showOnOpen === "boolean" ? out.showOnOpen : DEFAULTS.showOnOpen,
50
+ renderWidth: typeof width === "number" && Number.isFinite(width) ? Math.min(2e3, Math.max(600, Math.round(width))) : DEFAULTS.renderWidth
51
+ };
52
+ }
53
+ //#endregion
54
+ //#region lib/types/client/accent.js
55
+ /**
56
+ * The accent colour shared by the copylee DSH plugins (docs/ui-spec.md).
57
+ *
58
+ * One choice — terracotta orange by default, blue or black — is kept in the
59
+ * browser under a key every plugin reads, and painted as two CSS variables on
60
+ * `<body>`, where the Host keeps its own theme variables. A plugin's styles
61
+ * only ever write `var(--cl-accent)` / `var(--cl-accent-ink)`, so changing the
62
+ * colour in any plugin's settings recolours all of them at once.
63
+ *
64
+ * This file is identical in every plugin; change it in all of them together.
65
+ */
66
+ const ACCENTS = {
67
+ orange: {
68
+ name: "陶土橙",
69
+ accent: "#D97757",
70
+ ink: "#FFFFFF"
71
+ },
72
+ blue: {
73
+ name: "蓝色",
74
+ accent: "#3D63E6",
75
+ ink: "#FFFFFF"
76
+ },
77
+ black: {
78
+ name: "黑色",
79
+ accent: "var(--dsw-alias-label-primary, #1F1E1D)",
80
+ ink: "var(--dsw-alias-bg-base, #FFFFFF)"
81
+ }
82
+ };
83
+ const ACCENT_IDS = Object.keys(ACCENTS);
84
+ const DEFAULT_ACCENT = "orange";
85
+ /** Use these in styles; the fallbacks cover the moment before the first paint. */
86
+ const ACCENT = `var(--cl-accent, ${ACCENTS[DEFAULT_ACCENT].accent})`;
87
+ const ACCENT_INK = `var(--cl-accent-ink, ${ACCENTS[DEFAULT_ACCENT].ink})`;
88
+ const KEY = "copylee.dsh.accent";
89
+ const EVENT = "copylee-dsh-accent";
90
+ function isAccent(value) {
91
+ return typeof value === "string" && Object.hasOwn(ACCENTS, value);
92
+ }
93
+ /** The choice made in this browser, or null while none has been made. */
94
+ function storedAccent() {
95
+ try {
96
+ const stored = localStorage.getItem(KEY);
97
+ return isAccent(stored) ? stored : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+ function readAccent() {
103
+ return storedAccent() ?? "orange";
104
+ }
105
+ function paint() {
106
+ const colors = ACCENTS[readAccent()];
107
+ document.body?.style.setProperty("--cl-accent", colors.accent);
108
+ document.body?.style.setProperty("--cl-accent-ink", colors.ink);
109
+ }
110
+ /** Choose the accent for every plugin. */
111
+ function writeAccent(accent) {
112
+ try {
113
+ localStorage.setItem(KEY, accent);
114
+ } catch {}
115
+ paint();
116
+ window.dispatchEvent(new Event(EVENT));
117
+ }
118
+ /** Paint the current accent and keep it current. Returns the undo for plugin disposal. */
119
+ function installAccent() {
120
+ if (typeof document === "undefined") return () => {};
121
+ paint();
122
+ if (document.body === null) document.addEventListener("DOMContentLoaded", paint, { once: true });
123
+ window.addEventListener(EVENT, paint);
124
+ window.addEventListener("storage", paint);
125
+ return () => {
126
+ window.removeEventListener(EVENT, paint);
127
+ window.removeEventListener("storage", paint);
128
+ };
129
+ }
130
+ function useAccent() {
131
+ const [accent, setAccent] = react.useState(readAccent);
132
+ react.useEffect(() => {
133
+ const sync = () => setAccent(readAccent());
134
+ window.addEventListener(EVENT, sync);
135
+ window.addEventListener("storage", sync);
136
+ return () => {
137
+ window.removeEventListener(EVENT, sync);
138
+ window.removeEventListener("storage", sync);
139
+ };
140
+ }, []);
141
+ return [accent, writeAccent];
142
+ }
143
+ /** A row of round swatches: one of them is always chosen. */
144
+ function AccentPicker({ label = "强调色", hint = "对 copylee 的全部插件生效", names }) {
145
+ const [accent, choose] = useAccent();
146
+ const h = react.createElement;
147
+ const name = (id) => names?.[id] ?? ACCENTS[id].name;
148
+ return h("div", { style: {
149
+ display: "flex",
150
+ alignItems: "center",
151
+ gap: 10,
152
+ fontSize: 13,
153
+ flexWrap: "wrap"
154
+ } }, h("span", { style: { fontWeight: 500 } }, label), h("span", {
155
+ role: "radiogroup",
156
+ "aria-label": label,
157
+ style: {
158
+ display: "inline-flex",
159
+ gap: 8
160
+ }
161
+ }, ...ACCENT_IDS.map((id) => h("button", {
162
+ key: id,
163
+ type: "button",
164
+ role: "radio",
165
+ "aria-checked": accent === id,
166
+ "aria-label": name(id),
167
+ title: name(id),
168
+ onClick: () => choose(id),
169
+ style: {
170
+ width: 18,
171
+ height: 18,
172
+ padding: 0,
173
+ borderRadius: "50%",
174
+ cursor: "pointer",
175
+ background: ACCENTS[id].accent,
176
+ border: "2px solid var(--dsw-alias-bg-base, #fff)",
177
+ boxShadow: accent === id ? "0 0 0 2px var(--dsw-alias-label-primary, #1F1E1D)" : "0 0 0 1px var(--dsw-alias-border-l2, rgba(127,127,127,.35))"
178
+ }
179
+ }))), h("span", { style: {
180
+ fontSize: 12,
181
+ color: "var(--dsw-alias-label-tertiary, #888)"
182
+ } }, `${name(accent)} · ${hint}`));
183
+ }
184
+ //#endregion
185
+ //#region lib/types/client/nav-icon.js
186
+ /**
187
+ * Give the "Office" row in the DSH settings navigation a document icon
188
+ * instead of the shell's fallback gear.
189
+ *
190
+ * `settings.section` registrations only carry `id`, `order` and `label`; the
191
+ * shell picks icons for built-in ids only. So the row is marked by its label
192
+ * and CSS swaps the glyph (Lucide file-text, drawn as a currentColor mask so
193
+ * hover / active colors still apply).
194
+ */
195
+ const MARKER = "data-dsh-office-settings-nav";
196
+ const ICON_SVG = "%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3Cpath d='M10 9H8'/%3E%3Cpath d='M16 13H8'/%3E%3Cpath d='M16 17H8'/%3E%3C/svg%3E";
197
+ const navIconCss = `
198
+ [${MARKER}] > svg:first-child { display: none; }
199
+ [${MARKER}]::before {
200
+ content: '';
201
+ flex: none;
202
+ width: 16px;
203
+ height: 16px;
204
+ background: currentColor;
205
+ -webkit-mask: url("data:image/svg+xml,${ICON_SVG}") center / contain no-repeat;
206
+ mask: url("data:image/svg+xml,${ICON_SVG}") center / contain no-repeat;
207
+ }
208
+ `;
209
+ /** Keep the marker on the settings-nav button labelled `label`; returns a disposer. */
210
+ function registerNavIcon(label) {
211
+ const style = document.createElement("style");
212
+ style.setAttribute("data-dsh-office", "nav-icon");
213
+ style.textContent = navIconCss;
214
+ document.head.appendChild(style);
215
+ let disposed = false;
216
+ let frame = 0;
217
+ const sync = () => {
218
+ frame = 0;
219
+ if (disposed) return;
220
+ const dialogs = document.querySelectorAll("[role=\"dialog\"]");
221
+ if (dialogs.length === 0) return;
222
+ for (const dialog of dialogs) for (const button of dialog.querySelectorAll("nav button")) {
223
+ const mine = button.textContent?.trim() === label;
224
+ if (mine && !button.hasAttribute(MARKER)) button.setAttribute(MARKER, "");
225
+ else if (!mine && button.hasAttribute(MARKER)) button.removeAttribute(MARKER);
226
+ }
227
+ };
228
+ sync();
229
+ const observer = new MutationObserver(() => {
230
+ if (frame === 0) frame = requestAnimationFrame(sync);
231
+ });
232
+ observer.observe(document.body, {
233
+ childList: true,
234
+ subtree: true
235
+ });
236
+ return () => {
237
+ disposed = true;
238
+ if (frame !== 0) cancelAnimationFrame(frame);
239
+ observer.disconnect();
240
+ style.remove();
241
+ document.querySelectorAll(`[${MARKER}]`).forEach((element) => element.removeAttribute(MARKER));
242
+ };
243
+ }
244
+ //#endregion
245
+ //#region lib/types/client/index.js
246
+ /**
247
+ * Browser half: the "Office" section in Settings. It reads the plugin's own
248
+ * Loader entry through `remote.settings.describe()` and writes with
249
+ * `remote.settings.mutate()`; every field is volatile and each change saves
250
+ * itself. The live status (installed apps, open documents) comes from the
251
+ * host route in ../index.ts.
252
+ */
253
+ const STATUS_ROUTE = "/api/dsh-office/status";
254
+ const APP_NAMES = {
255
+ word: "Word",
256
+ excel: "Excel",
257
+ ppt: "PowerPoint"
258
+ };
259
+ const WIDTHS = [
260
+ 800,
261
+ 1100,
262
+ 1400,
263
+ 1800
264
+ ];
265
+ const h = react.createElement;
266
+ const S = {
267
+ page: {
268
+ display: "grid",
269
+ gap: 20,
270
+ maxWidth: 880,
271
+ paddingBottom: 32,
272
+ color: "var(--dsw-alias-label-primary, inherit)"
273
+ },
274
+ title: {
275
+ margin: 0,
276
+ fontSize: 20,
277
+ fontWeight: 600
278
+ },
279
+ subtitle: {
280
+ margin: "6px 0 0",
281
+ fontSize: 13,
282
+ lineHeight: 1.6,
283
+ color: "var(--dsw-alias-label-secondary, #666)"
284
+ },
285
+ card: {
286
+ display: "grid",
287
+ gap: 14,
288
+ padding: 16,
289
+ borderRadius: "var(--dsw-radius-lg, 12px)",
290
+ border: "1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.25))"
291
+ },
292
+ cardTitle: {
293
+ margin: 0,
294
+ fontSize: 15,
295
+ fontWeight: 600
296
+ },
297
+ hint: {
298
+ margin: 0,
299
+ fontSize: 12,
300
+ lineHeight: 1.55,
301
+ color: "var(--dsw-alias-label-tertiary, #888)"
302
+ },
303
+ toggleRow: {
304
+ display: "flex",
305
+ gap: 12,
306
+ alignItems: "flex-start",
307
+ justifyContent: "space-between"
308
+ },
309
+ toggleText: {
310
+ display: "grid",
311
+ gap: 4,
312
+ fontSize: 13,
313
+ fontWeight: 500
314
+ },
315
+ error: {
316
+ fontSize: 12,
317
+ color: "var(--dsw-alias-state-error-primary, #d33)"
318
+ },
319
+ ok: {
320
+ fontSize: 12,
321
+ color: "var(--dsw-alias-state-success-primary, #2a2)"
322
+ },
323
+ statusLine: {
324
+ display: "flex",
325
+ gap: 8,
326
+ alignItems: "baseline",
327
+ fontSize: 13
328
+ },
329
+ dot: {
330
+ width: 8,
331
+ height: 8,
332
+ borderRadius: 4,
333
+ flex: "0 0 auto",
334
+ alignSelf: "center"
335
+ },
336
+ chips: {
337
+ display: "flex",
338
+ gap: 8,
339
+ flexWrap: "wrap"
340
+ },
341
+ secondary: {
342
+ padding: "6px 12px",
343
+ fontSize: 12,
344
+ borderRadius: "var(--dsw-radius-md, 8px)",
345
+ border: "1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.3))",
346
+ background: "transparent",
347
+ color: "inherit",
348
+ cursor: "pointer"
349
+ }
350
+ };
351
+ function Switch({ checked, disabled, label, onChange }) {
352
+ return h("button", {
353
+ type: "button",
354
+ role: "switch",
355
+ "aria-checked": checked,
356
+ "aria-label": label,
357
+ disabled,
358
+ onClick: () => onChange(!checked),
359
+ style: {
360
+ flex: "0 0 auto",
361
+ width: 40,
362
+ height: 22,
363
+ borderRadius: 11,
364
+ border: "none",
365
+ padding: 2,
366
+ cursor: disabled ? "default" : "pointer",
367
+ background: checked ? ACCENT : "var(--dsw-alias-border-l3, rgba(127,127,127,.35))",
368
+ opacity: disabled ? .5 : 1,
369
+ transition: "background .15s"
370
+ }
371
+ }, h("span", { style: {
372
+ display: "block",
373
+ width: 18,
374
+ height: 18,
375
+ borderRadius: 9,
376
+ background: checked ? ACCENT_INK : "#fff",
377
+ transform: checked ? "translateX(18px)" : "none",
378
+ transition: "transform .15s"
379
+ } }));
380
+ }
381
+ function OfficeSection({ ctx }) {
382
+ const [loaded, setLoaded] = react.useState(false);
383
+ const [writable, setWritable] = react.useState(false);
384
+ const [view, setView] = react.useState(void 0);
385
+ const [settings, setSettings] = react.useState(DEFAULTS);
386
+ const [message, setMessage] = react.useState(null);
387
+ const [status, setStatus] = react.useState(null);
388
+ const [checking, setChecking] = react.useState(false);
389
+ const load = react.useCallback(async () => {
390
+ try {
391
+ const result = await ctx.remote.settings.describe();
392
+ if (!result.ok) throw new Error(result.error.message);
393
+ const found = result.value.namespaces.find((item) => item.ns === ENTRY_ID);
394
+ setWritable(result.value.writable);
395
+ setView(found);
396
+ setSettings(resolveConfig(found?.value));
397
+ } catch (error) {
398
+ setMessage({
399
+ kind: "error",
400
+ text: `读取设置失败:${error.message}`
401
+ });
402
+ } finally {
403
+ setLoaded(true);
404
+ }
405
+ }, [ctx]);
406
+ const refreshStatus = react.useCallback(async () => {
407
+ setChecking(true);
408
+ try {
409
+ const response = await fetch(STATUS_ROUTE, { credentials: "same-origin" });
410
+ setStatus(await response.json());
411
+ } catch {
412
+ setStatus({
413
+ ok: false,
414
+ error: "无法连接到插件"
415
+ });
416
+ } finally {
417
+ setChecking(false);
418
+ }
419
+ }, []);
420
+ react.useEffect(() => {
421
+ load();
422
+ refreshStatus();
423
+ return ctx.remote.$on?.("settings/document-updated", (ns) => {
424
+ if (ns === "copylee-office") load();
425
+ }) ?? void 0;
426
+ }, [
427
+ ctx,
428
+ load,
429
+ refreshStatus
430
+ ]);
431
+ const disabled = !writable || view === void 0;
432
+ const change = async (key, value) => {
433
+ if (view === void 0) return;
434
+ setSettings((previous) => ({
435
+ ...previous,
436
+ [key]: value
437
+ }));
438
+ try {
439
+ const result = await ctx.remote.settings.mutate(ENTRY_ID, [{
440
+ op: "set",
441
+ path: [key],
442
+ value
443
+ }], view.revision);
444
+ if (!result.ok) throw new Error(result.error.message);
445
+ setMessage({
446
+ kind: "ok",
447
+ text: "已保存,下一次调用即生效"
448
+ });
449
+ } catch (error) {
450
+ setMessage({
451
+ kind: "error",
452
+ text: `保存失败:${error.message}`
453
+ });
454
+ }
455
+ await load();
456
+ };
457
+ if (!loaded) return h("div", { style: S.page }, "加载中…");
458
+ return h("div", { style: S.page }, h("div", null, h("h2", { style: S.title }, "Office"), h("p", { style: S.subtitle }, "AI 直接在你电脑上打开的 Word、Excel、PowerPoint 里改文档:改动实时出现在窗口中,你可以同时编辑;窗口不必在前台。只有调用保存时才写入文件。")), view === void 0 ? h("div", { style: S.error }, "没有找到本插件的设置项(插件可能未启用)。") : null, view !== void 0 && !writable ? h("div", { style: S.error }, "当前设置为只读。") : null, h("section", { style: S.card }, h("div", { style: {
459
+ display: "flex",
460
+ justifyContent: "space-between",
461
+ alignItems: "center"
462
+ } }, h("h3", { style: S.cardTitle }, "应用状态"), h("button", {
463
+ type: "button",
464
+ style: S.secondary,
465
+ disabled: checking,
466
+ onClick: () => {
467
+ refreshStatus();
468
+ }
469
+ }, checking ? "检查中…" : "重新检查")), status === null ? h("p", { style: S.hint }, "检查中…") : !status.ok ? h("div", { style: S.error }, `控制组件不可用:${status.error ?? ""}`) : (status.apps ?? []).map((app) => h("div", {
470
+ key: app.app,
471
+ style: S.statusLine
472
+ }, h("span", { style: {
473
+ ...S.dot,
474
+ background: !app.installed ? "#999" : app.running ? "#2a2" : ACCENT
475
+ } }), h("span", { style: {
476
+ minWidth: 86,
477
+ fontWeight: 500
478
+ } }, APP_NAMES[app.app]), h("span", { style: {
479
+ ...S.hint,
480
+ fontSize: 13
481
+ } }, !app.installed ? "未安装" : !app.running ? "已安装,未运行(需要时自动启动)" : app.documents.length === 0 ? "运行中,没有打开的文档" : `运行中:${app.documents.map((doc) => doc.name + (doc.saved ? "" : "(未保存)")).join("、")}`))), h("p", { style: S.hint }, "需要本机装有微软 Office。Word 里 AI 的一批修改可以用一次 Ctrl+Z 撤回;Excel 和 PowerPoint 的修改无法用 Ctrl+Z 撤回。你正在单元格里输入或开着对话框时,AI 会等你。")), h("section", { style: S.card }, h("h3", { style: S.cardTitle }, "行为"), h("div", { style: S.toggleRow }, h("div", { style: S.toggleText }, "打开文档时显示到前台", h("span", { style: {
482
+ ...S.hint,
483
+ fontWeight: 400
484
+ } }, "关闭后文档仍会打开,只是不抢你当前的窗口。")), h(Switch, {
485
+ checked: settings.showOnOpen,
486
+ disabled,
487
+ label: "打开文档时显示到前台",
488
+ onChange: (value) => {
489
+ change("showOnOpen", value);
490
+ }
491
+ })), h("div", { style: {
492
+ display: "grid",
493
+ gap: 8
494
+ } }, h("div", { style: S.toggleText }, "核对排版的图片宽度", h("span", { style: {
495
+ ...S.hint,
496
+ fontWeight: 400
497
+ } }, "AI 改完后会把页面 / 幻灯片渲染成图片检查排版。越大越清晰,也越费 token。")), h("div", { style: S.chips }, WIDTHS.map((width) => h("button", {
498
+ key: width,
499
+ type: "button",
500
+ disabled,
501
+ onClick: () => {
502
+ change("renderWidth", width);
503
+ },
504
+ style: {
505
+ ...S.secondary,
506
+ ...settings.renderWidth === width ? {
507
+ background: ACCENT,
508
+ borderColor: ACCENT,
509
+ color: ACCENT_INK
510
+ } : {}
511
+ }
512
+ }, `${width} 像素${width === DEFAULTS.renderWidth ? "(默认)" : ""}`)))), message === null ? null : h("span", { style: message.kind === "ok" ? S.ok : S.error }, message.text)), h(AccentPicker, null));
513
+ }
514
+ const inject = [
515
+ "slots",
516
+ "remote",
517
+ "remote.settings"
518
+ ];
519
+ function apply(ctx) {
520
+ ctx.effect(() => installAccent(), "office: accent colour");
521
+ ctx.effect(() => registerNavIcon("Office"), "office: settings nav icon");
522
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
523
+ name: "settings.section",
524
+ id: ENTRY_ID,
525
+ order: 62,
526
+ label: () => "Office"
527
+ }, () => h(OfficeSection, { ctx })));
528
+ }
529
+ //#endregion
530
+ exports.apply = apply;
531
+ exports.inject = inject;
532
+ return module.exports;
533
+ }
534
+ });