@aseity/idocs-preview 0.3.0 → 0.5.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.
@@ -0,0 +1,1121 @@
1
+ import { mountEditor, } from './editor.js';
2
+ import { decodeImages, paintPage } from './index.js';
3
+ import { ensureWorkspaceStyles } from './workspace-styles.js';
4
+ const SVG_NS = 'http://www.w3.org/2000/svg';
5
+ const ICONS = {
6
+ rail: '<path d="M6.835 4c-.451.004-.82.012-1.137.038-.386.032-.659.085-.876.162l-.2.086c-.44.224-.807.564-1.063.982l-.103.184c-.126.247-.206.562-.248 1.076-.043.523-.043 1.19-.043 2.135v2.664c0 .944 0 1.612.043 2.135.042.515.122.829.248 1.076l.103.184c.256.418.624.758 1.063.982l.2.086c.217.077.49.13.876.162.316.026.685.034 1.136.038zm11.33 7.327c0 .922 0 1.654-.048 2.243-.043.522-.125.977-.305 1.395l-.082.177a4 4 0 0 1-1.473 1.593l-.276.155c-.465.237-.974.338-1.57.387-.59.048-1.322.048-2.244.048H7.833c-.922 0-1.654 0-2.243-.048-.522-.042-.977-.126-1.395-.305l-.176-.082a4 4 0 0 1-1.594-1.473l-.154-.275c-.238-.466-.34-.975-.388-1.572-.048-.589-.048-1.32-.048-2.243V8.663c0-.922 0-1.654.048-2.243.049-.597.15-1.106.388-1.571l.154-.276a4 4 0 0 1 1.594-1.472l.176-.083c.418-.18.873-.263 1.395-.305.589-.048 1.32-.048 2.243-.048h4.334c.922 0 1.654 0 2.243.048.597.049 1.106.15 1.571.388l.276.154a4 4 0 0 1 1.473 1.594l.082.176c.18.418.262.873.305 1.395.048.589.048 1.32.048 2.243zm-10 4.668h4.002c.944 0 1.612 0 2.135-.043.514-.042.829-.122 1.076-.248l.184-.103c.418-.256.758-.624.982-1.063l.086-.2c.077-.217.13-.49.162-.876.043-.523.043-1.19.043-2.135V8.663c0-.944 0-1.612-.043-2.135-.032-.386-.085-.659-.162-.876l-.086-.2a2.67 2.67 0 0 0-.982-1.063l-.184-.103c-.247-.126-.562-.206-1.076-.248-.523-.043-1.19-.043-2.135-.043H8.164L8.165 4z"/>',
7
+ undo: '<path d="M15.916 10.833a4.25 4.25 0 0 0-4.25-4.25H5.144l2.053 2.054.051.056a.75.75 0 0 1-1.054 1.055l-.058-.05-3.333-3.335a.75.75 0 0 1 0-1.06L6.136 1.97l.058-.052a.75.75 0 0 1 1.054 1.056l-.051.056-2.053 2.053h6.522a5.75 5.75 0 0 1 0 11.5H8.333a.75.75 0 0 1 0-1.5h3.333a4.25 4.25 0 0 0 4.25-4.25"/>',
8
+ redo: '<path d="M4.084 10.833a4.25 4.25 0 0 1 4.25-4.25h6.522l-2.053 2.054-.051.056a.75.75 0 0 0 1.054 1.055l.058-.05 3.333-3.335a.75.75 0 0 0 0-1.06L13.864 1.97l-.058-.052a.75.75 0 0 0-1.054 1.056l.051.056 2.053 2.053H8.334a5.75 5.75 0 0 0 0 11.5h3.333a.75.75 0 1 0 0-1.5H8.334a4.25 4.25 0 0 1-4.25-4.25"/>',
9
+ bold: '<path d="M6.33 10h4.17a3 3 0 0 0 0-6H6.33v12H11a3 3 0 0 0 0-6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
10
+ italic: '<path d="M8 4h6M6 16h6M12 4L8 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
11
+ underline: '<path d="M5.67 3.33v6a4.33 4.33 0 0 0 8.66 0v-6M4.67 17h10.66" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
12
+ strike: '<path d="M14 5.33c-.67-1.17-2.04-1.83-4-1.83-2.33 0-4 1.17-4 3 0 1.67 1.5 2.67 4 3.5s4 1.83 4 3.5c0 1.83-1.67 3-4 3-1.96 0-3.33-.66-4-1.83M4.33 10h11.34" fill="none" stroke="currentColor" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round"/>',
13
+ align: '<path d="M3 5h14M3 10h10M3 15h12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
14
+ zoomOut: '<path d="M4.5 10h11" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
15
+ zoomIn: '<path d="M10 4.5v11M4.5 10h11" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
16
+ fit: '<path d="M4.335 11a.665.665 0 0 1 1.33 0v3.335H9l.134.014a.665.665 0 0 1 0 1.302L9 15.665H5A.665.665 0 0 1 4.335 15zm10-2V5.665H11a.665.665 0 0 1 0-1.33h4l.134.014c.303.062.531.33.531.651v4a.665.665 0 1 1-1.33 0"/>',
17
+ search: '<circle cx="8.8" cy="8.8" r="5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12.6 12.6l3.8 3.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>',
18
+ paragraph: '<path d="M6.75 4.75h10M3.25 8.25h13.5M3.25 11.75h13.5M3.25 15.25h8.5"/>',
19
+ heading: '<path d="M5 4v12M15 4v12M5 10h10"/>',
20
+ table: '<rect x="3" y="4" width="14" height="12" rx="2"/><path d="M3 8h14M3 12h14M10 8v8"/>',
21
+ callout: '<rect x="3" y="3.5" width="14" height="13" rx="2.25"/><path d="M7 7v3M10.5 8h3M10.5 12h3"/><circle cx="7" cy="12.75" r="0.75" fill="currentColor" stroke="none"/>',
22
+ image: '<rect x="3" y="3" width="14" height="14" rx="2.25"/><circle cx="12.75" cy="7.25" r="1.5"/><path d="m3 13 3.5-3.5a1.4 1.4 0 0 1 2 0l7.1 7.1"/>',
23
+ pageBreak: '<path d="M4 7V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2M4 13v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2M3 10h2m4 0h2m4 0h2"/>',
24
+ chevronDown: '<path d="M15.78 7.404a.666.666 0 0 1 .94.942L10.766 14.3a1.08 1.08 0 0 1-1.53 0L3.28 8.346a.666.666 0 0 1 .942-.942L10 13.184z"/>',
25
+ check: '<path d="M14.445 5.038a.665.665 0 0 1 1.1.748l-6.14 9.03a.815.815 0 0 1-1.25.117L4.528 11.3a.665.665 0 0 1 .942-.94l3.184 3.192z"/>',
26
+ ask: '<path fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="1.45" d="M10 16.83c4.004 0 7.25-3.058 7.25-6.83S14.004 3.17 10 3.17 2.75 6.228 2.75 10a6.55 6.55 0 0 0 1.484 4.141c-.147.781-.39 1.537-.678 2.287a13.5 13.5 0 0 0 2.877-.48A7.56 7.56 0 0 0 10 16.83Z"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.45" d="M5.888 9C6.278 7.575 7.478 6.457 9 6.11"/>',
27
+ };
28
+ export function formatAskAssistantLabel(assistantName) {
29
+ const resolved = assistantName?.trim() || 'AI';
30
+ return /^[\x00-\x7f]/.test(resolved) ? `询问 ${resolved}` : `询问${resolved}`;
31
+ }
32
+ function makeSvgIcon(markup, size = 16) {
33
+ const svg = document.createElementNS(SVG_NS, 'svg');
34
+ svg.setAttribute('width', String(size));
35
+ svg.setAttribute('height', String(size));
36
+ svg.setAttribute('viewBox', '0 0 20 20');
37
+ svg.setAttribute('fill', 'currentColor');
38
+ svg.setAttribute('aria-hidden', 'true');
39
+ svg.innerHTML = markup;
40
+ return svg;
41
+ }
42
+ function el(tag, className, text) {
43
+ const node = document.createElement(tag);
44
+ if (className)
45
+ node.className = className;
46
+ if (text !== undefined)
47
+ node.textContent = text;
48
+ return node;
49
+ }
50
+ function mountToolbarScroller(toolbar, history, zoom, onScrollOrResize) {
51
+ const center = el('div', 'idocs-ws-tb-center');
52
+ const viewport = el('div', 'idocs-ws-toolbar-viewport');
53
+ const strip = el('div', 'idocs-ws-toolbar-strip');
54
+ const content = el('div', 'idocs-ws-toolbar-content');
55
+ const scrollButton = (direction, label) => {
56
+ const button = el('button', 'idocs-ws-icon-btn idocs-ws-toolbar-arrow');
57
+ button.type = 'button';
58
+ button.hidden = true;
59
+ button.title = label;
60
+ button.setAttribute('aria-label', label);
61
+ button.append(makeSvgIcon(ICONS.chevronDown, 14));
62
+ button.addEventListener('mousedown', (event) => event.preventDefault());
63
+ button.addEventListener('click', () => {
64
+ viewport.scrollBy({
65
+ left: direction * viewport.clientWidth * 0.75,
66
+ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth',
67
+ });
68
+ });
69
+ return button;
70
+ };
71
+ const left = scrollButton(-1, '向左滚动格式工具栏');
72
+ const right = scrollButton(1, '向右滚动格式工具栏');
73
+ strip.append(content);
74
+ viewport.append(strip);
75
+ center.append(left, viewport, right);
76
+ toolbar.append(history, center, zoom);
77
+ const syncEdges = () => {
78
+ left.disabled = viewport.scrollLeft <= 1;
79
+ right.disabled = viewport.scrollLeft + viewport.clientWidth >= viewport.scrollWidth - 1;
80
+ onScrollOrResize();
81
+ };
82
+ let compact = false;
83
+ const resize = new ResizeObserver(() => {
84
+ const { paddingLeft, paddingRight, columnGap } = getComputedStyle(toolbar);
85
+ const innerWidth = toolbar.clientWidth - parseFloat(paddingLeft) - parseFloat(paddingRight);
86
+ const available = innerWidth - history.offsetWidth - zoom.offsetWidth - 2 * parseFloat(columnGap);
87
+ const nextCompact = available < Math.min(144, content.offsetWidth);
88
+ if (nextCompact !== compact) {
89
+ compact = nextCompact;
90
+ if (compact) {
91
+ strip.prepend(history);
92
+ strip.append(zoom);
93
+ }
94
+ else {
95
+ toolbar.prepend(history);
96
+ toolbar.append(zoom);
97
+ }
98
+ }
99
+ const overflow = strip.offsetWidth > center.clientWidth;
100
+ left.hidden = !overflow;
101
+ right.hidden = !overflow;
102
+ syncEdges();
103
+ });
104
+ for (const element of [toolbar, content, history, zoom])
105
+ resize.observe(element);
106
+ viewport.addEventListener('scroll', syncEdges, { passive: true });
107
+ return {
108
+ content,
109
+ viewport,
110
+ destroy() {
111
+ resize.disconnect();
112
+ viewport.removeEventListener('scroll', syncEdges);
113
+ },
114
+ };
115
+ }
116
+ function headingLevelFromStyle(style) {
117
+ switch (style) {
118
+ case 'heading1':
119
+ return 1;
120
+ case 'heading2':
121
+ return 2;
122
+ case 'heading3':
123
+ return 3;
124
+ default:
125
+ return null;
126
+ }
127
+ }
128
+ export function extractOutline(doc) {
129
+ const entries = [];
130
+ let lastPath = '';
131
+ for (let pageIndex = 0; pageIndex < doc.pages.length; pageIndex += 1) {
132
+ for (const line of doc.pages[pageIndex].lines) {
133
+ if (line.story !== 'body')
134
+ continue;
135
+ const seg = line.sourceSegments?.find((s) => s.source.kind === 'editable');
136
+ if (!seg || seg.source.kind !== 'editable')
137
+ continue;
138
+ const level = headingLevelFromStyle(seg.source.blockStyle);
139
+ if (!level)
140
+ continue;
141
+ const fragment = line.sourceSegments
142
+ ?.filter((s) => s.source.kind === 'editable')
143
+ .map((s) => line.text.slice(s.start, s.end))
144
+ .join('')
145
+ .trim();
146
+ const piece = fragment || line.text.trim();
147
+ if (entries.length > 0 && line.path === lastPath) {
148
+ const prev = entries[entries.length - 1];
149
+ entries[entries.length - 1] = {
150
+ ...prev,
151
+ text: piece ? `${prev.text}${prev.text ? ' ' : ''}${piece}` : prev.text,
152
+ };
153
+ }
154
+ else {
155
+ lastPath = line.path;
156
+ entries.push({
157
+ path: line.path,
158
+ level,
159
+ text: piece || `标题 ${entries.length + 1}`,
160
+ page: pageIndex,
161
+ anchor: seg.source.stops[0]?.anchor,
162
+ });
163
+ }
164
+ }
165
+ }
166
+ return entries;
167
+ }
168
+ export function isTableCellPath(path) {
169
+ return /\.rows\.\d+\.cells\.\d+(?:\.|$)/.test(path);
170
+ }
171
+ function findEditableLineMeta(doc, page, flow) {
172
+ const targetPage = doc?.pages[page];
173
+ if (!targetPage)
174
+ return {};
175
+ for (const line of targetPage.lines) {
176
+ for (const seg of line.sourceSegments ?? []) {
177
+ if (seg.source.kind === 'editable' && seg.source.stops.some((stop) => stop.anchor.flow === flow)) {
178
+ return {
179
+ blockStyle: seg.source.blockStyle,
180
+ align: seg.source.align,
181
+ };
182
+ }
183
+ }
184
+ }
185
+ return {};
186
+ }
187
+ export function resolveToolbarContext(input) {
188
+ if (input.readOnly) {
189
+ const processing = input.processing ?? Boolean(input.busyLabel);
190
+ return {
191
+ kind: 'readOnly',
192
+ processing,
193
+ label: input.busyLabel || (processing ? '正在修订文档' : '只读'),
194
+ };
195
+ }
196
+ if (input.readOnlyTarget) {
197
+ return { kind: 'readOnlyTarget', target: input.readOnlyTarget };
198
+ }
199
+ if (input.selectedImage) {
200
+ return { kind: 'image', target: input.selectedImage };
201
+ }
202
+ if (input.selection) {
203
+ const { range } = input.selection.selection;
204
+ const collapsed = range.start.flow === range.end.flow && range.start.offset === range.end.offset;
205
+ const meta = findEditableLineMeta(input.document, input.selection.page, range.start.flow);
206
+ return {
207
+ kind: 'editable',
208
+ inTable: isTableCellPath(input.selection.path),
209
+ blockStyle: meta.blockStyle,
210
+ align: meta.align,
211
+ collapsed,
212
+ };
213
+ }
214
+ return { kind: 'empty' };
215
+ }
216
+ function sameImageList(a, b) {
217
+ if (!a || a.length !== b.length)
218
+ return false;
219
+ for (let i = 0; i < a.length; i += 1) {
220
+ if (a[i].missing !== b[i].missing ||
221
+ a[i].format !== b[i].format ||
222
+ a[i].washout !== b[i].washout ||
223
+ a[i].bytes !== b[i].bytes) {
224
+ return false;
225
+ }
226
+ }
227
+ return true;
228
+ }
229
+ function toolbarRenderKey(ctx, tableModePreferred) {
230
+ switch (ctx.kind) {
231
+ case 'readOnly':
232
+ return `ro:${ctx.processing}:${ctx.label}`;
233
+ case 'editable':
234
+ if (ctx.inTable && tableModePreferred)
235
+ return 'table';
236
+ return `ed:${ctx.inTable}:${ctx.blockStyle ?? ''}:${ctx.align ?? ''}:${ctx.collapsed}`;
237
+ case 'empty':
238
+ return 'empty';
239
+ case 'readOnlyTarget':
240
+ case 'image':
241
+ return null;
242
+ }
243
+ }
244
+ export function mountEditorWorkspace(container, session, options = {}) {
245
+ ensureWorkspaceStyles();
246
+ let readOnly = options.readOnly === true;
247
+ let busyLabel = options.busyLabel;
248
+ let processing = readOnly && (options.processing ?? Boolean(options.busyLabel));
249
+ let railOpen = options.railOpen ?? false;
250
+ let railTab = 'pages';
251
+ let tableModePreferred = false;
252
+ let lastToolbarKey = '';
253
+ let lastOutlinePath;
254
+ let activeDoc;
255
+ let renderedRailDoc;
256
+ let renderedRailTab;
257
+ let thumbImages = [];
258
+ let lastThumbImages = [];
259
+ let destroyed = false;
260
+ const root = el('div', 'idocs-ws');
261
+ root.classList.toggle('is-readonly', readOnly);
262
+ root.classList.toggle('is-processing', processing);
263
+ const toolbar = el('nav', 'idocs-ws-toolbar');
264
+ toolbar.setAttribute('aria-label', '文档格式工具栏');
265
+ const tbLeft = el('div', 'idocs-ws-tb-left');
266
+ const tbRight = el('div', 'idocs-ws-tb-right');
267
+ let openToolbarMenu;
268
+ const closeToolbarMenu = () => {
269
+ if (!openToolbarMenu)
270
+ return;
271
+ openToolbarMenu.trigger.setAttribute('aria-expanded', 'false');
272
+ openToolbarMenu.menu.hidden = true;
273
+ openToolbarMenu = undefined;
274
+ };
275
+ const positionToolbarMenu = () => {
276
+ if (!openToolbarMenu)
277
+ return;
278
+ const { trigger, menu } = openToolbarMenu;
279
+ const anchor = trigger.getBoundingClientRect();
280
+ const visible = toolbarScroller.viewport.getBoundingClientRect();
281
+ if (anchor.right <= visible.left || anchor.left >= visible.right) {
282
+ closeToolbarMenu();
283
+ return;
284
+ }
285
+ const frame = toolbar.getBoundingClientRect();
286
+ const gutter = 4;
287
+ const anchorLeft = Math.max(anchor.left, visible.left) - frame.left;
288
+ menu.style.left = `${Math.max(gutter, Math.min(anchorLeft, toolbar.clientWidth - menu.offsetWidth - gutter))}px`;
289
+ menu.style.top = `${anchor.bottom - frame.top + 6}px`;
290
+ };
291
+ const toolbarScroller = mountToolbarScroller(toolbar, tbLeft, tbRight, positionToolbarMenu);
292
+ const tbContent = toolbarScroller.content;
293
+ const toggleToolbarMenu = (trigger, menu, open) => {
294
+ closeAllPopovers();
295
+ if (!open)
296
+ return;
297
+ trigger.setAttribute('aria-expanded', 'true');
298
+ menu.hidden = false;
299
+ openToolbarMenu = { trigger, menu };
300
+ positionToolbarMenu();
301
+ };
302
+ const railToggleBtn = el('button', 'idocs-ws-icon-btn');
303
+ railToggleBtn.type = 'button';
304
+ const initialRailLabel = railOpen ? '收起页面列表' : '展开页面列表';
305
+ railToggleBtn.title = initialRailLabel;
306
+ railToggleBtn.setAttribute('aria-label', initialRailLabel);
307
+ railToggleBtn.setAttribute('aria-expanded', String(railOpen));
308
+ railToggleBtn.append(makeSvgIcon(ICONS.rail, 16));
309
+ const undoBtn = el('button', 'idocs-ws-icon-btn');
310
+ undoBtn.type = 'button';
311
+ undoBtn.title = '撤销';
312
+ undoBtn.setAttribute('aria-label', '撤销');
313
+ undoBtn.append(makeSvgIcon(ICONS.undo, 16));
314
+ const redoBtn = el('button', 'idocs-ws-icon-btn');
315
+ redoBtn.type = 'button';
316
+ redoBtn.title = '重做';
317
+ redoBtn.setAttribute('aria-label', '重做');
318
+ redoBtn.append(makeSvgIcon(ICONS.redo, 16));
319
+ tbLeft.append(railToggleBtn, undoBtn, redoBtn);
320
+ const zoomOutBtn = el('button', 'idocs-ws-icon-btn');
321
+ zoomOutBtn.type = 'button';
322
+ zoomOutBtn.title = '缩小';
323
+ zoomOutBtn.setAttribute('aria-label', '缩小');
324
+ zoomOutBtn.append(makeSvgIcon(ICONS.zoomOut));
325
+ const zoomLabel = el('span', 'idocs-ws-zoom-label', '100%');
326
+ const zoomInBtn = el('button', 'idocs-ws-icon-btn');
327
+ zoomInBtn.type = 'button';
328
+ zoomInBtn.title = '放大';
329
+ zoomInBtn.setAttribute('aria-label', '放大');
330
+ zoomInBtn.append(makeSvgIcon(ICONS.zoomIn));
331
+ const fitWidthBtn = el('button', 'idocs-ws-icon-btn');
332
+ fitWidthBtn.type = 'button';
333
+ fitWidthBtn.title = '适应宽度';
334
+ fitWidthBtn.setAttribute('aria-label', '适应宽度');
335
+ fitWidthBtn.append(makeSvgIcon(ICONS.fit, 18));
336
+ const findBtn = el('button', 'idocs-ws-icon-btn');
337
+ findBtn.type = 'button';
338
+ findBtn.title = '查找';
339
+ findBtn.setAttribute('aria-label', '查找');
340
+ findBtn.append(makeSvgIcon(ICONS.search));
341
+ const zoomDivider = el('span', 'idocs-ws-divider');
342
+ zoomDivider.setAttribute('aria-hidden', 'true');
343
+ tbRight.append(zoomOutBtn, zoomLabel, zoomInBtn, zoomDivider, fitWidthBtn, findBtn);
344
+ const body = el('div', 'idocs-ws-body');
345
+ body.classList.toggle('is-rail-collapsed', !railOpen);
346
+ const railSlot = el('div', 'idocs-ws-rail-slot');
347
+ const rail = el('aside', 'idocs-ws-rail');
348
+ if ('inert' in rail)
349
+ rail.inert = !railOpen;
350
+ rail.setAttribute('aria-hidden', String(!railOpen));
351
+ const railTabs = el('div', 'idocs-ws-rail-tabs');
352
+ railTabs.setAttribute('role', 'tablist');
353
+ const pagesTabBtn = el('button', 'idocs-ws-rail-tab', '页面');
354
+ pagesTabBtn.type = 'button';
355
+ pagesTabBtn.setAttribute('role', 'tab');
356
+ pagesTabBtn.setAttribute('aria-selected', 'true');
357
+ const outlineTabBtn = el('button', 'idocs-ws-rail-tab', '大纲');
358
+ outlineTabBtn.type = 'button';
359
+ outlineTabBtn.setAttribute('role', 'tab');
360
+ outlineTabBtn.setAttribute('aria-selected', 'false');
361
+ railTabs.append(pagesTabBtn, outlineTabBtn);
362
+ const railList = el('div', 'idocs-ws-rail-list');
363
+ rail.append(railTabs, railList);
364
+ const stage = el('div', 'idocs-ws-stage');
365
+ const editorHost = el('div', 'idocs-ws-editor-host');
366
+ const askLabel = formatAskAssistantLabel(options.assistantName);
367
+ const selPill = el('div', 'idocs-ws-sel-pill');
368
+ selPill.hidden = true;
369
+ const pillQuoteBtn = options.onQuoteSelection ? el('button', 'idocs-ws-sel-pill-btn') : null;
370
+ if (pillQuoteBtn) {
371
+ pillQuoteBtn.type = 'button';
372
+ const askSvg = makeSvgIcon(ICONS.ask, 16);
373
+ askSvg.classList.add('idocs-ws-sel-pill-icon');
374
+ pillQuoteBtn.append(askSvg, el('span', undefined, askLabel));
375
+ pillQuoteBtn.addEventListener('mousedown', (event) => event.preventDefault());
376
+ selPill.append(pillQuoteBtn);
377
+ }
378
+ const dock = el('nav', 'idocs-ws-dock');
379
+ dock.setAttribute('aria-label', '插入内容');
380
+ dock.hidden = readOnly;
381
+ const insertButtons = [];
382
+ const makeDockButton = (icon, label, activate, popup) => {
383
+ const container = el('span');
384
+ const className = 'idocs-ws-dock-btn';
385
+ const svg = makeSvgIcon(icon, 20);
386
+ const bind = (button) => {
387
+ if (!button)
388
+ return;
389
+ if (popup)
390
+ button.setAttribute('aria-expanded', String(!popup.hidden));
391
+ button.append(svg);
392
+ };
393
+ if (options.mountInsertButtons) {
394
+ insertButtons.push({ container, label, className, activate, bind });
395
+ }
396
+ else {
397
+ const button = el('button', className);
398
+ button.type = 'button';
399
+ button.title = label;
400
+ button.setAttribute('aria-label', label);
401
+ button.addEventListener('click', activate);
402
+ bind(button);
403
+ container.append(button);
404
+ }
405
+ return container;
406
+ };
407
+ const tableAnchor = el('div', 'idocs-ws-popover-anchor');
408
+ const tablePopover = el('div', 'idocs-ws-popover is-up idocs-ws-table-popover');
409
+ tablePopover.hidden = true;
410
+ const tableGridHead = el('div', 'idocs-ws-table-grid-head', '3 × 3 表格');
411
+ const tableGrid = el('div', 'idocs-ws-table-grid');
412
+ const tableCells = [];
413
+ let hoverRows = 3;
414
+ let hoverCols = 3;
415
+ for (let r = 1; r <= 6; r += 1) {
416
+ for (let c = 1; c <= 6; c += 1) {
417
+ const cell = el('button', 'idocs-ws-table-cell');
418
+ cell.type = 'button';
419
+ cell.dataset.rows = String(r);
420
+ cell.dataset.cols = String(c);
421
+ cell.setAttribute('aria-label', `${r} 行 ${c} 列`);
422
+ tableCells.push(cell);
423
+ tableGrid.append(cell);
424
+ }
425
+ }
426
+ tablePopover.append(tableGridHead, tableGrid);
427
+ tableAnchor.append(tablePopover);
428
+ stage.append(editorHost, selPill, dock);
429
+ railSlot.append(rail);
430
+ body.append(railSlot, stage);
431
+ root.append(toolbar, body);
432
+ container.append(root);
433
+ const paintTableGrid = (rows, cols) => {
434
+ hoverRows = rows;
435
+ hoverCols = cols;
436
+ tableGridHead.textContent = `${rows} × ${cols} 表格`;
437
+ for (const cell of tableCells) {
438
+ const r = Number(cell.dataset.rows);
439
+ const c = Number(cell.dataset.cols);
440
+ cell.classList.toggle('is-hot', r <= rows && c <= cols);
441
+ }
442
+ };
443
+ paintTableGrid(3, 3);
444
+ const setTablePopoverOpen = (open) => {
445
+ tablePopover.hidden = !open;
446
+ tableAnchor.querySelector('.idocs-ws-dock-btn')?.setAttribute('aria-expanded', String(open));
447
+ };
448
+ const closeAllPopovers = () => {
449
+ setTablePopoverOpen(false);
450
+ closeToolbarMenu();
451
+ };
452
+ let view;
453
+ const syncZoom = () => {
454
+ zoomLabel.textContent = `${Math.round(view.zoom * 100)}%`;
455
+ };
456
+ const updatePill = (sel) => {
457
+ if (readOnly ||
458
+ view?.dragging ||
459
+ !sel ||
460
+ !sel.clientRect ||
461
+ !pillQuoteBtn ||
462
+ !sel.excerpt?.trim()) {
463
+ selPill.hidden = true;
464
+ return;
465
+ }
466
+ selPill.hidden = false;
467
+ const stageRect = stage.getBoundingClientRect();
468
+ const pillWidth = selPill.offsetWidth || 92;
469
+ const pillHeight = selPill.offsetHeight || 32;
470
+ const left = Math.max(8, Math.min(stageRect.width - pillWidth - 8, sel.clientRect.left - stageRect.left + sel.clientRect.width / 2 - pillWidth / 2));
471
+ const top = Math.max(8, sel.clientRect.top - stageRect.top - pillHeight - 8);
472
+ selPill.style.left = `${Math.round(left)}px`;
473
+ selPill.style.top = `${Math.round(top)}px`;
474
+ };
475
+ const renderToolbarCenter = () => {
476
+ const sel = view.selection();
477
+ const ctx = resolveToolbarContext({
478
+ readOnly,
479
+ processing,
480
+ busyLabel,
481
+ readOnlyTarget: view.readOnlyTarget(),
482
+ selectedImage: view.selectedImage(),
483
+ selection: sel,
484
+ document: activeDoc,
485
+ });
486
+ const nextKey = toolbarRenderKey(ctx, tableModePreferred);
487
+ if (nextKey !== null && nextKey === lastToolbarKey) {
488
+ return;
489
+ }
490
+ lastToolbarKey = nextKey ?? '';
491
+ closeToolbarMenu();
492
+ tbContent.replaceChildren();
493
+ if (ctx.kind === 'readOnly') {
494
+ if (ctx.processing) {
495
+ const status = el('span', 'idocs-ws-processing-indicator');
496
+ status.setAttribute('role', 'status');
497
+ status.setAttribute('aria-label', ctx.label);
498
+ status.title = ctx.label;
499
+ const ring = el('span', 'idocs-ws-spinner');
500
+ ring.setAttribute('aria-hidden', 'true');
501
+ status.append(ring);
502
+ tbContent.append(status);
503
+ }
504
+ return;
505
+ }
506
+ if (ctx.kind === 'readOnlyTarget') {
507
+ const box = el('div', 'idocs-ws-ctx is-readonly-target');
508
+ box.append(el('span', 'idocs-ws-badge is-warn', '此内容暂不支持编辑'));
509
+ if (options.onQuoteSelection && ctx.target.excerpt?.trim()) {
510
+ const quoteBtn = el('button', 'idocs-ws-pill-btn');
511
+ quoteBtn.type = 'button';
512
+ quoteBtn.append(makeSvgIcon(ICONS.ask, 15), el('span', undefined, askLabel));
513
+ quoteBtn.addEventListener('click', () => {
514
+ const target = view.readOnlyTarget();
515
+ if (!target?.excerpt?.trim())
516
+ return;
517
+ options.onQuoteSelection?.({ text: target.excerpt, path: target.path, page: target.page });
518
+ });
519
+ box.append(quoteBtn);
520
+ }
521
+ const clearBtn = el('button', 'idocs-ws-pill-btn', '取消选中');
522
+ clearBtn.type = 'button';
523
+ clearBtn.addEventListener('click', () => {
524
+ view.clearReadOnlyTarget();
525
+ view.focus();
526
+ });
527
+ box.append(clearBtn);
528
+ tbContent.append(box);
529
+ return;
530
+ }
531
+ if (ctx.kind === 'image') {
532
+ const box = el('div', 'idocs-ws-ctx');
533
+ box.append(el('span', 'idocs-ws-badge', '插图'));
534
+ for (const ratio of [0.5, 0.75, 1]) {
535
+ const btn = el('button', 'idocs-ws-pill-btn', `宽度 ${Math.round(ratio * 100)}%`);
536
+ btn.type = 'button';
537
+ btn.addEventListener('click', () => {
538
+ void view.editImage(ctx.target.path, { widthRatio: ratio });
539
+ });
540
+ box.append(btn);
541
+ }
542
+ box.append(el('span', 'idocs-ws-divider'));
543
+ for (const [align, label] of [
544
+ ['left', '左对齐'],
545
+ ['center', '居中'],
546
+ ['right', '右对齐'],
547
+ ]) {
548
+ const btn = el('button', 'idocs-ws-pill-btn', label);
549
+ btn.type = 'button';
550
+ btn.addEventListener('click', () => {
551
+ void view.editImage(ctx.target.path, { align });
552
+ });
553
+ box.append(btn);
554
+ }
555
+ if (options.onPickImage) {
556
+ const replaceBtn = el('button', 'idocs-ws-pill-btn', '替换图片');
557
+ replaceBtn.type = 'button';
558
+ replaceBtn.addEventListener('click', async () => {
559
+ const picked = await options.onPickImage?.();
560
+ if (picked && !destroyed) {
561
+ await view.editImage(ctx.target.path, { src: picked.src, alt: picked.alt });
562
+ }
563
+ });
564
+ box.append(replaceBtn);
565
+ }
566
+ const doneBtn = el('button', 'idocs-ws-pill-btn', '完成');
567
+ doneBtn.type = 'button';
568
+ doneBtn.addEventListener('click', () => view.clearSelectedImage());
569
+ box.append(doneBtn);
570
+ tbContent.append(box);
571
+ return;
572
+ }
573
+ const inTable = ctx.kind === 'editable' && ctx.inTable;
574
+ const showTable = inTable && tableModePreferred;
575
+ const box = el('div', showTable ? 'idocs-ws-ctx is-table' : 'idocs-ws-ctx');
576
+ if (inTable) {
577
+ const seg = el('div', 'idocs-ws-seg');
578
+ const textTab = el('button', 'idocs-ws-seg-btn', '文字');
579
+ textTab.type = 'button';
580
+ textTab.setAttribute('aria-pressed', String(!showTable));
581
+ textTab.addEventListener('click', () => {
582
+ tableModePreferred = false;
583
+ renderToolbarCenter();
584
+ });
585
+ const tableTab = el('button', 'idocs-ws-seg-btn', '表格');
586
+ tableTab.type = 'button';
587
+ tableTab.setAttribute('aria-pressed', String(showTable));
588
+ tableTab.addEventListener('click', () => {
589
+ tableModePreferred = true;
590
+ renderToolbarCenter();
591
+ });
592
+ seg.append(textTab, tableTab);
593
+ box.append(seg, el('span', 'idocs-ws-divider'));
594
+ }
595
+ if (showTable) {
596
+ const tableActions = [
597
+ ['insertRowAbove', '上方插入行'],
598
+ ['insertRowBelow', '下方插入行'],
599
+ ['insertColumnLeft', '左侧插入列'],
600
+ ['insertColumnRight', '右侧插入列'],
601
+ ['deleteRow', '删除行', true],
602
+ ['deleteColumn', '删除列', true],
603
+ ['toggleHeaderRow', '重复表头'],
604
+ ];
605
+ for (const [action, label, danger] of tableActions) {
606
+ const btn = el('button', danger ? 'idocs-ws-pill-btn is-danger' : 'idocs-ws-pill-btn', label);
607
+ btn.type = 'button';
608
+ if (action === 'toggleHeaderRow')
609
+ btn.title = '开启或关闭跨页重复表头';
610
+ btn.addEventListener('click', () => {
611
+ void view.editTable(action);
612
+ });
613
+ box.append(btn);
614
+ }
615
+ tbContent.append(box);
616
+ return;
617
+ }
618
+ const styleOptions = [
619
+ ['body', '正文'],
620
+ ['heading1', '一级标题'],
621
+ ['heading2', '二级标题'],
622
+ ['heading3', '三级标题'],
623
+ ];
624
+ const activeStyle = ctx.kind === 'editable' ? ctx.blockStyle : undefined;
625
+ const selectedStyle = activeStyle ?? 'body';
626
+ const selectedLabel = styleOptions.find(([v]) => v === selectedStyle)?.[1] ?? '正文';
627
+ const styleTrigger = el('button', 'idocs-ws-select-trigger');
628
+ styleTrigger.type = 'button';
629
+ styleTrigger.setAttribute('role', 'combobox');
630
+ styleTrigger.setAttribute('aria-haspopup', 'listbox');
631
+ styleTrigger.setAttribute('aria-expanded', 'false');
632
+ styleTrigger.setAttribute('aria-label', '段落样式');
633
+ styleTrigger.disabled = ctx.kind !== 'editable' || activeStyle === undefined;
634
+ const styleValueSpan = el('span', 'idocs-ws-select-value', selectedLabel);
635
+ const chevronSvg = makeSvgIcon(ICONS.chevronDown, 14);
636
+ chevronSvg.classList.add('idocs-ws-select-chevron');
637
+ styleTrigger.append(styleValueSpan, chevronSvg);
638
+ styleTrigger.addEventListener('mousedown', (event) => event.preventDefault());
639
+ const stylePopover = el('div', 'idocs-ws-popover is-down idocs-ws-style-popover');
640
+ stylePopover.setAttribute('role', 'listbox');
641
+ stylePopover.setAttribute('aria-label', '段落样式');
642
+ stylePopover.hidden = true;
643
+ const styleItems = [];
644
+ let highlightedStyleIdx = Math.max(0, styleOptions.findIndex(([v]) => v === selectedStyle));
645
+ const syncStyleHighlight = (nextIdx) => {
646
+ highlightedStyleIdx = (nextIdx + styleItems.length) % styleItems.length;
647
+ styleItems.forEach((item, idx) => {
648
+ if (idx === highlightedStyleIdx)
649
+ item.dataset.highlighted = 'true';
650
+ else
651
+ delete item.dataset.highlighted;
652
+ });
653
+ };
654
+ for (let idx = 0; idx < styleOptions.length; idx += 1) {
655
+ const [val, label] = styleOptions[idx];
656
+ const isSelected = val === selectedStyle;
657
+ const item = el('button', isSelected ? 'idocs-ws-menu-item is-active' : 'idocs-ws-menu-item');
658
+ item.type = 'button';
659
+ item.setAttribute('role', 'option');
660
+ item.setAttribute('aria-selected', String(isSelected));
661
+ item.dataset.value = val;
662
+ item.append(el('span', 'idocs-ws-menu-label', label));
663
+ if (isSelected) {
664
+ const checkWrap = el('span', 'idocs-ws-menu-check');
665
+ checkWrap.setAttribute('aria-hidden', 'true');
666
+ checkWrap.append(makeSvgIcon(ICONS.check, 14));
667
+ item.append(checkWrap);
668
+ }
669
+ item.addEventListener('mousedown', (event) => event.preventDefault());
670
+ item.addEventListener('mouseenter', () => syncStyleHighlight(idx));
671
+ item.addEventListener('click', (event) => {
672
+ event.stopPropagation();
673
+ closeAllPopovers();
674
+ void view.setBlockStyle(val);
675
+ });
676
+ styleItems.push(item);
677
+ stylePopover.append(item);
678
+ }
679
+ const toggleStylePopover = (forceOpen) => {
680
+ if (styleTrigger.disabled)
681
+ return;
682
+ const shouldOpen = forceOpen ?? Boolean(stylePopover.hidden);
683
+ toggleToolbarMenu(styleTrigger, stylePopover, shouldOpen);
684
+ if (shouldOpen) {
685
+ syncStyleHighlight(Math.max(0, styleOptions.findIndex(([v]) => v === selectedStyle)));
686
+ }
687
+ };
688
+ styleTrigger.addEventListener('click', (event) => {
689
+ event.stopPropagation();
690
+ toggleStylePopover();
691
+ });
692
+ styleTrigger.addEventListener('keydown', (event) => {
693
+ if (event.key === 'ArrowDown') {
694
+ event.preventDefault();
695
+ if (stylePopover.hidden)
696
+ toggleStylePopover(true);
697
+ else
698
+ syncStyleHighlight(highlightedStyleIdx + 1);
699
+ }
700
+ else if (event.key === 'ArrowUp') {
701
+ event.preventDefault();
702
+ if (stylePopover.hidden)
703
+ toggleStylePopover(true);
704
+ else
705
+ syncStyleHighlight(highlightedStyleIdx - 1);
706
+ }
707
+ else if ((event.key === 'Enter' || event.key === ' ') && !stylePopover.hidden) {
708
+ event.preventDefault();
709
+ const picked = styleOptions[highlightedStyleIdx]?.[0];
710
+ closeAllPopovers();
711
+ if (picked)
712
+ void view.setBlockStyle(picked);
713
+ }
714
+ else if (event.key === 'Escape' && !stylePopover.hidden) {
715
+ event.preventDefault();
716
+ event.stopPropagation();
717
+ closeAllPopovers();
718
+ }
719
+ });
720
+ stylePopover.addEventListener('click', (event) => event.stopPropagation());
721
+ box.append(styleTrigger, stylePopover, el('span', 'idocs-ws-divider'));
722
+ const collapsed = ctx.kind !== 'editable' || ctx.collapsed;
723
+ const marks = [
724
+ ['bold', '粗体', ICONS.bold],
725
+ ['italic', '斜体', ICONS.italic],
726
+ ['underline', '下划线', ICONS.underline],
727
+ ['strike', '删除线', ICONS.strike],
728
+ ];
729
+ for (const [mark, label, icon] of marks) {
730
+ const btn = el('button', 'idocs-ws-icon-btn');
731
+ btn.type = 'button';
732
+ btn.title = label;
733
+ btn.setAttribute('aria-label', label);
734
+ btn.disabled = collapsed;
735
+ btn.append(makeSvgIcon(icon));
736
+ btn.addEventListener('mousedown', (event) => event.preventDefault());
737
+ btn.addEventListener('click', () => {
738
+ void view.toggleMark(mark);
739
+ });
740
+ box.append(btn);
741
+ }
742
+ box.append(el('span', 'idocs-ws-divider'));
743
+ const alignBtn = el('button', 'idocs-ws-icon-btn');
744
+ alignBtn.type = 'button';
745
+ alignBtn.title = '对齐方式';
746
+ alignBtn.setAttribute('aria-label', '对齐方式');
747
+ alignBtn.setAttribute('aria-haspopup', 'menu');
748
+ alignBtn.setAttribute('aria-expanded', 'false');
749
+ alignBtn.disabled = ctx.kind !== 'editable';
750
+ alignBtn.append(makeSvgIcon(ICONS.align));
751
+ alignBtn.addEventListener('mousedown', (event) => event.preventDefault());
752
+ const alignPopover = el('div', 'idocs-ws-popover is-down idocs-ws-align-popover');
753
+ alignPopover.setAttribute('role', 'menu');
754
+ alignPopover.hidden = true;
755
+ const currentAlign = ctx.kind === 'editable' ? (ctx.align ?? 'left') : undefined;
756
+ for (const [align, label] of [
757
+ ['left', '左对齐'],
758
+ ['center', '居中对齐'],
759
+ ['right', '右对齐'],
760
+ ['justify', '两端对齐'],
761
+ ]) {
762
+ const isSelected = currentAlign === align;
763
+ const item = el('button', isSelected ? 'idocs-ws-menu-item is-active' : 'idocs-ws-menu-item');
764
+ item.type = 'button';
765
+ item.setAttribute('role', 'menuitemradio');
766
+ item.setAttribute('aria-checked', String(isSelected));
767
+ item.append(el('span', 'idocs-ws-menu-label', label));
768
+ if (isSelected) {
769
+ const checkWrap = el('span', 'idocs-ws-menu-check');
770
+ checkWrap.setAttribute('aria-hidden', 'true');
771
+ checkWrap.append(makeSvgIcon(ICONS.check, 14));
772
+ item.append(checkWrap);
773
+ }
774
+ item.addEventListener('mousedown', (event) => event.preventDefault());
775
+ item.addEventListener('click', (event) => {
776
+ event.stopPropagation();
777
+ closeAllPopovers();
778
+ void view.setAlignment(align);
779
+ });
780
+ alignPopover.append(item);
781
+ }
782
+ alignBtn.addEventListener('click', (event) => {
783
+ event.stopPropagation();
784
+ toggleToolbarMenu(alignBtn, alignPopover, Boolean(alignPopover.hidden));
785
+ });
786
+ alignPopover.addEventListener('click', (event) => event.stopPropagation());
787
+ box.append(alignBtn, alignPopover);
788
+ tbContent.append(box);
789
+ };
790
+ const paintThumbnails = () => {
791
+ if (!railOpen || !activeDoc || railTab !== 'pages')
792
+ return;
793
+ const canvases = railList.querySelectorAll('.idocs-ws-thumb-canvas');
794
+ const dpr = Math.max(1, Math.min(2, globalThis.devicePixelRatio || 1));
795
+ canvases.forEach((canvas, idx) => {
796
+ const page = activeDoc?.pages[idx];
797
+ if (!page)
798
+ return;
799
+ const cssWidth = Math.max(120, Math.round(canvas.clientWidth || 164));
800
+ const scale = cssWidth / page.width;
801
+ const cssHeight = Math.max(40, Math.round(page.height * scale));
802
+ const pxPerPt = scale * dpr;
803
+ canvas.width = Math.max(1, Math.round(cssWidth * dpr));
804
+ canvas.height = Math.max(1, Math.round(cssHeight * dpr));
805
+ canvas.style.aspectRatio = `${page.width} / ${page.height}`;
806
+ const ctx = canvas.getContext('2d');
807
+ if (!ctx)
808
+ return;
809
+ paintPage(ctx, page, pxPerPt, thumbImages, activeDoc?.glyphOutlines ?? []);
810
+ });
811
+ };
812
+ const updateRailHighlight = (pageIndex) => {
813
+ const cards = railList.querySelectorAll('.idocs-ws-thumb-card');
814
+ cards.forEach((card, idx) => {
815
+ const active = idx === pageIndex;
816
+ card.classList.toggle('is-active', active);
817
+ if (active)
818
+ card.setAttribute('aria-current', 'page');
819
+ else
820
+ card.removeAttribute('aria-current');
821
+ });
822
+ };
823
+ const updateOutlineHighlight = (activePath) => {
824
+ if (railTab !== 'outline' || activePath === lastOutlinePath)
825
+ return;
826
+ lastOutlinePath = activePath;
827
+ const items = railList.querySelectorAll('.idocs-ws-outline-item');
828
+ items.forEach((item) => {
829
+ item.classList.toggle('is-active', Boolean(activePath && item.dataset.path === activePath));
830
+ });
831
+ };
832
+ const renderRail = () => {
833
+ if (!railOpen || (renderedRailDoc === activeDoc && renderedRailTab === railTab))
834
+ return;
835
+ renderedRailDoc = activeDoc;
836
+ renderedRailTab = railTab;
837
+ lastOutlinePath = undefined;
838
+ pagesTabBtn.setAttribute('aria-selected', String(railTab === 'pages'));
839
+ outlineTabBtn.setAttribute('aria-selected', String(railTab === 'outline'));
840
+ railList.classList.toggle('is-outline', railTab === 'outline');
841
+ railList.innerHTML = '';
842
+ if (railTab === 'pages') {
843
+ if (!sameImageList(lastThumbImages, activeDoc?.images ?? [])) {
844
+ const nextImages = activeDoc?.images ?? [];
845
+ lastThumbImages = nextImages;
846
+ for (const image of thumbImages)
847
+ image?.close();
848
+ thumbImages = [];
849
+ if (nextImages.length > 0) {
850
+ void decodeImages(nextImages).then((decoded) => {
851
+ if (destroyed || lastThumbImages !== nextImages) {
852
+ for (const image of decoded)
853
+ image?.close();
854
+ return;
855
+ }
856
+ thumbImages = decoded;
857
+ if (railOpen)
858
+ paintThumbnails();
859
+ else
860
+ renderedRailDoc = undefined;
861
+ });
862
+ }
863
+ }
864
+ const pages = activeDoc?.pages ?? [];
865
+ if (pages.length === 0) {
866
+ railList.append(el('div', 'idocs-ws-empty', '正在加载页面…'));
867
+ return;
868
+ }
869
+ pages.forEach((_, idx) => {
870
+ const card = el('button', 'idocs-ws-thumb-card');
871
+ card.type = 'button';
872
+ card.setAttribute('aria-label', `第 ${idx + 1} 页`);
873
+ if (idx === view.currentPage) {
874
+ card.classList.add('is-active');
875
+ card.setAttribute('aria-current', 'page');
876
+ }
877
+ const sheet = el('span', 'idocs-ws-thumb-sheet');
878
+ sheet.setAttribute('aria-hidden', 'true');
879
+ const canvas = el('canvas', 'idocs-ws-thumb-canvas');
880
+ sheet.append(canvas);
881
+ const label = el('span', 'idocs-ws-thumb-label', String(idx + 1));
882
+ label.setAttribute('aria-hidden', 'true');
883
+ card.append(label, sheet);
884
+ card.addEventListener('click', () => view.goToPage(idx, { behavior: 'smooth' }));
885
+ railList.append(card);
886
+ });
887
+ paintThumbnails();
888
+ return;
889
+ }
890
+ const entries = activeDoc ? extractOutline(activeDoc) : [];
891
+ if (entries.length === 0) {
892
+ railList.append(el('div', 'idocs-ws-empty', '暂无标题,可将段落设为一级至三级标题'));
893
+ return;
894
+ }
895
+ const activePath = view.selection()?.path;
896
+ for (const entry of entries) {
897
+ const item = el('button', `idocs-ws-outline-item level-${entry.level}`, entry.text);
898
+ item.type = 'button';
899
+ item.dataset.path = entry.path;
900
+ item.classList.toggle('is-active', activePath === entry.path);
901
+ item.title = entry.text;
902
+ item.addEventListener('click', () => {
903
+ view.goToPage(entry.page, { behavior: 'smooth' });
904
+ if (entry.anchor) {
905
+ void view.selectAnchors(entry.anchor);
906
+ }
907
+ });
908
+ railList.append(item);
909
+ }
910
+ };
911
+ const syncHistoryButtons = (state) => {
912
+ undoBtn.disabled = readOnly || !state.canUndo;
913
+ redoBtn.disabled = readOnly || !state.canRedo;
914
+ };
915
+ const syncDocument = (doc) => {
916
+ if (doc === activeDoc)
917
+ return;
918
+ activeDoc = doc;
919
+ renderRail();
920
+ renderToolbarCenter();
921
+ };
922
+ view = mountEditor(editorHost, session, {
923
+ ...options.view,
924
+ bottomInset: options.view?.bottomInset ?? 68,
925
+ readOnly,
926
+ onPageChange: (page, doc) => {
927
+ updateRailHighlight(page);
928
+ options.view?.onPageChange?.(page, doc);
929
+ },
930
+ onZoomChange: (next) => {
931
+ syncZoom();
932
+ if (!selPill.hidden)
933
+ updatePill(view.selection());
934
+ options.view?.onZoomChange?.(next);
935
+ },
936
+ onSelectionChange: (sel) => {
937
+ updatePill(sel);
938
+ updateOutlineHighlight(sel?.path);
939
+ renderToolbarCenter();
940
+ options.view?.onSelectionChange?.(sel);
941
+ },
942
+ onReadOnlyTarget: (target) => {
943
+ updatePill(null);
944
+ renderToolbarCenter();
945
+ options.view?.onReadOnlyTarget?.(target);
946
+ },
947
+ onImageSelect: (target) => {
948
+ updatePill(null);
949
+ renderToolbarCenter();
950
+ options.view?.onImageSelect?.(target);
951
+ },
952
+ });
953
+ const onSessionState = (state) => {
954
+ if (destroyed)
955
+ return;
956
+ syncHistoryButtons(state);
957
+ if (state.layout.kind === 'ready') {
958
+ syncDocument(state.layout.frame.document);
959
+ }
960
+ else if (state.layout.kind === 'pending' && !activeDoc && state.layout.previous) {
961
+ syncDocument(state.layout.previous.document);
962
+ }
963
+ };
964
+ const unsubscribe = session.subscribe(onSessionState);
965
+ onSessionState(session.state());
966
+ syncZoom();
967
+ const onDocClick = (event) => {
968
+ if (!event.composedPath().includes(tableAnchor))
969
+ closeAllPopovers();
970
+ };
971
+ const onDocKeydown = (event) => {
972
+ if (event.key === 'Escape')
973
+ closeAllPopovers();
974
+ };
975
+ document.addEventListener('click', onDocClick);
976
+ document.addEventListener('keydown', onDocKeydown);
977
+ const applyRailOpen = (open) => {
978
+ railOpen = open;
979
+ body.classList.toggle('is-rail-collapsed', !open);
980
+ if ('inert' in rail)
981
+ rail.inert = !open;
982
+ rail.setAttribute('aria-hidden', String(!open));
983
+ const label = open ? '收起页面列表' : '展开页面列表';
984
+ railToggleBtn.title = label;
985
+ railToggleBtn.setAttribute('aria-label', label);
986
+ railToggleBtn.setAttribute('aria-expanded', String(open));
987
+ if (open) {
988
+ renderRail();
989
+ if (railTab === 'pages')
990
+ updateRailHighlight(view.currentPage);
991
+ else
992
+ updateOutlineHighlight(view.selection()?.path);
993
+ }
994
+ };
995
+ editorHost
996
+ .querySelector('.idocs-preview-scroller')
997
+ ?.addEventListener('scroll', () => {
998
+ if (!selPill.hidden)
999
+ updatePill(view.selection());
1000
+ }, { passive: true });
1001
+ railSlot.addEventListener('transitionend', () => updatePill(view.selection()));
1002
+ railToggleBtn.addEventListener('click', () => applyRailOpen(!railOpen));
1003
+ pagesTabBtn.addEventListener('click', () => {
1004
+ if (railTab === 'pages')
1005
+ return;
1006
+ railTab = 'pages';
1007
+ renderRail();
1008
+ });
1009
+ outlineTabBtn.addEventListener('click', () => {
1010
+ if (railTab === 'outline')
1011
+ return;
1012
+ railTab = 'outline';
1013
+ renderRail();
1014
+ });
1015
+ undoBtn.addEventListener('click', () => void view.undo());
1016
+ redoBtn.addEventListener('click', () => void view.redo());
1017
+ zoomOutBtn.addEventListener('click', () => {
1018
+ view.setZoom(Math.max(0.25, view.zoom - 0.15));
1019
+ syncZoom();
1020
+ });
1021
+ zoomInBtn.addEventListener('click', () => {
1022
+ view.setZoom(Math.min(4, view.zoom + 0.15));
1023
+ syncZoom();
1024
+ });
1025
+ fitWidthBtn.addEventListener('click', () => {
1026
+ view.setZoom(view.fitWidthZoom);
1027
+ syncZoom();
1028
+ });
1029
+ findBtn.addEventListener('click', () => view.find.open());
1030
+ pillQuoteBtn?.addEventListener('click', () => {
1031
+ const sel = view.selection();
1032
+ const text = sel?.excerpt;
1033
+ if (!sel || !text?.trim())
1034
+ return;
1035
+ options.onQuoteSelection?.({ text, path: sel.path, page: sel.page });
1036
+ });
1037
+ const insertAndFocus = async (block) => {
1038
+ const active = document.activeElement;
1039
+ const commit = await view.insertBlock(block);
1040
+ if (commit?.selection && !destroyed && !readOnly && document.activeElement === active)
1041
+ view.focus();
1042
+ };
1043
+ dock.append(makeDockButton(ICONS.paragraph, '段落', () => void insertAndFocus({ kind: 'paragraph' })), makeDockButton(ICONS.heading, '标题', () => void insertAndFocus({ kind: 'heading', level: 2 })));
1044
+ tableAnchor.prepend(makeDockButton(ICONS.table, '表格', () => {
1045
+ closeToolbarMenu();
1046
+ setTablePopoverOpen(Boolean(tablePopover.hidden));
1047
+ }, tablePopover));
1048
+ dock.append(tableAnchor, makeDockButton(ICONS.callout, '提示框', () => void insertAndFocus({ kind: 'callout', colors: options.calloutColors })));
1049
+ if (options.onPickImage) {
1050
+ dock.append(makeDockButton(ICONS.image, '图片', async () => {
1051
+ const picked = await options.onPickImage?.();
1052
+ if (picked && !destroyed) {
1053
+ await view.insertBlock({ kind: 'image', src: picked.src, alt: picked.alt });
1054
+ }
1055
+ }));
1056
+ }
1057
+ dock.append(makeDockButton(ICONS.pageBreak, '分页符', () => void view.insertBlock({ kind: 'pageBreak' })));
1058
+ const detachInsertButtons = options.mountInsertButtons?.(insertButtons);
1059
+ tableGrid.addEventListener('mouseover', (event) => {
1060
+ const cell = event.target?.closest('.idocs-ws-table-cell');
1061
+ if (!cell)
1062
+ return;
1063
+ paintTableGrid(Number(cell.dataset.rows), Number(cell.dataset.cols));
1064
+ });
1065
+ tableGrid.addEventListener('click', (event) => {
1066
+ const cell = event.target?.closest('.idocs-ws-table-cell');
1067
+ const rows = cell ? Number(cell.dataset.rows) : hoverRows;
1068
+ const cols = cell ? Number(cell.dataset.cols) : hoverCols;
1069
+ closeAllPopovers();
1070
+ void view.insertBlock({ kind: 'table', rows, cols });
1071
+ });
1072
+ return {
1073
+ view,
1074
+ get readOnly() {
1075
+ return readOnly;
1076
+ },
1077
+ get processing() {
1078
+ return processing;
1079
+ },
1080
+ setReadOnly(next, nextBusyLabel, nextProcessing) {
1081
+ if (destroyed)
1082
+ return;
1083
+ readOnly = next;
1084
+ busyLabel = nextBusyLabel;
1085
+ processing = next ? (nextProcessing ?? Boolean(nextBusyLabel)) : false;
1086
+ root.classList.toggle('is-readonly', readOnly);
1087
+ root.classList.toggle('is-processing', processing);
1088
+ view.setReadOnly(next);
1089
+ dock.hidden = next;
1090
+ if (next) {
1091
+ selPill.hidden = true;
1092
+ closeAllPopovers();
1093
+ }
1094
+ else {
1095
+ updatePill(view.selection());
1096
+ }
1097
+ syncHistoryButtons(session.state());
1098
+ renderToolbarCenter();
1099
+ },
1100
+ setRailOpen(open) {
1101
+ if (destroyed)
1102
+ return;
1103
+ applyRailOpen(open);
1104
+ },
1105
+ destroy() {
1106
+ if (destroyed)
1107
+ return;
1108
+ destroyed = true;
1109
+ toolbarScroller.destroy();
1110
+ document.removeEventListener('click', onDocClick);
1111
+ document.removeEventListener('keydown', onDocKeydown);
1112
+ unsubscribe();
1113
+ view.destroy();
1114
+ detachInsertButtons?.();
1115
+ for (const img of thumbImages)
1116
+ img?.close();
1117
+ thumbImages = [];
1118
+ root.remove();
1119
+ },
1120
+ };
1121
+ }