aegon-lv97 1.0.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.
Files changed (78) hide show
  1. package/gen-ai/build-report/chartStreamRefs.ts +227 -0
  2. package/gen-ai/build-report/coherence-check/index.vue +559 -0
  3. package/gen-ai/build-report/components/ReportChapterWorkspace.vue +4571 -0
  4. package/gen-ai/build-report/components/ReportConfigPanel.vue +727 -0
  5. package/gen-ai/build-report/components/ReportGenerateSettingsPanel.vue +2361 -0
  6. package/gen-ai/build-report/components/ReportGeneratingPanel.vue +1765 -0
  7. package/gen-ai/build-report/components/ReportPromptPanel.vue +625 -0
  8. package/gen-ai/build-report/components/ReportSourceFileSummary.vue +665 -0
  9. package/gen-ai/build-report/components/ReportSourcesPanel.vue +1327 -0
  10. package/gen-ai/build-report/components/ReportTemplateDetail.vue +191 -0
  11. package/gen-ai/build-report/components/ReportTemplateEditorFormPanel.vue +667 -0
  12. package/gen-ai/build-report/components/ReportTemplatePicker.vue +302 -0
  13. package/gen-ai/build-report/components/ReportWorkflowSidebar.vue +148 -0
  14. package/gen-ai/build-report/components/paramsSetting.vue +886 -0
  15. package/gen-ai/build-report/components/reportEdit.vue +2283 -0
  16. package/gen-ai/build-report/index.vue +856 -0
  17. package/gen-ai/build-report/report-domain.ts +1133 -0
  18. package/gen-ai/build-report/report-template-data.ts +295 -0
  19. package/gen-ai/build-report/useReportWorkflow.ts +1386 -0
  20. package/gen-ai/common/assets/NotoSans-SC.woff2 +0 -0
  21. package/gen-ai/common/assets/NotoSans-TC.woff2 +0 -0
  22. package/gen-ai/common/assets/NotoSans.woff2 +0 -0
  23. package/gen-ai/common/assets/fonts.css +278 -0
  24. package/gen-ai/common/assets/pagination.css +163 -0
  25. package/gen-ai/common/directives/btnLoading.ts +455 -0
  26. package/gen-ai/common/directives/debounce.ts +98 -0
  27. package/gen-ai/common/directives/index.ts +30 -0
  28. package/gen-ai/common/en.md +417 -0
  29. package/gen-ai/common/index.ts +36 -0
  30. package/gen-ai/common/types/component-expose.ts +9 -0
  31. package/gen-ai/common/utils/api-res-status.ts +7 -0
  32. package/gen-ai/common/utils/navigation.ts +59 -0
  33. package/gen-ai/common/utils/remote-request.ts +45 -0
  34. package/gen-ai/common/utils/scroll.ts +577 -0
  35. package/gen-ai/components/AIExport/docx-export.ts +950 -0
  36. package/gen-ai/components/AIExport/docxExport.ts +2566 -0
  37. package/gen-ai/components/AIExport/index.ts +52 -0
  38. package/gen-ai/components/AIExport/parseMarkdownHtml.ts +653 -0
  39. package/gen-ai/components/CapsuleScrollbar.vue +149 -0
  40. package/gen-ai/components/ChapterTitleScroll.vue +468 -0
  41. package/gen-ai/components/EntryHeroNavCards.vue +379 -0
  42. package/gen-ai/components/GenAiPagination.vue +52 -0
  43. package/gen-ai/components/ModelCard.vue +1114 -0
  44. package/gen-ai/components/Preview/FilePreviewPage.vue +949 -0
  45. package/gen-ai/components/Preview/PreviewCapsuleScroll.vue +205 -0
  46. package/gen-ai/components/Preview/PreviewOutlineTree.vue +148 -0
  47. package/gen-ai/components/Preview/index.ts +11 -0
  48. package/gen-ai/components/Preview/preview.ts +724 -0
  49. package/gen-ai/components/PromptField.vue +505 -0
  50. package/gen-ai/components/RecentReportsTable.vue +974 -0
  51. package/gen-ai/components/ReportCollapsibleSection.vue +172 -0
  52. package/gen-ai/components/ReportHistoryList.vue +637 -0
  53. package/gen-ai/components/ReportSectionConfirmPopover.vue +251 -0
  54. package/gen-ai/components/editor/ChapterTiptapEditor.vue +3718 -0
  55. package/gen-ai/components/editor/StructureBlockView.vue +441 -0
  56. package/gen-ai/components/editor/TiptapToolbar.vue +1036 -0
  57. package/gen-ai/components/editor/TypewriterDocEditor.vue +422 -0
  58. package/gen-ai/components/editor/_StructureOrgNode.vue +326 -0
  59. package/gen-ai/components/editor/index.ts +28 -0
  60. package/gen-ai/components/editor/parseMarkdownHtml.ts +754 -0
  61. package/gen-ai/components/editor/reviewCitationTip.ts +163 -0
  62. package/gen-ai/gen-entry/api/index.ts +32 -0
  63. package/gen-ai/gen-entry/index.vue +287 -0
  64. package/gen-ai/gen-entry/types/index.ts +4 -0
  65. package/gen-ai/i.mark +2 -0
  66. package/gen-ai/management-center/index.vue +1815 -0
  67. package/gen-ai/management-center/mockData.ts +248 -0
  68. package/gen-ai/management-center/types/index.ts +37 -0
  69. package/gen-ai/model-manager/TemplateEditorFormPanel.vue +661 -0
  70. package/gen-ai/model-manager/api/index.ts +32 -0
  71. package/gen-ai/model-manager/api/mockApi.ts +33 -0
  72. package/gen-ai/model-manager/index.vue +1583 -0
  73. package/gen-ai/model-manager/mockData.ts +177 -0
  74. package/gen-ai/model-manager/template-detail-shared.ts +154 -0
  75. package/gen-ai/model-manager/template-editor.vue +345 -0
  76. package/gen-ai/model-manager/types/index.ts +40 -0
  77. package/gen-ai/model-manager/utils.ts +25 -0
  78. package/package.json +12 -0
@@ -0,0 +1,3718 @@
1
+ <template>
2
+ <div
3
+ ref="editorRootRef"
4
+ class="report-tiptap-editor is-readonly"
5
+ :class="{ 'is-typing': isTypewriting }"
6
+ @mouseleave="scheduleHideReviewPopover"
7
+ >
8
+ <div class="report-tiptap-editor__toolbar" role="toolbar" aria-label="富文本工具欄">
9
+ <div class="report-tiptap-editor__group">
10
+ <button
11
+ type="button"
12
+ class="report-tiptap-editor__tool"
13
+ title="撤銷"
14
+ :disabled="!editable || !editor?.can().undo()"
15
+ @click="editor?.chain().focus().undo().run()"
16
+ >
17
+ <Undo2 :size="18" :stroke-width="1.75" />
18
+ </button>
19
+ <button
20
+ type="button"
21
+ class="report-tiptap-editor__tool"
22
+ title="重做"
23
+ :disabled="!editable || !editor?.can().redo()"
24
+ @click="editor?.chain().focus().redo().run()"
25
+ >
26
+ <Redo2 :size="18" :stroke-width="1.75" />
27
+ </button>
28
+ </div>
29
+
30
+ <span class="report-tiptap-editor__divider" aria-hidden="true" />
31
+
32
+ <!-- 格式 + 插入工具欄 -->
33
+ <TiptapToolbar :editor="editor" :editable="editable" />
34
+ </div>
35
+
36
+ <div
37
+ class="report-tiptap-editor__body"
38
+ @click="onEditorBodyClick"
39
+ @mouseover="onEditorBodyMouseOver"
40
+ >
41
+ <EditorContent :editor="editor" class="report-tiptap-editor__content" />
42
+ </div>
43
+
44
+ <!-- //0918 批註 tip:掛到中間 main card 內,避免超出模板區(Vue 3.4 無 Teleport defer) -->
45
+ <Teleport :to="reviewPopoverTeleportTo">
46
+ <div
47
+ v-if="reviewPopoverVisible && activeReviewMarker"
48
+ ref="reviewPopoverRef"
49
+ class="report-chapter-review-popover"
50
+ :style="reviewPopoverStyle"
51
+ @click.stop
52
+ @mouseenter="onReviewPopoverMouseEnter"
53
+ @mouseleave="onReviewPopoverMouseLeave"
54
+ >
55
+ <div class="review-marker-popover">
56
+ <div class="review-marker-popover__meta">
57
+ <button
58
+ v-if="activeReviewMarker.author"
59
+ type="button"
60
+ class="review-marker-popover__source-name"
61
+ :title="activeReviewMarker.author"
62
+ @click.stop="onSourceNameClick"
63
+ >
64
+ {{ formatSourceNameDisplay(activeReviewMarker.author) }}
65
+ </button>
66
+ <!-- //0918:序号 id>0 才显示红点 -->
67
+ <span
68
+ v-if="Number(activeReviewMarker.id) > 0"
69
+ class="review-marker-popover__id"
70
+ >{{ activeReviewMarker.id }}</span>
71
+ <!-- //0918 -->
72
+ </div>
73
+ <div class="review-marker-popover__body">
74
+ <div
75
+ ref="reviewPopoverScrollRef"
76
+ class="review-marker-popover__scroll"
77
+ @scroll.passive="syncReviewPopoverThumb"
78
+ >
79
+ <div class="review-marker-popover__content" v-html="tipPopoverHtml" />
80
+ </div>
81
+ <!-- //0918 滾動條:掛 body、貼正文不蓋文件名;>18 行 tip 打開即顯示灰膠囊 -->
82
+ <div
83
+ class="review-marker-popover__thumb-track"
84
+ :class="{ 'is-visible': reviewPopoverThumbShow }"
85
+ aria-hidden="true"
86
+ @mousedown.prevent="onReviewPopoverTrackDown"
87
+ >
88
+ <div
89
+ class="review-marker-popover__thumb"
90
+ :style="{
91
+ height: `${reviewPopoverThumbH}px`,
92
+ transform: `translateY(${reviewPopoverThumbY}px)`,
93
+ }"
94
+ @mousedown.stop.prevent="onReviewPopoverThumbDown"
95
+ />
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ </Teleport>
101
+ <!-- //0918 -->
102
+ </div>
103
+ </template>
104
+
105
+ <script setup lang="ts">
106
+ import {
107
+ createNodeFromContent,
108
+ mergeAttributes,
109
+ Node,
110
+ ResizableNodeView,
111
+ type CommandProps,
112
+ } from '@tiptap/core'
113
+ import Image from '@tiptap/extension-image'
114
+ import { Italic as ItalicExtension } from '@tiptap/extension-italic'
115
+ import LinkExtension from '@tiptap/extension-link'
116
+ import { TableKit } from '@tiptap/extension-table/kit'
117
+ import Underline from '@tiptap/extension-underline'
118
+ import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
119
+ import StarterKit from '@tiptap/starter-kit'
120
+ import type { Editor } from '@tiptap/vue-3'
121
+ import { EditorContent, useEditor, VueNodeViewRenderer } from '@tiptap/vue-3'
122
+ import { ElMessage } from 'element-plus'
123
+ import { Redo2, Undo2 } from 'lucide-vue-next'
124
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
125
+ //0918
126
+ import { CHART_STREAM_REFS_UPDATED_EVENT, getChartStreamRef } from '../../build-report/chartStreamRefs'
127
+ import {
128
+ canOpenCitationTip,
129
+ citationIndexFromRefOrDom,
130
+ readCitationDataset,
131
+ resolveCitationTipText,
132
+ } from './reviewCitationTip'
133
+ //0918
134
+ import type { SourceFile } from '../../build-report/useReportWorkflow'
135
+ import {
136
+ isPreviewableFileName,
137
+ openFilePreviewInNewTab,
138
+ } from '../Preview'
139
+ import {
140
+ normalizeReadableSpacing,
141
+ parseMarkdownToHtml,
142
+ parseMarkdownToHtmlStream,
143
+ renderTipMarkdownHtml,
144
+ stripThinkBlocks,
145
+ } from './parseMarkdownHtml'
146
+ import ReportStructureBlockView from './StructureBlockView.vue'
147
+ import TiptapToolbar from './TiptapToolbar.vue'
148
+
149
+ export interface EditorReviewMarker {
150
+ id: number
151
+ author: string
152
+ content: string
153
+ sourceFile?: SourceFile
154
+ href?: string
155
+ linkLabel?: string
156
+ filePath?: string
157
+ filePreviewUrl?: string
158
+ chunkId?: string
159
+ }
160
+
161
+ const props = withDefaults(
162
+ defineProps<{
163
+ chapterTitle?: string
164
+ chapterIntro?: string
165
+ /** 已廢棄:打字機僅展示,始終不可編輯 */
166
+ editable?: boolean
167
+ showReviewMarkers?: boolean
168
+ /** 批註由父級傳入;空則不展示 */
169
+ reviewMarkers?: EditorReviewMarker[]
170
+ /** 跳過打字機,直接滿內容 */
171
+ disableTypewriter?: boolean
172
+ /**
173
+ * 內容身份(如 chapter key)。設置後:拖拽僅改標題序號時不重跑打字機,
174
+ * 標題與正文作為整體隨 DOM 重排移動。
175
+ */
176
+ contentIdentity?: string
177
+ /**
178
+ * 多章串聯打字機相位:
179
+ * - auto:單章模式,掛載即打字
180
+ * - idle:等待(空白,不打字)
181
+ * - typing:當前正在打字
182
+ * - done:已打完,保持滿內容
183
+ */
184
+ typewriterPhase?: 'auto' | 'idle' | 'typing' | 'done'
185
+ /**
186
+ * 只渲染父級標題 + 正文
187
+ */
188
+ plainContent?: boolean
189
+ /**
190
+ * 流式斷續:追平當前緩衝後,若在此時間內無新內容才 emit complete。
191
+ * 緩衝仍為空時會一直等待(不 complete)。單位 ms。
192
+ */
193
+ typewriterSettleMs?: number
194
+ /**
195
+ * // 0924:chartAsStream 仍在推。追平后先不收口,避免整篇重画再续打。
196
+ */
197
+ streamPending?: boolean
198
+ }>(),
199
+ {
200
+ chapterTitle: '',
201
+ chapterIntro: '',
202
+ editable: false,
203
+ showReviewMarkers: false,
204
+ reviewMarkers: () => [],
205
+ plainContent: true,
206
+ disableTypewriter: false,
207
+ contentIdentity: '',
208
+ typewriterPhase: 'auto',
209
+ typewriterSettleMs: 1500,
210
+ streamPending: false,
211
+ }
212
+ )
213
+
214
+ const emit = defineEmits<{
215
+ 'typewriter-complete': []
216
+ }>()
217
+
218
+ const markerSourceFiles = ref<Record<number, SourceFile>>({})
219
+ const reviewPopoverVisible = ref(false)
220
+ const reviewPopoverStyle = ref<Record<string, string>>({})
221
+ const activeReviewMarker = ref<EditorReviewMarker | null>(null)
222
+ /** //0918 掛到當前打字機根節點內,避免超出中欄 */
223
+ const reviewPopoverTeleportTo = ref<string | HTMLElement>('body')
224
+ const reviewPopoverRef = ref<HTMLElement | null>(null)
225
+ const reviewPopoverScrollRef = ref<HTMLElement | null>(null)
226
+ //0918 tip popup 滾動條 / 邊距常數
227
+ const REVIEW_POPOVER_THUMB_H = 50
228
+ /** //0918 上右下左永遠 14px */
229
+ const REVIEW_POPOVER_PAD = 14
230
+ /** //0918 超過 18 行出滾動條;行高 14px × 1.6 = 22.4 */
231
+ const REVIEW_POPOVER_LINE_H = 22.4
232
+ const REVIEW_POPOVER_MIN_LINES = 18
233
+ const REVIEW_POPOVER_MIN_BODY_H = Math.round(REVIEW_POPOVER_LINE_H * REVIEW_POPOVER_MIN_LINES) //0918 與 CSS max-height 一致
234
+ const REVIEW_POPOVER_META_GAP = 10
235
+ const REVIEW_POPOVER_MAX_H =
236
+ REVIEW_POPOVER_PAD * 2 + 28 + REVIEW_POPOVER_META_GAP + REVIEW_POPOVER_MIN_BODY_H
237
+ const reviewPopoverThumbShow = ref(false)
238
+ /** //0918 正文 >18 行(可滾) */
239
+ const reviewPopoverNeedsScroll = ref(false)
240
+ const reviewPopoverThumbH = ref(REVIEW_POPOVER_THUMB_H)
241
+ const reviewPopoverThumbY = ref(0)
242
+ let hideReviewPopoverTimer = 0
243
+ let reviewPopoverThumbDrag: {
244
+ startY: number
245
+ startScrollTop: number
246
+ trackTravel: number
247
+ maxScroll: number
248
+ } | null = null
249
+
250
+ //0918 tip 灰膠囊位置跟隨 scrollTop
251
+ function syncReviewPopoverThumb() {
252
+ const wrap = reviewPopoverScrollRef.value
253
+ if (!wrap) {
254
+ reviewPopoverThumbY.value = 0
255
+ return
256
+ }
257
+ const viewH = Math.max(0, wrap.clientHeight)
258
+ const maxScroll = Math.max(0, wrap.scrollHeight - viewH)
259
+ //0918 實際溢出時補標(防止量高漏判)
260
+ if (maxScroll > 1) {
261
+ reviewPopoverNeedsScroll.value = true
262
+ reviewPopoverThumbShow.value = true
263
+ }
264
+ if (!reviewPopoverThumbShow.value || viewH <= 0) {
265
+ reviewPopoverThumbY.value = 0
266
+ return
267
+ }
268
+ const thumbH = Math.min(
269
+ REVIEW_POPOVER_THUMB_H,
270
+ Math.max(50, Math.floor(viewH * 0.35)),
271
+ Math.max(50, viewH)
272
+ )
273
+ //0918 最短 50px,保證灰膠囊看得見
274
+ reviewPopoverThumbH.value = Math.min(viewH, Math.max(50, thumbH))
275
+ const travel = Math.max(0, viewH - reviewPopoverThumbH.value)
276
+ if (maxScroll <= 0 || travel <= 0) {
277
+ reviewPopoverThumbY.value = 0
278
+ return
279
+ }
280
+ reviewPopoverThumbY.value = Math.min(
281
+ travel,
282
+ Math.max(0, Math.round((wrap.scrollTop / maxScroll) * travel))
283
+ )
284
+ }
285
+
286
+ /** //0918 移入 tip:確保 >18 行時滾動條 UI 亮著 */
287
+ function onReviewPopoverMouseEnter() {
288
+ cancelHideReviewPopover()
289
+ if (reviewPopoverNeedsScroll.value) {
290
+ reviewPopoverThumbShow.value = true
291
+ void nextTick(() => syncReviewPopoverThumb())
292
+ }
293
+ }
294
+
295
+ function onReviewPopoverMouseLeave() {
296
+ //0918 不在 leave 時藏條:>18 行 tip 打開期間 UI 保持可見
297
+ scheduleHideReviewPopover()
298
+ }
299
+
300
+ /** //0918 滾輪 delta → px(兼容 line/page 模式) */
301
+ function wheelDeltaToPx(event: WheelEvent, viewH: number) {
302
+ let dy = event.deltaY
303
+ if (event.deltaMode === 1) dy *= 16
304
+ else if (event.deltaMode === 2) dy *= viewH || REVIEW_POPOVER_MIN_BODY_H
305
+ if (dy !== 0 && Math.abs(dy) < 1) dy = dy < 0 ? -1 : 1
306
+ return dy
307
+ }
308
+
309
+ //0918 tip 原生 wheel(非 passive),拖條/滾輪可滾
310
+ function onReviewPopoverWheelNative(event: WheelEvent) {
311
+ const pop = reviewPopoverRef.value
312
+ const wrap = reviewPopoverScrollRef.value
313
+ if (!pop || !wrap || !reviewPopoverVisible.value) return
314
+
315
+ const maxScroll = Math.max(0, wrap.scrollHeight - wrap.clientHeight)
316
+ if (maxScroll <= 1) return
317
+
318
+ event.preventDefault()
319
+ event.stopPropagation()
320
+ cancelHideReviewPopover()
321
+
322
+ const dy = wheelDeltaToPx(event, wrap.clientHeight)
323
+ if (!dy) return
324
+ wrap.scrollTop = Math.min(maxScroll, Math.max(0, wrap.scrollTop + dy))
325
+ syncReviewPopoverThumb()
326
+ }
327
+
328
+ function bindReviewPopoverWheel() {
329
+ const pop = reviewPopoverRef.value
330
+ if (!pop) return
331
+ pop.removeEventListener('wheel', onReviewPopoverWheelNative, true)
332
+ pop.addEventListener('wheel', onReviewPopoverWheelNative, { capture: true, passive: false })
333
+ }
334
+
335
+ function unbindReviewPopoverWheel() {
336
+ const pop = reviewPopoverRef.value
337
+ if (!pop) return
338
+ pop.removeEventListener('wheel', onReviewPopoverWheelNative, true)
339
+ }
340
+
341
+ /**
342
+ * //0918 ≤18 行撐開;>18 行鎖 18 行並強制顯示右側滾動條。
343
+ * CSS max-height 會裁切正文,量高失敗時也要用實際 scrollHeight 補判,否則條不出現。
344
+ * @param outerMaxH 视口可用高度上限,屏底 tip 时压矮以免画出屏幕
345
+ */
346
+ function layoutReviewPopoverScroll(outerMaxH?: number) {
347
+ const pop = reviewPopoverRef.value
348
+ const scroll = reviewPopoverScrollRef.value
349
+ if (!pop || !scroll) return
350
+ const meta = pop.querySelector('.review-marker-popover__meta') as HTMLElement | null
351
+ const content = scroll.querySelector('.review-marker-popover__content') as HTMLElement | null
352
+
353
+ scroll.style.height = ''
354
+ scroll.style.minHeight = ''
355
+ scroll.style.maxHeight = ''
356
+ scroll.style.overflowY = 'auto'
357
+ if (content) content.style.minHeight = ''
358
+
359
+ reviewPopoverStyle.value = {
360
+ ...reviewPopoverStyle.value,
361
+ height: 'auto',
362
+ maxHeight: 'none',
363
+ overflow: 'hidden',
364
+ }
365
+ pop.style.height = 'auto'
366
+ pop.style.maxHeight = 'none'
367
+ void pop.offsetWidth
368
+ void scroll.offsetHeight
369
+
370
+ const metaH = meta ? Math.ceil(meta.getBoundingClientRect().height) : 28
371
+ const metaMb = meta
372
+ ? parseFloat(getComputedStyle(meta).marginBottom) || REVIEW_POPOVER_META_GAP
373
+ : REVIEW_POPOVER_META_GAP
374
+
375
+ let rawTextH = content?.scrollHeight || scroll.scrollHeight || REVIEW_POPOVER_LINE_H
376
+ if (content && pop.offsetWidth > 0) {
377
+ const cs = getComputedStyle(content)
378
+ const probe = content.cloneNode(true) as HTMLElement
379
+ const probeW = Math.max(
380
+ 1,
381
+ content.clientWidth || Math.floor(pop.getBoundingClientRect().width) - REVIEW_POPOVER_PAD * 2
382
+ )
383
+ probe.style.cssText = [
384
+ 'position:absolute',
385
+ 'left:-99999px',
386
+ 'top:0',
387
+ 'visibility:hidden',
388
+ 'pointer-events:none',
389
+ `width:${probeW}px`,
390
+ 'height:auto',
391
+ 'max-height:none',
392
+ 'overflow:visible',
393
+ 'white-space:normal',
394
+ 'word-break:break-word',
395
+ 'overflow-wrap:anywhere',
396
+ `font-size:${cs.fontSize}`,
397
+ `font-family:${cs.fontFamily}`,
398
+ `font-weight:${cs.fontWeight}`,
399
+ `line-height:${cs.lineHeight}`,
400
+ 'margin:0',
401
+ 'padding:0',
402
+ ].join(';')
403
+ document.body.appendChild(probe)
404
+ rawTextH = Math.max(rawTextH, probe.scrollHeight)
405
+ probe.remove()
406
+ }
407
+
408
+ //0920:按视口可用高度收紧;空间紧时允许压到 1 行,禁止再保 3 行把框顶出屏底
409
+ const chromeH = REVIEW_POPOVER_PAD * 2 + metaH + metaMb
410
+ const availBody =
411
+ outerMaxH != null && outerMaxH > 0
412
+ ? Math.floor(outerMaxH - chromeH)
413
+ : REVIEW_POPOVER_MIN_BODY_H
414
+ const bodyCap = Math.max(
415
+ Math.round(REVIEW_POPOVER_LINE_H),
416
+ Math.min(REVIEW_POPOVER_MIN_BODY_H, availBody)
417
+ )
418
+ //0920
419
+
420
+ let needsScroll = rawTextH > bodyCap + 1
421
+
422
+ if (needsScroll) {
423
+ scroll.style.height = `${bodyCap}px`
424
+ scroll.style.maxHeight = `${bodyCap}px`
425
+ scroll.style.overflowY = 'auto'
426
+ } else {
427
+ scroll.style.height = 'auto'
428
+ scroll.style.maxHeight = `${bodyCap}px`
429
+ scroll.style.overflowY = 'hidden'
430
+ }
431
+
432
+ void scroll.offsetHeight
433
+ //0918 CSS/限高裁切後再量一次:有溢出就必須出條
434
+ if (scroll.scrollHeight > scroll.clientHeight + 1) {
435
+ needsScroll = true
436
+ scroll.style.height = `${bodyCap}px`
437
+ scroll.style.maxHeight = `${bodyCap}px`
438
+ scroll.style.overflowY = 'auto'
439
+ }
440
+
441
+ reviewPopoverNeedsScroll.value = needsScroll
442
+ //0918 >18 行:tip 打開即顯示滾動條 UI
443
+ reviewPopoverThumbShow.value = needsScroll
444
+
445
+ const finalBodyH = needsScroll ? bodyCap : Math.min(bodyCap, Math.ceil(rawTextH))
446
+ const nextMax = chromeH + finalBodyH
447
+ reviewPopoverStyle.value = {
448
+ ...reviewPopoverStyle.value,
449
+ height: 'auto',
450
+ maxHeight: `${nextMax}px`,
451
+ overflow: 'hidden',
452
+ }
453
+ pop.style.maxHeight = `${nextMax}px`
454
+
455
+ void scroll.offsetHeight
456
+ if (needsScroll) {
457
+ const viewH = scroll.clientHeight || REVIEW_POPOVER_MIN_BODY_H
458
+ reviewPopoverThumbH.value = Math.min(viewH, Math.max(50, Math.floor(viewH * 0.35)))
459
+ }
460
+ syncReviewPopoverThumb()
461
+ bindReviewPopoverWheel()
462
+ }
463
+
464
+ //0918 tip 拖滑塊 / 點軌道
465
+ function onReviewPopoverThumbDown(event: MouseEvent) {
466
+ const wrap = reviewPopoverScrollRef.value
467
+ if (!wrap) return
468
+ cancelHideReviewPopover()
469
+ const viewH = wrap.clientHeight
470
+ const maxScroll = Math.max(0, wrap.scrollHeight - viewH)
471
+ if (maxScroll <= 0) return
472
+ const thumbH = reviewPopoverThumbH.value
473
+ reviewPopoverThumbDrag = {
474
+ startY: event.clientY,
475
+ startScrollTop: wrap.scrollTop,
476
+ trackTravel: Math.max(1, viewH - thumbH),
477
+ maxScroll,
478
+ }
479
+ window.addEventListener('mousemove', onReviewPopoverThumbMove)
480
+ window.addEventListener('mouseup', onReviewPopoverThumbUp)
481
+ }
482
+
483
+ function onReviewPopoverTrackDown(event: MouseEvent) {
484
+ const wrap = reviewPopoverScrollRef.value
485
+ const track = event.currentTarget as HTMLElement | null
486
+ if (!wrap || !track) return
487
+ if ((event.target as HTMLElement | null)?.closest?.('.review-marker-popover__thumb')) return
488
+ cancelHideReviewPopover()
489
+ const viewH = wrap.clientHeight
490
+ const maxScroll = Math.max(0, wrap.scrollHeight - viewH)
491
+ if (maxScroll <= 0) return
492
+ const rect = track.getBoundingClientRect()
493
+ const thumbH = reviewPopoverThumbH.value
494
+ const travel = Math.max(1, viewH - thumbH)
495
+ const y = event.clientY - rect.top - thumbH / 2
496
+ const ratio = Math.min(1, Math.max(0, y / travel))
497
+ wrap.scrollTop = ratio * maxScroll
498
+ syncReviewPopoverThumb()
499
+ onReviewPopoverThumbDown(event)
500
+ }
501
+
502
+ function onReviewPopoverThumbMove(event: MouseEvent) {
503
+ const drag = reviewPopoverThumbDrag
504
+ const wrap = reviewPopoverScrollRef.value
505
+ if (!drag || !wrap) return
506
+ if (drag.maxScroll <= 0) return
507
+ const deltaY = event.clientY - drag.startY
508
+ const nextScroll = drag.startScrollTop + (deltaY / drag.trackTravel) * drag.maxScroll
509
+ wrap.scrollTop = Math.min(Math.max(0, nextScroll), drag.maxScroll)
510
+ syncReviewPopoverThumb()
511
+ }
512
+
513
+ function onReviewPopoverThumbUp() {
514
+ reviewPopoverThumbDrag = null
515
+ window.removeEventListener('mousemove', onReviewPopoverThumbMove)
516
+ window.removeEventListener('mouseup', onReviewPopoverThumbUp)
517
+ //0918 拖完若指針已不在 tip 上,收起條
518
+ const pop = reviewPopoverRef.value
519
+ if (pop && !pop.matches(':hover')) {
520
+ reviewPopoverThumbShow.value = false
521
+ }
522
+ }
523
+
524
+ function resolveReviewPopoverMount() {
525
+ //0920:挂 body + fixed;夹紧边界用中栏 main(见 getMainClampBounds),避免 overflow 裁切又超出打字机
526
+ reviewPopoverTeleportTo.value = 'body'
527
+ return null
528
+ //0920
529
+ }
530
+
531
+ function sanitizeTipContent(text: string) {
532
+ const cleaned = String(text || '')
533
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
534
+ .trim()
535
+ //0920:Markdown 表格行尾的 | 必须保留,否则最后一列会被裁掉
536
+ if (/\|/.test(cleaned) && /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/m.test(cleaned)) {
537
+ return cleaned
538
+ }
539
+ //0920
540
+ return cleaned.replace(/\|+\s*$/g, '')
541
+ }
542
+
543
+ const tipPopoverHtml = computed(() => renderTipMarkdownHtml(activeReviewMarker.value?.content || ''))
544
+
545
+ /** 從父級 reviewMarkers 同步來源檔 */
546
+ function syncMarkerSourceFiles() {
547
+ const next: Record<number, SourceFile> = {}
548
+ for (const marker of props.reviewMarkers) {
549
+ if (marker.sourceFile) next[marker.id] = marker.sourceFile
550
+ }
551
+ markerSourceFiles.value = next
552
+ }
553
+
554
+ function getMarkerSourceFile(markerId: number): SourceFile | undefined {
555
+ return markerSourceFiles.value[markerId]
556
+ }
557
+
558
+ function fileIdFromPreviewUrl(url: string, fallback: string) {
559
+ const raw = String(url || '').trim()
560
+ if (!raw) return fallback
561
+ try {
562
+ const parsed = new URL(raw, window.location.origin)
563
+ return (
564
+ parsed.searchParams.get('pageId') ||
565
+ parsed.searchParams.get('pageContentId') ||
566
+ parsed.searchParams.get('fileIndexId') ||
567
+ parsed.searchParams.get('fileId') ||
568
+ fallback
569
+ )
570
+ } catch {
571
+ return fallback
572
+ }
573
+ }
574
+
575
+ function resolvePreviewFile(marker: EditorReviewMarker): SourceFile | null {
576
+ const fromList = getMarkerSourceFile(marker.id)
577
+ const fromRef = marker.chunkId ? getChartStreamRef(marker.chunkId) : undefined
578
+ const name = String(
579
+ marker.author || marker.linkLabel || fromRef?.sourceName || fromList?.name || ''
580
+ ).trim()
581
+ // 图二:预览组件用 filePath(download);filePreviewUrl 为 wiki 链,不替代 download
582
+ const url = String(
583
+ marker.filePath || fromRef?.filePath || marker.href || fromList?.url || ''
584
+ ).trim()
585
+ const id = String(
586
+ fromList?.id || fileIdFromPreviewUrl(url, marker.chunkId || String(marker.id))
587
+ ).trim()
588
+ if (!name && !url && !id) return null
589
+ return {
590
+ id: id || String(marker.id),
591
+ name: name || fromList?.name || '未命名檔案',
592
+ selected: fromList?.selected ?? true,
593
+ url: url || undefined,
594
+ }
595
+ }
596
+
597
+ async function openSourcePreview(file: SourceFile) {
598
+ if (!isPreviewableFileName(file.name)) {
599
+ ElMessage.warning('暫僅支持預覽 PDF / DOCX / DOC / 圖片')
600
+ return
601
+ }
602
+ if (!String(file.url || '').trim()) {
603
+ ElMessage.warning('缺少檔案地址,無法預覽')
604
+ return
605
+ }
606
+ //0918 先關 tip,避免 Teleport 殘留影響中欄;預覽只開新 tab,不改本頁路由
607
+ reviewPopoverVisible.value = false
608
+ try {
609
+ await openFilePreviewInNewTab({
610
+ id: file.id,
611
+ name: file.name,
612
+ url: file.url,
613
+ })
614
+ } catch (err) {
615
+ ElMessage.warning(
616
+ err instanceof Error && err.message.trim() ? err.message : '無法打開預覽頁'
617
+ )
618
+ }
619
+ }
620
+
621
+ function onSourceNameClick() {
622
+ const marker = activeReviewMarker.value
623
+ if (!marker) return
624
+ const file = resolvePreviewFile(marker)
625
+ if (!file) {
626
+ ElMessage.warning('缺少檔案資訊,無法預覽')
627
+ return
628
+ }
629
+ void openSourcePreview(file)
630
+ }
631
+
632
+ function closeReviewPopover() {
633
+ unbindReviewPopoverWheel()
634
+ cancelHideReviewPopover()
635
+ onReviewPopoverThumbUp()
636
+ reviewPopoverVisible.value = false
637
+ activeReviewMarker.value = null
638
+ reviewPopoverNeedsScroll.value = false
639
+ reviewPopoverThumbShow.value = false
640
+ reviewPopoverThumbY.value = 0
641
+ }
642
+
643
+ function cancelHideReviewPopover() {
644
+ if (!hideReviewPopoverTimer) return
645
+ window.clearTimeout(hideReviewPopoverTimer)
646
+ hideReviewPopoverTimer = 0
647
+ }
648
+
649
+ function scheduleHideReviewPopover() {
650
+ //0918 拖動滾動條時勿關閉 tip
651
+ if (reviewPopoverThumbDrag) return
652
+ cancelHideReviewPopover()
653
+ hideReviewPopoverTimer = window.setTimeout(() => {
654
+ hideReviewPopoverTimer = 0
655
+ if (reviewPopoverThumbDrag) return
656
+ closeReviewPopover()
657
+ }, 160)
658
+ }
659
+
660
+ function formatSourceNameDisplay(name: string) {
661
+ return String(name || '').trim()
662
+ }
663
+
664
+ function getMainClampBounds(_main?: HTMLElement | null) {
665
+ const pad = 8
666
+ //0922:夹在打字机可视滚动区内,不画出中栏正文
667
+ const root = editorRootRef.value
668
+ const scroll = root ? findTypewriterScrollParent(root) : null
669
+ const box = scroll?.getBoundingClientRect() || root?.getBoundingClientRect()
670
+ if (box && box.width > 80 && box.height > 80) {
671
+ return {
672
+ left: box.left + pad,
673
+ top: box.top + pad,
674
+ right: Math.max(box.left + pad + 120, box.right - pad),
675
+ bottom: Math.max(box.top + pad + 80, box.bottom - pad),
676
+ originLeft: 0,
677
+ originTop: 0,
678
+ useAbsolute: false as const,
679
+ }
680
+ }
681
+ const main =
682
+ _main ||
683
+ (root?.closest('.report-edit-page__main--anchored') as HTMLElement | null) ||
684
+ (document.querySelector('.report-edit-page__main--anchored') as HTMLElement | null)
685
+ if (main) {
686
+ const r = main.getBoundingClientRect()
687
+ return {
688
+ left: r.left + pad,
689
+ top: r.top + pad,
690
+ right: r.right - pad,
691
+ bottom: r.bottom - pad,
692
+ originLeft: 0,
693
+ originTop: 0,
694
+ useAbsolute: false as const,
695
+ }
696
+ }
697
+ return {
698
+ left: pad,
699
+ top: pad,
700
+ right: window.innerWidth - pad,
701
+ bottom: window.innerHeight - pad,
702
+ originLeft: 0,
703
+ originTop: 0,
704
+ useAbsolute: false as const,
705
+ }
706
+ }
707
+
708
+ /** //0918 錨在當前 tip 上下:優先下方,不夠則上方;禁止按 18 行滿高把 tip 頂飛到編輯器頂部 */
709
+ function positionReviewPopover(el: HTMLElement, place: 'above' | 'below' | 'auto' = 'auto') {
710
+ resolveReviewPopoverMount()
711
+ const rect = el.getBoundingClientRect()
712
+ const gap = 8
713
+ const bounds = getMainClampBounds()
714
+ const POPOVER_MAX_W = 500
715
+ const maxWidth = Math.min(POPOVER_MAX_W, Math.max(200, bounds.right - bounds.left))
716
+ const width = maxWidth
717
+
718
+ const spaceBelow = Math.max(0, bounds.bottom - rect.bottom - gap)
719
+ const spaceAbove = Math.max(0, rect.top - bounds.top - gap)
720
+ let placement: 'above' | 'below' = place === 'auto' ? 'below' : place
721
+ if (place === 'auto') {
722
+ //0920:屏底 tip 优先上方;下方不足则翻上方
723
+ if (spaceBelow >= 140 && spaceBelow >= spaceAbove) placement = 'below'
724
+ else if (spaceAbove >= 96) placement = 'above'
725
+ else placement = spaceAbove >= spaceBelow ? 'above' : 'below'
726
+ //0920
727
+ }
728
+
729
+ const sideSpace = placement === 'below' ? spaceBelow : spaceAbove
730
+ const maxHeight = Math.min(REVIEW_POPOVER_MAX_H, Math.max(96, sideSpace || REVIEW_POPOVER_MAX_H))
731
+
732
+ let left = rect.left
733
+ if (left + width > bounds.right) left = bounds.right - width
734
+ left = Math.min(Math.max(left, bounds.left), Math.max(bounds.left, bounds.right - width))
735
+
736
+ const guessH = Math.min(80, maxHeight)
737
+ let top = placement === 'below' ? rect.bottom + gap : rect.top - gap - guessH
738
+ if (placement === 'below' && top + guessH > bounds.bottom && spaceAbove >= guessH) {
739
+ placement = 'above'
740
+ top = rect.top - gap - guessH
741
+ }
742
+ if (placement === 'above' && top < bounds.top && spaceBelow >= guessH) {
743
+ placement = 'below'
744
+ top = rect.bottom + gap
745
+ }
746
+ //0920:夹进视口
747
+ top = Math.min(Math.max(top, bounds.top), Math.max(bounds.top, bounds.bottom - guessH))
748
+ //0920
749
+
750
+ const originLeft = 0
751
+ const originTop = 0
752
+
753
+ reviewPopoverStyle.value = {
754
+ //0920
755
+ position: 'fixed',
756
+ left: `${Math.round(left)}px`,
757
+ top: `${Math.round(top)}px`,
758
+ //0920
759
+ transform: 'none',
760
+ width: `${Math.round(width)}px`,
761
+ maxWidth: `${POPOVER_MAX_W}px`,
762
+ height: 'auto',
763
+ maxHeight: `${Math.round(maxHeight)}px`,
764
+ overflow: 'hidden',
765
+ zIndex: '5000',
766
+ }
767
+ return { placement, gap, bounds, rect, originLeft, originTop, maxHeight }
768
+ }
769
+
770
+ /** //0918 按實際高度貼回 tip,絕不因滿高估算被夾到編輯器頂部 */
771
+ function anchorReviewPopoverToCitation(
772
+ citation: HTMLElement,
773
+ placement: 'above' | 'below',
774
+ gap: number,
775
+ bounds: { left: number; top: number; right: number; bottom: number },
776
+ _originLeft: number,
777
+ _originTop: number
778
+ ) {
779
+ const pop = reviewPopoverRef.value
780
+ if (!pop) return placement
781
+ const rect = citation.getBoundingClientRect()
782
+ const popH = Math.max(1, Math.ceil(pop.getBoundingClientRect().height))
783
+ const popW = pop.offsetWidth || 0
784
+
785
+ //0920:fixed 坐标即为视口坐标
786
+ let left = Number.parseFloat(String(reviewPopoverStyle.value.left || '0'))
787
+ if (left + popW > bounds.right) left = bounds.right - popW
788
+ left = Math.min(Math.max(left, bounds.left), Math.max(bounds.left, bounds.right - popW))
789
+ //0920
790
+
791
+ let nextPlacement = placement
792
+ const belowTop = rect.bottom + gap
793
+ const aboveTop = rect.top - gap - popH
794
+ const belowFits = belowTop + popH <= bounds.bottom + 0.5
795
+ const aboveFits = aboveTop >= bounds.top - 0.5
796
+
797
+ let top: number
798
+ if (placement === 'below') {
799
+ if (belowFits) top = belowTop
800
+ else if (aboveFits) {
801
+ nextPlacement = 'above'
802
+ top = aboveTop
803
+ } else {
804
+ //0920:两侧都不够时选空间大的一侧并夹进视口
805
+ nextPlacement = spacePreferAbove(rect, bounds) ? 'above' : 'below'
806
+ top =
807
+ nextPlacement === 'above'
808
+ ? Math.max(bounds.top, Math.min(aboveTop, bounds.bottom - popH))
809
+ : Math.max(bounds.top, Math.min(belowTop, bounds.bottom - popH))
810
+ //0920
811
+ }
812
+ } else if (aboveFits) {
813
+ top = aboveTop
814
+ } else if (belowFits) {
815
+ nextPlacement = 'below'
816
+ top = belowTop
817
+ } else {
818
+ top = Math.max(bounds.top, Math.min(aboveTop, bounds.bottom - popH))
819
+ }
820
+
821
+ //0920:最终再夹一次,保证整框在视口内可见
822
+ top = Math.min(Math.max(top, bounds.top), Math.max(bounds.top, bounds.bottom - popH))
823
+ //0920
824
+
825
+ reviewPopoverStyle.value = {
826
+ ...reviewPopoverStyle.value,
827
+ position: 'fixed',
828
+ left: `${Math.round(left)}px`,
829
+ top: `${Math.round(top)}px`,
830
+ }
831
+ return nextPlacement
832
+ }
833
+
834
+ //0920:屏底 tip 空间判断 + 可用高度
835
+ function spacePreferAbove(
836
+ rect: DOMRect,
837
+ bounds: { top: number; bottom: number }
838
+ ) {
839
+ const below = Math.max(0, bounds.bottom - rect.bottom)
840
+ const above = Math.max(0, rect.top - bounds.top)
841
+ return above >= below
842
+ }
843
+
844
+ function sideSpaceForPlacement(
845
+ citation: HTMLElement,
846
+ placement: 'above' | 'below',
847
+ gap: number,
848
+ bounds: { top: number; bottom: number }
849
+ ) {
850
+ const rect = citation.getBoundingClientRect()
851
+ if (placement === 'below') return Math.max(0, bounds.bottom - rect.bottom - gap)
852
+ return Math.max(0, rect.top - bounds.top - gap)
853
+ }
854
+ //0920
855
+
856
+ function onReviewDocClick(event: MouseEvent) {
857
+ if (!reviewPopoverVisible.value) return
858
+ const target = event.target as HTMLElement | null
859
+ if (target?.closest('.report-chapter-review-popover')) return
860
+ if (target?.closest('.report-tiptap-citation')) return
861
+ closeReviewPopover()
862
+ }
863
+
864
+ function markerFromCitation(citation: HTMLElement): EditorReviewMarker | null {
865
+ //0918:旧 tip(data-tip / reviewMarkers / [+n])+ 新 REF 回填,同一 popup
866
+ const fields = readCitationDataset(citation)
867
+ const tipText = resolveCitationTipText(fields)
868
+ const id = citationIndexFromRefOrDom(fields, fields.citationId)
869
+ const fromList =
870
+ id > 0 ? (props.reviewMarkers.find((item) => item.id === id) ?? null) : null
871
+ if (fromList) {
872
+ return {
873
+ ...fromList,
874
+ id: fromList.id > 0 ? fromList.id : id,
875
+ content: sanitizeTipContent(fromList.content || tipText || fields.tip),
876
+ href: fromList.href || fields.href || fields.filePath,
877
+ linkLabel: fromList.linkLabel || fields.linkLabel || fields.sourceName || fields.href,
878
+ author: fromList.author || fields.sourceName,
879
+ filePath: fromList.filePath || fields.filePath,
880
+ filePreviewUrl: fromList.filePreviewUrl || fields.filePreviewUrl,
881
+ chunkId: fromList.chunkId || fields.chunkId,
882
+ }
883
+ }
884
+ if (!canOpenCitationTip({ ...fields, tip: tipText || fields.tip })) return null
885
+ return {
886
+ id: id > 0 ? id : 0,
887
+ author: fields.sourceName,
888
+ content: sanitizeTipContent(tipText || fields.tip),
889
+ href: fields.href || fields.filePath,
890
+ linkLabel: fields.linkLabel || fields.sourceName || fields.href,
891
+ filePath: fields.filePath,
892
+ filePreviewUrl: fields.filePreviewUrl,
893
+ chunkId: fields.chunkId,
894
+ }
895
+ }
896
+
897
+ function openReviewFromCitation(citation: HTMLElement) {
898
+ cancelHideReviewPopover()
899
+ const marker = markerFromCitation(citation)
900
+ if (!marker) {
901
+ closeReviewPopover()
902
+ return
903
+ }
904
+ activeReviewMarker.value = marker
905
+ const placed = positionReviewPopover(citation)
906
+ reviewPopoverVisible.value = true
907
+ void nextTick(() => {
908
+ if (!reviewPopoverVisible.value || activeReviewMarker.value !== marker) return
909
+ if (!reviewPopoverRef.value) return
910
+ //0918 先按內容撐開/限高,再用實際高度貼回 tip(避免短 tip 被 18 行滿高頂飛)
911
+ //0920:按当侧视口压矮 + 屏底优先上方 + finalize 夹进视口
912
+ const fitAndAnchor = (prefer: 'above' | 'below') => {
913
+ let placement = prefer
914
+ const space0 = sideSpaceForPlacement(citation, placement, placed.gap, placed.bounds)
915
+ layoutReviewPopoverScroll(space0 || placed.maxHeight)
916
+ placement = anchorReviewPopoverToCitation(
917
+ citation,
918
+ placement,
919
+ placed.gap,
920
+ placed.bounds,
921
+ placed.originLeft,
922
+ placed.originTop
923
+ )
924
+ const space1 = sideSpaceForPlacement(citation, placement, placed.gap, placed.bounds)
925
+ layoutReviewPopoverScroll(space1 || space0 || placed.maxHeight)
926
+ placement = anchorReviewPopoverToCitation(
927
+ citation,
928
+ placement,
929
+ placed.gap,
930
+ placed.bounds,
931
+ placed.originLeft,
932
+ placed.originTop
933
+ )
934
+ syncReviewPopoverThumb()
935
+ return placement
936
+ }
937
+ const tipRect = citation.getBoundingClientRect()
938
+ //0920:相对中栏打字机区域中线决定上下,避免相对整屏导致 popup 飞出中栏
939
+ const midY = (placed.bounds.top + placed.bounds.bottom) / 2
940
+ const prefer: 'above' | 'below' = tipRect.top > midY ? 'above' : placed.placement
941
+ fitAndAnchor(prefer)
942
+ requestAnimationFrame(() => {
943
+ if (!reviewPopoverVisible.value || activeReviewMarker.value !== marker) return
944
+ fitAndAnchor(prefer)
945
+ finalizePopoverInViewport(citation)
946
+ })
947
+ //0920
948
+ })
949
+ }
950
+
951
+ //0920:量完实际高度后再夹一次,保证 popup 四边都在中栏打字机内
952
+ function finalizePopoverInViewport(citation: HTMLElement) {
953
+ const pop = reviewPopoverRef.value
954
+ if (!pop || !reviewPopoverVisible.value) return
955
+ const bounds = getMainClampBounds()
956
+ const gap = 8
957
+ const tipRect = citation.getBoundingClientRect()
958
+ let popRect = pop.getBoundingClientRect()
959
+ const midY = (bounds.top + bounds.bottom) / 2
960
+ let placement: 'above' | 'below' = tipRect.top > midY ? 'above' : 'below'
961
+ if (popRect.bottom > bounds.bottom + 1 || popRect.top < bounds.top - 1) {
962
+ const space = sideSpaceForPlacement(citation, placement, gap, bounds)
963
+ layoutReviewPopoverScroll(Math.max(48, space))
964
+ placement = anchorReviewPopoverToCitation(
965
+ citation,
966
+ placement,
967
+ gap,
968
+ bounds,
969
+ 0,
970
+ 0
971
+ )
972
+ popRect = pop.getBoundingClientRect()
973
+ }
974
+ let left = Number.parseFloat(String(reviewPopoverStyle.value.left || '0'))
975
+ let top = Number.parseFloat(String(reviewPopoverStyle.value.top || '0'))
976
+ const w = popRect.width || pop.offsetWidth
977
+ const h = popRect.height || pop.offsetHeight
978
+ if (left + w > bounds.right) left = bounds.right - w
979
+ left = Math.min(Math.max(left, bounds.left), Math.max(bounds.left, bounds.right - w))
980
+ if (top + h > bounds.bottom) top = bounds.bottom - h
981
+ top = Math.min(Math.max(top, bounds.top), Math.max(bounds.top, bounds.bottom - h))
982
+ // 仍超中栏高度:压矮到中栏可用高度
983
+ if (h > bounds.bottom - bounds.top) {
984
+ layoutReviewPopoverScroll(bounds.bottom - bounds.top)
985
+ top = bounds.top
986
+ }
987
+ reviewPopoverStyle.value = {
988
+ ...reviewPopoverStyle.value,
989
+ position: 'fixed',
990
+ left: `${Math.round(left)}px`,
991
+ top: `${Math.round(top)}px`,
992
+ }
993
+ syncReviewPopoverThumb()
994
+ }
995
+ //0920
996
+
997
+ function onEditorBodyMouseOver(event: MouseEvent) {
998
+ const target = event.target as HTMLElement | null
999
+ if (target?.closest('.report-chapter-review-popover')) {
1000
+ cancelHideReviewPopover()
1001
+ return
1002
+ }
1003
+ const citation = target?.closest('.report-tiptap-citation') as HTMLElement | null
1004
+ if (!citation) {
1005
+ if (reviewPopoverVisible.value) scheduleHideReviewPopover()
1006
+ return
1007
+ }
1008
+ openReviewFromCitation(citation)
1009
+ }
1010
+
1011
+ function onEditorBodyClick(event: MouseEvent) {
1012
+ const target = event.target as HTMLElement | null
1013
+ const bodyLink = target?.closest('a.report-tiptap-link') as HTMLAnchorElement | null
1014
+ if (bodyLink?.href) {
1015
+ event.preventDefault()
1016
+ event.stopPropagation()
1017
+ closeReviewPopover()
1018
+ window.open(bodyLink.href, '_blank', 'noopener,noreferrer')
1019
+ return
1020
+ }
1021
+
1022
+ const citation = target?.closest('.report-tiptap-citation') as HTMLElement | null
1023
+ if (!citation) return
1024
+ event.preventDefault()
1025
+ event.stopPropagation()
1026
+ openReviewFromCitation(citation)
1027
+ }
1028
+
1029
+ function onChartStreamRefsUpdated() {
1030
+ //0918:生成章节打字中禁止满文灌入;只按已打进度重渲(REF 晚到也能出 tip)
1031
+ if (!props.disableTypewriter && !props.editable) {
1032
+ if (streamSession) {
1033
+ applyStreamSessionHtml(streamSession)
1034
+ return
1035
+ }
1036
+ if (editor.value) startTypewriterOrSetFull()
1037
+ return
1038
+ }
1039
+ if (isTypewriting.value && streamSession) {
1040
+ applyStreamSessionHtml(streamSession)
1041
+ return
1042
+ }
1043
+ // 0924:refList 入库后,静态正文强制补圆点
1044
+ if (!isTypewriting.value && editor.value) {
1045
+ forceApplyTipHtml(buildContentHtml())
1046
+ }
1047
+ // 0924
1048
+ }
1049
+
1050
+ onMounted(() => {
1051
+ syncMarkerSourceFiles()
1052
+ resolveReviewPopoverMount()
1053
+ document.addEventListener('click', onReviewDocClick, true)
1054
+ window.addEventListener(CHART_STREAM_REFS_UPDATED_EVENT, onChartStreamRefsUpdated)
1055
+ // 0924:编辑器挂上后再匹配一次 refList
1056
+ nextTick(() => {
1057
+ if (!editor.value) return
1058
+ if (!props.disableTypewriter && !props.editable) return
1059
+ forceApplyTipHtml(buildContentHtml())
1060
+ })
1061
+ // 0924
1062
+ })
1063
+
1064
+ onBeforeUnmount(() => {
1065
+ stopTypewriter()
1066
+ unbindReviewPopoverWheel()
1067
+ cancelHideReviewPopover()
1068
+ onReviewPopoverThumbUp()
1069
+ document.removeEventListener('click', onReviewDocClick, true)
1070
+ window.removeEventListener(CHART_STREAM_REFS_UPDATED_EVENT, onChartStreamRefsUpdated)
1071
+ })
1072
+
1073
+ function escapeHtml(value: string) {
1074
+ return value
1075
+ .replace(/&/g, '&amp;')
1076
+ .replace(/</g, '&lt;')
1077
+ .replace(/>/g, '&gt;')
1078
+ .replace(/"/g, '&quot;')
1079
+ }
1080
+
1081
+ /**
1082
+ * tip 用 inline atom,避免 Mark 把打字光標/後續字元吸進圓點(視覺上成「2|」)
1083
+ */
1084
+ const CitationNode = Node.create({
1085
+ name: 'citation',
1086
+ group: 'inline',
1087
+ inline: true,
1088
+ atom: true,
1089
+ selectable: true,
1090
+ draggable: false,
1091
+
1092
+ addOptions() {
1093
+ return {
1094
+ HTMLAttributes: {
1095
+ class: 'report-tiptap-citation',
1096
+ },
1097
+ }
1098
+ },
1099
+
1100
+ addAttributes() {
1101
+ return {
1102
+ id: {
1103
+ default: null,
1104
+ parseHTML: (element) => element.getAttribute('data-citation'),
1105
+ renderHTML: (attributes) => {
1106
+ if (attributes.id == null) return {}
1107
+ return {
1108
+ 'data-citation': attributes.id,
1109
+ class: 'report-tiptap-citation',
1110
+ }
1111
+ },
1112
+ },
1113
+ tip: {
1114
+ default: '',
1115
+ parseHTML: (element) =>
1116
+ element.getAttribute('data-tip') || element.getAttribute('title') || '',
1117
+ renderHTML: (attributes) => {
1118
+ if (!attributes.tip) return {}
1119
+ return { 'data-tip': attributes.tip }
1120
+ },
1121
+ },
1122
+ href: {
1123
+ default: '',
1124
+ parseHTML: (element) => element.getAttribute('data-href') || '',
1125
+ renderHTML: (attributes) => {
1126
+ if (!attributes.href) return {}
1127
+ return { 'data-href': attributes.href }
1128
+ },
1129
+ },
1130
+ linkLabel: {
1131
+ default: '',
1132
+ parseHTML: (element) => element.getAttribute('data-link-label') || '',
1133
+ renderHTML: (attributes) => {
1134
+ if (!attributes.linkLabel) return {}
1135
+ return { 'data-link-label': attributes.linkLabel }
1136
+ },
1137
+ },
1138
+ sourceName: {
1139
+ default: '',
1140
+ parseHTML: (element) => element.getAttribute('data-source-name') || '',
1141
+ renderHTML: (attributes) => {
1142
+ if (!attributes.sourceName) return {}
1143
+ return { 'data-source-name': attributes.sourceName }
1144
+ },
1145
+ },
1146
+ filePath: {
1147
+ default: '',
1148
+ parseHTML: (element) => element.getAttribute('data-file-path') || '',
1149
+ renderHTML: (attributes) => {
1150
+ if (!attributes.filePath) return {}
1151
+ return { 'data-file-path': attributes.filePath }
1152
+ },
1153
+ },
1154
+ filePreviewUrl: {
1155
+ default: '',
1156
+ parseHTML: (element) => element.getAttribute('data-file-preview-url') || '',
1157
+ renderHTML: (attributes) => {
1158
+ if (!attributes.filePreviewUrl) return {}
1159
+ return { 'data-file-preview-url': attributes.filePreviewUrl }
1160
+ },
1161
+ },
1162
+ chunkId: {
1163
+ default: '',
1164
+ parseHTML: (element) => element.getAttribute('data-chunk-id') || '',
1165
+ renderHTML: (attributes) => {
1166
+ if (!attributes.chunkId) return {}
1167
+ return { 'data-chunk-id': attributes.chunkId }
1168
+ },
1169
+ },
1170
+ }
1171
+ },
1172
+
1173
+ parseHTML() {
1174
+ return [{ tag: 'span.report-tiptap-citation' }, { tag: 'span[data-citation]' }]
1175
+ },
1176
+
1177
+ renderHTML({ node, HTMLAttributes }) {
1178
+ const id = String(node.attrs.id ?? '1')
1179
+ return [
1180
+ 'span',
1181
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
1182
+ 'data-citation': id,
1183
+ class: 'report-tiptap-citation',
1184
+ contenteditable: 'false',
1185
+ }),
1186
+ id,
1187
+ ]
1188
+ },
1189
+ })
1190
+
1191
+ function getEditorImageMaxSize(editorDom: HTMLElement | null) {
1192
+ const body = editorDom?.closest('.report-tiptap-editor__body') as HTMLElement | null
1193
+ const maxWidth = Math.max(120, (body?.clientWidth ?? editorDom?.clientWidth ?? 640) - 8)
1194
+ const maxHeight = Math.max(120, (body?.clientHeight ?? 480) - 8)
1195
+ return { maxWidth, maxHeight }
1196
+ }
1197
+
1198
+ function applyImageDomSize(el: HTMLImageElement, width: number, height: number) {
1199
+ const w = Math.max(1, Math.round(width))
1200
+ const h = Math.max(1, Math.round(height))
1201
+ el.style.width = `${w}px`
1202
+ el.style.height = `${h}px`
1203
+ el.setAttribute('width', String(w))
1204
+ el.setAttribute('height', String(h))
1205
+ }
1206
+
1207
+ declare module '@tiptap/core' {
1208
+ interface Commands<ReturnType> {
1209
+ structureBlock: {
1210
+ insertStructureBlock: () => ReturnType
1211
+ }
1212
+ }
1213
+ }
1214
+
1215
+ /** 結構區塊:組織架構圖演示 NodeView */
1216
+ const StructureBlock = Node.create({
1217
+ name: 'structureBlock',
1218
+ group: 'block',
1219
+ atom: true,
1220
+ draggable: true,
1221
+ selectable: true,
1222
+
1223
+ addAttributes() {
1224
+ return {
1225
+ title: {
1226
+ default: '',
1227
+ parseHTML: (element) => element.getAttribute('data-title') || '',
1228
+ renderHTML: (attributes) => ({ 'data-title': attributes.title }),
1229
+ },
1230
+ zoom: {
1231
+ default: 70,
1232
+ parseHTML: (element) => Number(element.getAttribute('data-zoom') || 70),
1233
+ renderHTML: (attributes) => ({ 'data-zoom': attributes.zoom }),
1234
+ },
1235
+ tree: {
1236
+ default: {
1237
+ id: 'root',
1238
+ label: '',
1239
+ pct: 0,
1240
+ children: [],
1241
+ },
1242
+ parseHTML: (element) => {
1243
+ const raw = element.getAttribute('data-tree')
1244
+ if (!raw) {
1245
+ return {
1246
+ id: 'root',
1247
+ label: '',
1248
+ pct: 0,
1249
+ children: [],
1250
+ }
1251
+ }
1252
+ try {
1253
+ return JSON.parse(raw)
1254
+ } catch {
1255
+ return {
1256
+ id: 'root',
1257
+ label: '',
1258
+ pct: 0,
1259
+ children: [],
1260
+ }
1261
+ }
1262
+ },
1263
+ renderHTML: (attributes) => ({
1264
+ 'data-tree': JSON.stringify(attributes.tree ?? null),
1265
+ }),
1266
+ },
1267
+ }
1268
+ },
1269
+
1270
+ parseHTML() {
1271
+ return [{ tag: 'div[data-type="structure-block"]' }]
1272
+ },
1273
+
1274
+ renderHTML({ HTMLAttributes }) {
1275
+ return ['div', mergeAttributes({ 'data-type': 'structure-block' }, HTMLAttributes)]
1276
+ },
1277
+
1278
+ addCommands() {
1279
+ return {
1280
+ insertStructureBlock:
1281
+ () =>
1282
+ ({ commands }) =>
1283
+ commands.insertContent({
1284
+ type: this.name,
1285
+ attrs: {
1286
+ title: '',
1287
+ zoom: 70,
1288
+ tree: {
1289
+ id: 'root',
1290
+ label: '',
1291
+ pct: 0,
1292
+ children: [],
1293
+ },
1294
+ },
1295
+ }),
1296
+ }
1297
+ },
1298
+
1299
+ addNodeView() {
1300
+ return VueNodeViewRenderer(ReportStructureBlockView)
1301
+ },
1302
+ })
1303
+
1304
+ /** 可縮放圖片:手動調整後同步 width/height 到 DOM,並限制不超過編輯器可視高度 */
1305
+ const ResizableReportImage = Image.extend({
1306
+ addAttributes() {
1307
+ return {
1308
+ ...this.parent?.(),
1309
+ width: {
1310
+ default: null,
1311
+ parseHTML: (element) => {
1312
+ const raw = element.getAttribute('width')
1313
+ return raw ? Number(raw) : null
1314
+ },
1315
+ renderHTML: (attributes) => {
1316
+ if (attributes.width == null) return {}
1317
+ return { width: attributes.width }
1318
+ },
1319
+ },
1320
+ height: {
1321
+ default: null,
1322
+ parseHTML: (element) => {
1323
+ const raw = element.getAttribute('height')
1324
+ return raw ? Number(raw) : null
1325
+ },
1326
+ renderHTML: (attributes) => {
1327
+ if (attributes.height == null) return {}
1328
+ return { height: attributes.height }
1329
+ },
1330
+ },
1331
+ }
1332
+ },
1333
+
1334
+ addNodeView() {
1335
+ return ({ node, getPos, HTMLAttributes, editor }) => {
1336
+ const el = document.createElement('img')
1337
+ el.draggable = false
1338
+
1339
+ const mergedAttributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
1340
+ Object.entries(mergedAttributes).forEach(([key, value]) => {
1341
+ if (value == null) return
1342
+ if (key === 'width' || key === 'height') return
1343
+ el.setAttribute(key, String(value))
1344
+ })
1345
+ if (mergedAttributes.src != null) el.src = String(mergedAttributes.src)
1346
+
1347
+ const { maxWidth, maxHeight } = getEditorImageMaxSize(editor.view.dom)
1348
+
1349
+ const nodeView = new ResizableNodeView({
1350
+ element: el,
1351
+ editor,
1352
+ node,
1353
+ getPos,
1354
+ onResize: (width, height) => {
1355
+ applyImageDomSize(el, width, height)
1356
+ },
1357
+ onCommit: (width, height) => {
1358
+ const pos = getPos()
1359
+ if (pos === undefined) return
1360
+ applyImageDomSize(el, width, height)
1361
+ this.editor
1362
+ .chain()
1363
+ .setNodeSelection(pos)
1364
+ .updateAttributes(this.name, {
1365
+ width: Math.round(width),
1366
+ height: Math.round(height),
1367
+ })
1368
+ .run()
1369
+ },
1370
+ onUpdate: (updatedNode) => {
1371
+ if (updatedNode.type.name !== this.name) return false
1372
+ const nextWidth = updatedNode.attrs.width as number | null
1373
+ const nextHeight = updatedNode.attrs.height as number | null
1374
+ if (nextWidth != null && nextHeight != null) {
1375
+ applyImageDomSize(el, nextWidth, nextHeight)
1376
+ }
1377
+ const nextSrc = updatedNode.attrs.src as string | null
1378
+ if (nextSrc && el.getAttribute('src') !== nextSrc) {
1379
+ el.src = nextSrc
1380
+ }
1381
+ return true
1382
+ },
1383
+ options: {
1384
+ directions: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
1385
+ min: { width: 48, height: 48 },
1386
+ max: { width: maxWidth, height: maxHeight },
1387
+ preserveAspectRatio: true,
1388
+ className: {
1389
+ container: 'report-tiptap-image-resize',
1390
+ wrapper: 'report-tiptap-image-resize__wrap',
1391
+ handle: 'report-tiptap-image-resize__handle',
1392
+ resizing: 'is-resizing',
1393
+ },
1394
+ },
1395
+ })
1396
+
1397
+ const initialWidth = node.attrs.width as number | null
1398
+ const initialHeight = node.attrs.height as number | null
1399
+ if (initialWidth != null && initialHeight != null) {
1400
+ applyImageDomSize(el, initialWidth, initialHeight)
1401
+ }
1402
+
1403
+ const dom = nodeView.dom as HTMLElement
1404
+ dom.style.visibility = 'hidden'
1405
+ dom.style.pointerEvents = 'none'
1406
+ el.onload = () => {
1407
+ // 載入後若尚無尺寸,按編輯器上限收縮一次並寫回 attrs/DOM
1408
+ if (node.attrs.width == null || node.attrs.height == null) {
1409
+ const naturalW = el.naturalWidth || el.offsetWidth
1410
+ const naturalH = el.naturalHeight || el.offsetHeight
1411
+ if (naturalW > 0 && naturalH > 0) {
1412
+ let width = naturalW
1413
+ let height = naturalH
1414
+ if (height > maxHeight) {
1415
+ width = Math.round((width * maxHeight) / height)
1416
+ height = maxHeight
1417
+ }
1418
+ if (width > maxWidth) {
1419
+ height = Math.round((height * maxWidth) / width)
1420
+ width = maxWidth
1421
+ }
1422
+ applyImageDomSize(el, width, height)
1423
+ const pos = getPos()
1424
+ if (pos !== undefined) {
1425
+ this.editor
1426
+ .chain()
1427
+ .command(({ tr }: CommandProps) => {
1428
+ tr.setNodeMarkup(pos, undefined, {
1429
+ ...node.attrs,
1430
+ width,
1431
+ height,
1432
+ })
1433
+ return true
1434
+ })
1435
+ .run()
1436
+ }
1437
+ }
1438
+ }
1439
+ dom.style.visibility = ''
1440
+ dom.style.pointerEvents = ''
1441
+ }
1442
+
1443
+ return nodeView
1444
+ }
1445
+ },
1446
+ })
1447
+
1448
+ /** 僅父級標題 + 正文;正文按 Markdown + HTML 解析(接口 sectionContent) */
1449
+ function buildPlainContentHtml() {
1450
+ const title = normalizeReadableSpacing((props.chapterTitle || '').trim())
1451
+ const intro = (props.chapterIntro || '').trim()
1452
+ if (!title && !intro) return ''
1453
+ let html = ''
1454
+ if (title) html += `<h2>${escapeHtml(title)}</h2>`
1455
+ if (intro) html += parseMarkdownToHtml(intro)
1456
+ return html
1457
+ }
1458
+
1459
+ function buildContentHtml() {
1460
+ return buildPlainContentHtml()
1461
+ }
1462
+
1463
+ /**
1464
+ * 流式打字機:
1465
+ * - 按父級傳入的 title/intro 緩衝逐字輸出
1466
+ * - 緩衝追平後進入 waiting,持續等待模型斷續追加
1467
+ * - 追加後從已打進度續打(不整段重來)
1468
+ * - 追平且 settleMs 內無新內容 → complete;緩衝一直為空則永不 complete
1469
+ */
1470
+ /** DeepSeek 風格:約 48 字/秒;單幀 1 單位;中英同一逐字節奏 */
1471
+ //0920
1472
+ const TYPEWRITER_CHARS_PER_SEC = 48
1473
+ const TYPEWRITER_MAX_UNITS_PER_FRAME = 1
1474
+ //0920
1475
+
1476
+ /**
1477
+ * 中英統一逐字(1 碼點);禁止英文整詞坨出。
1478
+ * HTML 标签一次性吞到 `>`,半截标签吞到缓冲末尾(解析层不画),杜绝闪 `<`/`>`。
1479
+ */
1480
+ function nextTypewriterUnitLen(text: string, index: number): number {
1481
+ //0920
1482
+ if (index < 0 || index >= text.length) return 0
1483
+ const ch = text.charAt(index)
1484
+ if (ch === '<') {
1485
+ const next = text.charAt(index + 1)
1486
+ if (!next || /[a-zA-Z/!?]/.test(next)) {
1487
+ const gt = text.indexOf('>', index + 1)
1488
+ if (gt >= 0) return gt - index + 1
1489
+ return Math.max(1, text.length - index)
1490
+ }
1491
+ }
1492
+ //0920:缓冲里已有完整 [ref:…] 一次吞掉,禁止逐字打出 ref 再变成 tip
1493
+ if (ch === '[') {
1494
+ const closed = /^\[ref:[^\]]*\]/i.exec(text.slice(index))
1495
+ if (closed) return closed[0].length
1496
+ }
1497
+ //0920
1498
+ const cp = text.codePointAt(index)
1499
+ if (cp == null) return 0
1500
+ const len = String.fromCodePoint(cp).length
1501
+ //0920
1502
+ return len
1503
+ }
1504
+
1505
+ /** 中英同一節奏,每單位成本恒为 1 */
1506
+ function peekNextTypewriterUnitCost(session: {
1507
+ titleTarget: string
1508
+ introTarget: string
1509
+ titleTyped: number
1510
+ introTyped: number
1511
+ }): number {
1512
+ //0920
1513
+ if (session.titleTyped < session.titleTarget.length) return 1
1514
+ if (session.introTyped < session.introTarget.length) return 1
1515
+ //0920
1516
+ return 1
1517
+ }
1518
+
1519
+ const editorRootRef = ref<HTMLElement | null>(null)
1520
+ const isTypewriting = ref(false)
1521
+ let typewriterRaf = 0
1522
+ let typewriterToken = 0
1523
+ let typewriterSettleTimer = 0
1524
+
1525
+ interface StreamSession {
1526
+ titleTarget: string
1527
+ introTarget: string
1528
+ titleTyped: number
1529
+ introTyped: number
1530
+ lastTs: number
1531
+ charCarry: number
1532
+ waiting: boolean
1533
+ /** 啟動時已有內容(一次性接口灌入) */
1534
+ startedWithContent: boolean
1535
+ /** 過程中緩衝曾變長(斷續流式) */
1536
+ sawContentGrowth: boolean
1537
+ }
1538
+
1539
+ let streamSession: StreamSession | null = null
1540
+
1541
+ function isScrollableY(el: HTMLElement): boolean {
1542
+ const { overflowY } = getComputedStyle(el)
1543
+ const canOverflow =
1544
+ overflowY === 'auto' ||
1545
+ overflowY === 'scroll' ||
1546
+ overflowY === 'overlay' ||
1547
+ el.classList.contains('el-scrollbar__wrap')
1548
+ return canOverflow && el.scrollHeight > el.clientHeight + 1
1549
+ }
1550
+
1551
+ //0918:中栏滚动容器(优先 report-edit;迁移后无 class 时回落)
1552
+ function findTypewriterScrollParent(from: HTMLElement): HTMLElement | null {
1553
+ const main =
1554
+ (from.closest('.report-edit-page__main--anchored') as HTMLElement | null) ||
1555
+ (from.closest('.report-edit-page__main') as HTMLElement | null) ||
1556
+ (document.querySelector('.report-edit-page__main--anchored') as HTMLElement | null) ||
1557
+ (document.querySelector('.report-edit-page__main') as HTMLElement | null)
1558
+ if (main) {
1559
+ const wrap =
1560
+ (main.querySelector('.report-edit-page__col-scroll .el-scrollbar__wrap') as HTMLElement | null) ||
1561
+ (main.querySelector('.el-scrollbar__wrap') as HTMLElement | null)
1562
+ if (wrap) return wrap
1563
+ }
1564
+
1565
+ const col =
1566
+ (from.closest('.report-edit-page__col-scroll') as HTMLElement | null) ||
1567
+ (from.closest('[data-report-main-scroll]') as HTMLElement | null)
1568
+ const wrapInCol = col?.querySelector('.el-scrollbar__wrap') as HTMLElement | null
1569
+ if (wrapInCol) return wrapInCol
1570
+
1571
+ let node: HTMLElement | null = from.parentElement
1572
+ let fallback: HTMLElement | null = null
1573
+ while (node && node !== document.body) {
1574
+ if (node.classList.contains('el-scrollbar__wrap')) fallback = node
1575
+ if (isScrollableY(node)) return node
1576
+ node = node.parentElement
1577
+ }
1578
+ return fallback
1579
+ }
1580
+ //0918
1581
+
1582
+ /** 打字機默認跟滾;用戶拖條 / 滾輪接管後暫停,回到底部再恢復 */
1583
+ let typewriterFollowPaused = false
1584
+ let typewriterAutoScrolling = false
1585
+ let typewriterFollowWrap: HTMLElement | null = null
1586
+ let typewriterFollowBar: HTMLElement | null = null
1587
+ /** 結束打字灌全文時強制釘底,避免 applyEditorHtml 還原到中間位置 */
1588
+ let pinBottomAfterNextApply = false
1589
+ let scrollEaseRaf = 0
1590
+ let finalizePinTimers: number[] = []
1591
+
1592
+ function isNearTypewriterBottom(el: HTMLElement) {
1593
+ const maxTop = el.scrollHeight - el.clientHeight
1594
+ return maxTop <= 0 || el.scrollTop >= maxTop - 48
1595
+ }
1596
+
1597
+ function pauseTypewriterFollow() {
1598
+ if (!isTypewriting.value) return
1599
+ // 人工介入優先:滾輪 / 拖條 / touch 立刻停跟滾,直到用戶回到底部
1600
+ typewriterFollowPaused = true
1601
+ if (scrollEaseRaf) {
1602
+ cancelAnimationFrame(scrollEaseRaf)
1603
+ scrollEaseRaf = 0
1604
+ }
1605
+ }
1606
+
1607
+ function onTypewriterFollowScroll() {
1608
+ if (!isTypewriting.value || typewriterAutoScrolling) return
1609
+ const wrap = typewriterFollowWrap
1610
+ if (!wrap) return
1611
+ if (isNearTypewriterBottom(wrap)) {
1612
+ // 用戶滾回底部 → 恢復自動釘底
1613
+ typewriterFollowPaused = false
1614
+ return
1615
+ }
1616
+ // 非程序滾動且已離開底部 → 視為人工接管(兼容只拖軌道、不經 wheel 的情況)
1617
+ typewriterFollowPaused = true
1618
+ }
1619
+
1620
+ function unbindTypewriterFollowGuards() {
1621
+ if (typewriterFollowWrap) {
1622
+ typewriterFollowWrap.classList.remove('is-typewriter-follow')
1623
+ typewriterFollowWrap.removeEventListener('wheel', pauseTypewriterFollow, true)
1624
+ typewriterFollowWrap.removeEventListener('touchmove', pauseTypewriterFollow, true)
1625
+ typewriterFollowWrap.removeEventListener('scroll', onTypewriterFollowScroll)
1626
+ typewriterFollowWrap = null
1627
+ }
1628
+ if (typewriterFollowBar) {
1629
+ typewriterFollowBar.removeEventListener('pointerdown', pauseTypewriterFollow, true)
1630
+ typewriterFollowBar = null
1631
+ }
1632
+ }
1633
+
1634
+ function bindTypewriterFollowGuards() {
1635
+ const root = editorRootRef.value
1636
+ if (!root) return
1637
+ const wrap = findTypewriterScrollParent(root)
1638
+ if (!wrap) return
1639
+ if (wrap !== typewriterFollowWrap) {
1640
+ unbindTypewriterFollowGuards()
1641
+ typewriterFollowWrap = wrap
1642
+ wrap.classList.add('is-typewriter-follow')
1643
+ // capture:人工操作先於跟滾處理
1644
+ wrap.addEventListener('wheel', pauseTypewriterFollow, { passive: true, capture: true })
1645
+ wrap.addEventListener('touchmove', pauseTypewriterFollow, { passive: true, capture: true })
1646
+ wrap.addEventListener('scroll', onTypewriterFollowScroll, { passive: true })
1647
+ }
1648
+ const scrollbar = wrap.closest('.el-scrollbar')
1649
+ const bar = scrollbar?.querySelector('.el-scrollbar__bar.is-vertical') as HTMLElement | null
1650
+ if (bar && bar !== typewriterFollowBar) {
1651
+ if (typewriterFollowBar) {
1652
+ typewriterFollowBar.removeEventListener('pointerdown', pauseTypewriterFollow, true)
1653
+ }
1654
+ typewriterFollowBar = bar
1655
+ bar.addEventListener('pointerdown', pauseTypewriterFollow, { capture: true })
1656
+ }
1657
+ }
1658
+
1659
+ function withTypewriterAutoScroll(fn: () => void) {
1660
+ typewriterAutoScrolling = true
1661
+ fn()
1662
+ window.setTimeout(() => {
1663
+ typewriterAutoScrolling = false
1664
+ }, 80)
1665
+ }
1666
+
1667
+ function clearFinalizePinTimers() {
1668
+ for (const id of finalizePinTimers) window.clearTimeout(id)
1669
+ finalizePinTimers = []
1670
+ }
1671
+
1672
+ /** 釘到中欄滾動容器最底部(打印中平滑跟進;結束時強制多次補釘) */
1673
+ function pinTypewriterScrollToBottom(options: { smooth?: boolean; force?: boolean } = {}) {
1674
+ const { smooth = true, force = false } = options
1675
+ if (!force && typewriterFollowPaused) return
1676
+ const root = editorRootRef.value
1677
+ if (!root) return
1678
+ const scrollParent = findTypewriterScrollParent(root)
1679
+ if (!scrollParent) return
1680
+ if (!force) bindTypewriterFollowGuards()
1681
+
1682
+ const maxTop = Math.max(0, scrollParent.scrollHeight - scrollParent.clientHeight)
1683
+ if (maxTop <= 0) {
1684
+ withTypewriterAutoScroll(() => {
1685
+ scrollParent.scrollTop = 0
1686
+ })
1687
+ return
1688
+ }
1689
+
1690
+ if (!smooth || force) {
1691
+ if (scrollEaseRaf) {
1692
+ cancelAnimationFrame(scrollEaseRaf)
1693
+ scrollEaseRaf = 0
1694
+ }
1695
+ withTypewriterAutoScroll(() => {
1696
+ scrollParent.scrollTop = maxTop
1697
+ })
1698
+ return
1699
+ }
1700
+
1701
+ const cur = scrollParent.scrollTop
1702
+ const delta = maxTop - cur
1703
+ if (delta <= 0.5) return
1704
+ // 落後過多直接追上;否則緩動,避免內容跳動
1705
+ if (delta > 96) {
1706
+ withTypewriterAutoScroll(() => {
1707
+ scrollParent.scrollTop = maxTop
1708
+ })
1709
+ return
1710
+ }
1711
+ withTypewriterAutoScroll(() => {
1712
+ scrollParent.scrollTop = cur + Math.max(delta * 0.5, Math.min(delta, 28))
1713
+ })
1714
+ }
1715
+
1716
+ /** DeepSeek 式跟隨:打印過程中始終朝容器底部,最新一行留在視口底邊 */
1717
+ function scrollTypewriterLikeDeepSeek() {
1718
+ pinTypewriterScrollToBottom({ smooth: true, force: false })
1719
+ }
1720
+
1721
+ let scrollFollowToken = 0
1722
+
1723
+ /** 佈局穩定後再釘底;結束時多次補釘,避免停在中間 */
1724
+ function scrollTypewriterAfterLayout(finalize = false) {
1725
+ if (!finalize && typewriterFollowPaused) return
1726
+ const token = ++scrollFollowToken
1727
+ const run = () => {
1728
+ if (token !== scrollFollowToken) return
1729
+ if (!finalize && typewriterFollowPaused) return
1730
+ pinTypewriterScrollToBottom({ smooth: !finalize, force: finalize })
1731
+ }
1732
+ run()
1733
+ requestAnimationFrame(() => {
1734
+ requestAnimationFrame(run)
1735
+ })
1736
+ if (!finalize) return
1737
+ clearFinalizePinTimers()
1738
+ for (const ms of [32, 80, 160, 320]) {
1739
+ finalizePinTimers.push(
1740
+ window.setTimeout(() => {
1741
+ if (token !== scrollFollowToken) return
1742
+ pinTypewriterScrollToBottom({ smooth: false, force: true })
1743
+ }, ms)
1744
+ )
1745
+ }
1746
+ }
1747
+
1748
+ function clearTypewriterSettleTimer() {
1749
+ if (typewriterSettleTimer) {
1750
+ window.clearTimeout(typewriterSettleTimer)
1751
+ typewriterSettleTimer = 0
1752
+ }
1753
+ }
1754
+
1755
+ /** 與最終灌入全文一致:不剝句末標點,避免打完後跳變 */
1756
+ function getStreamTargets() {
1757
+ return {
1758
+ title: (props.chapterTitle || '').trim(),
1759
+ // <think> 不进打字机,避免先空打几十秒才出正文
1760
+ intro: stripThinkBlocks(props.chapterIntro || '').trim(),
1761
+ }
1762
+ }
1763
+
1764
+ /** 新緩衝相對已打前綴:可續打則保留進度,否則回退到公共前綴 */
1765
+ function mergeStreamField(
1766
+ prevTarget: string,
1767
+ typed: number,
1768
+ nextTarget: string
1769
+ ): { target: string; typed: number; grew: boolean } {
1770
+ const typedText = prevTarget.slice(0, typed)
1771
+ if (nextTarget.startsWith(typedText)) {
1772
+ return {
1773
+ target: nextTarget,
1774
+ typed,
1775
+ grew: nextTarget.length > prevTarget.length,
1776
+ }
1777
+ }
1778
+ let common = 0
1779
+ const limit = Math.min(typedText.length, nextTarget.length)
1780
+ while (common < limit && typedText[common] === nextTarget[common]) common += 1
1781
+ return {
1782
+ target: nextTarget,
1783
+ typed: common,
1784
+ grew: nextTarget.length > common,
1785
+ }
1786
+ }
1787
+
1788
+ function renderStreamHtml(session: StreamSession): string {
1789
+ let html = ''
1790
+ if (session.titleTarget.length > 0 || session.titleTyped > 0) {
1791
+ html += `<h2>${escapeHtml(session.titleTarget.slice(0, session.titleTyped))}</h2>`
1792
+ }
1793
+ if (
1794
+ session.introTyped > 0 ||
1795
+ (session.titleTyped >= session.titleTarget.length && session.introTarget)
1796
+ ) {
1797
+ html += parseMarkdownToHtmlStream(session.introTarget.slice(0, session.introTyped), {
1798
+ // 追平後把末尾 [ref:uuid] 刷成 tip;打字中則等後續字元,避免光標貼 tip 成「3|」
1799
+ flushTrailingRef: isStreamCaughtUp(session),
1800
+ })
1801
+ }
1802
+ return html
1803
+ }
1804
+
1805
+ function isStreamCaughtUp(session: StreamSession) {
1806
+ return (
1807
+ session.titleTyped >= session.titleTarget.length &&
1808
+ session.introTyped >= session.introTarget.length
1809
+ )
1810
+ }
1811
+
1812
+ function isStreamBufferEmpty(session: StreamSession) {
1813
+ return !session.titleTarget && !session.introTarget
1814
+ }
1815
+
1816
+ function applyStreamSessionHtml(session: StreamSession) {
1817
+ applyEditorHtml(renderStreamHtml(session))
1818
+ //0918:每帧重绑跟滚,避免首帧找不到滚动父级后永久失效
1819
+ bindTypewriterFollowGuards()
1820
+ scrollTypewriterAfterLayout(false)
1821
+ }
1822
+
1823
+ function advanceStreamChars(session: StreamSession, unitCount: number): 'progress' | 'caught_up' {
1824
+ let remain = unitCount
1825
+ while (remain > 0) {
1826
+ if (session.titleTyped < session.titleTarget.length) {
1827
+ // 標題啟動時已滿打;若仍有缺口也按單位推進
1828
+ const take = nextTypewriterUnitLen(session.titleTarget, session.titleTyped)
1829
+ if (take <= 0) break
1830
+ session.titleTyped += take
1831
+ remain -= 1
1832
+ continue
1833
+ }
1834
+ if (session.introTyped < session.introTarget.length) {
1835
+ //0918-1:正文按單位(逐字/逐詞)推進,禁止一次吃整行整段
1836
+ const take = nextTypewriterUnitLen(session.introTarget, session.introTyped)
1837
+ if (take <= 0) break
1838
+ session.introTyped += take
1839
+ remain -= 1
1840
+ //0918-1
1841
+ continue
1842
+ }
1843
+ return 'caught_up'
1844
+ }
1845
+ return isStreamCaughtUp(session) ? 'caught_up' : 'progress'
1846
+ }
1847
+
1848
+ function stopTypewriter() {
1849
+ if (typewriterRaf) {
1850
+ cancelAnimationFrame(typewriterRaf)
1851
+ typewriterRaf = 0
1852
+ }
1853
+ if (scrollEaseRaf) {
1854
+ cancelAnimationFrame(scrollEaseRaf)
1855
+ scrollEaseRaf = 0
1856
+ }
1857
+ clearTypewriterSettleTimer()
1858
+ clearFinalizePinTimers()
1859
+ streamSession = null
1860
+ isTypewriting.value = false
1861
+ typewriterFollowPaused = false
1862
+ unbindTypewriterFollowGuards()
1863
+ }
1864
+
1865
+ function finishTypewriter() {
1866
+ // 0924:记下已打长度,流还在推时下一段从这里续,不要整篇重画
1867
+ lastFinishedIntroLen = streamSession?.introTyped ?? lastFinishedIntroLen
1868
+ //0920:收尾强制 setContent 满文(含 tip);再延迟一拍兜 REF 晚到
1869
+ pinBottomAfterNextApply = true
1870
+ stopTypewriter()
1871
+ forceApplyTipHtml(buildContentHtml())
1872
+ staticCommittedKey = staticContentKey()
1873
+ pinBottomAfterNextApply = false
1874
+ scrollTypewriterAfterLayout(true)
1875
+ emit('typewriter-complete')
1876
+ window.setTimeout(() => {
1877
+ //0920:生成中 / 打字中勿抢灌,否则 SSE 续打被满文 setContent 打断
1878
+ if (!editor.value || props.editable) return
1879
+ if (!props.disableTypewriter || isTypewriting.value) return
1880
+ forceApplyTipHtml(buildContentHtml())
1881
+ }, 80)
1882
+ //0920
1883
+ }
1884
+
1885
+ /**
1886
+ * 追平後:
1887
+ * - 緩衝空 → 持續等首包
1888
+ * - 否則 → settle 等待斷續追加(生成章节禁止跳过打字直接灌全文)
1889
+ */
1890
+ function onStreamCaughtUp(token: number, session: StreamSession) {
1891
+ session.waiting = true
1892
+ isTypewriting.value = true
1893
+ applyStreamSessionHtml(session)
1894
+
1895
+ if (isStreamBufferEmpty(session) || !session.introTarget) {
1896
+ clearTypewriterSettleTimer()
1897
+ return
1898
+ }
1899
+
1900
+ //0918:一次性接口也须先逐字打完(此处已 caught up),再走 settle;禁止未打完灌全文
1901
+ if (!typewriterSettleTimer) scheduleStreamSettle(token)
1902
+ }
1903
+
1904
+ function scheduleStreamSettle(token: number) {
1905
+ clearTypewriterSettleTimer()
1906
+ // 0924:流还在推时保持逐字等待。此时收口会整篇重画,下一段再从头打
1907
+ if (props.streamPending) return
1908
+ // 0924
1909
+ const settleMs = Math.max(0, props.typewriterSettleMs)
1910
+ if (settleMs <= 0) {
1911
+ finishTypewriter()
1912
+ return
1913
+ }
1914
+ typewriterSettleTimer = window.setTimeout(() => {
1915
+ typewriterSettleTimer = 0
1916
+ if (token !== typewriterToken) return
1917
+ const session = streamSession
1918
+ if (!session || !isStreamCaughtUp(session) || isStreamBufferEmpty(session)) return
1919
+ // 0924:定时器触发时流已重新开始,继续等,不收口
1920
+ if (props.streamPending) return
1921
+ // 0924
1922
+ finishTypewriter()
1923
+ }, settleMs)
1924
+ }
1925
+
1926
+ // 0924:流结束且已经追平,这时才收口。打字还没追平则继续等
1927
+ watch(
1928
+ () => props.streamPending,
1929
+ (pending) => {
1930
+ if (pending || props.disableTypewriter || props.editable) return
1931
+ const session = streamSession
1932
+ if (!session || !isTypewriting.value) return
1933
+ if (!isStreamCaughtUp(session) || isStreamBufferEmpty(session) || !session.introTarget) return
1934
+ finishTypewriter()
1935
+ }
1936
+ )
1937
+ // 0924
1938
+
1939
+ function ensureTypewriterRaf(token: number) {
1940
+ if (typewriterRaf) return
1941
+ typewriterRaf = requestAnimationFrame((ts) => streamFrame(token, ts))
1942
+ }
1943
+
1944
+ function streamFrame(token: number, ts: number) {
1945
+ typewriterRaf = 0
1946
+ if (token !== typewriterToken || !editor.value) {
1947
+ stopTypewriter()
1948
+ return
1949
+ }
1950
+ //0918:仅关闭打字机 / 可编辑 / done 才满文;生成中保持逐字推进
1951
+ if (props.editable || props.disableTypewriter || props.typewriterPhase === 'done') {
1952
+ //0920:收尾落 tip;勿在「仍应打字」时误停(由 disableTypewriter 判定)
1953
+ stopTypewriter()
1954
+ if (props.disableTypewriter || props.typewriterPhase === 'done') {
1955
+ forceApplyTipHtml(buildContentHtml())
1956
+ }
1957
+ //0920
1958
+ return
1959
+ }
1960
+ if (props.typewriterPhase === 'idle') {
1961
+ stopTypewriter()
1962
+ applyEditorHtml('')
1963
+ return
1964
+ }
1965
+
1966
+ const session = streamSession
1967
+ if (!session) return
1968
+
1969
+ if (!session.lastTs) session.lastTs = ts
1970
+ const dt = Math.min(48, ts - session.lastTs)
1971
+ session.lastTs = ts
1972
+
1973
+ if (isStreamCaughtUp(session)) {
1974
+ onStreamCaughtUp(token, session)
1975
+ return
1976
+ }
1977
+
1978
+ session.waiting = false
1979
+ //0920:中英统一逐字,每单位成本 1
1980
+ session.charCarry += (TYPEWRITER_CHARS_PER_SEC * dt) / 1000
1981
+ const unitCost = peekNextTypewriterUnitCost(session)
1982
+ if (session.charCarry < unitCost) {
1983
+ ensureTypewriterRaf(token)
1984
+ return
1985
+ }
1986
+ session.charCarry -= unitCost
1987
+ const n = TYPEWRITER_MAX_UNITS_PER_FRAME
1988
+ //0920
1989
+
1990
+ if (n > 0) {
1991
+ const result = advanceStreamChars(session, n)
1992
+ applyStreamSessionHtml(session)
1993
+ if (result === 'caught_up') {
1994
+ onStreamCaughtUp(token, session)
1995
+ return
1996
+ }
1997
+ }
1998
+
1999
+ ensureTypewriterRaf(token)
2000
+ }
2001
+
2002
+ /** 父級斷續回寫 title/intro:合併緩衝並續打,不整段重啟 */
2003
+ function onStreamContentUpdated() {
2004
+ if (props.editable || props.disableTypewriter) return
2005
+ if (props.typewriterPhase === 'idle' || props.typewriterPhase === 'done') return
2006
+
2007
+ const { title, intro } = getStreamTargets()
2008
+ if (!streamSession || !isTypewriting.value) {
2009
+ startTypewriterOrSetFull()
2010
+ return
2011
+ }
2012
+
2013
+ const session = streamSession
2014
+ const prevTitleLen = session.titleTarget.length
2015
+ const prevIntroLen = session.introTarget.length
2016
+ const nextTitle = mergeStreamField(session.titleTarget, session.titleTyped, title)
2017
+ const nextIntro = mergeStreamField(session.introTarget, session.introTyped, intro)
2018
+ session.titleTarget = nextTitle.target
2019
+ session.titleTyped = nextTitle.typed
2020
+ session.introTarget = nextIntro.target
2021
+ session.introTyped = nextIntro.typed
2022
+
2023
+ if (
2024
+ nextTitle.grew ||
2025
+ nextIntro.grew ||
2026
+ session.titleTarget.length > prevTitleLen ||
2027
+ session.introTarget.length > prevIntroLen
2028
+ ) {
2029
+ session.sawContentGrowth = true
2030
+ }
2031
+
2032
+ clearTypewriterSettleTimer()
2033
+ if (isStreamCaughtUp(session)) {
2034
+ onStreamCaughtUp(typewriterToken, session)
2035
+ return
2036
+ }
2037
+
2038
+ session.waiting = false
2039
+ applyStreamSessionHtml(session)
2040
+ ensureTypewriterRaf(typewriterToken)
2041
+ }
2042
+
2043
+ function applyPhaseContent() {
2044
+ stopTypewriter()
2045
+ if (!editor.value) return
2046
+
2047
+ if (props.editable || props.disableTypewriter || props.typewriterPhase === 'done') {
2048
+ applyEditorHtml(buildContentHtml())
2049
+ return
2050
+ }
2051
+
2052
+ if (props.typewriterPhase === 'idle') {
2053
+ applyEditorHtml('')
2054
+ return
2055
+ }
2056
+
2057
+ // auto / typing → 打字機
2058
+ startTypewriterOrSetFull()
2059
+ }
2060
+
2061
+ /** 只讀:流式打字機;可編輯 / disableTypewriter / done:直接滿內容 */
2062
+ function startTypewriterOrSetFull() {
2063
+ stopTypewriter()
2064
+ if (!editor.value) return
2065
+
2066
+ if (props.editable || props.disableTypewriter || props.typewriterPhase === 'done') {
2067
+ //0920:静态满文含 tip 时强制 setContent
2068
+ const full = buildContentHtml().trim()
2069
+ if (full.includes('report-tiptap-citation')) {
2070
+ forceApplyTipHtml(full)
2071
+ return
2072
+ }
2073
+ if (
2074
+ props.disableTypewriter &&
2075
+ !props.editable &&
2076
+ staticCommittedKey &&
2077
+ staticCommittedKey === staticContentKey() &&
2078
+ lastAppliedEditorHtml &&
2079
+ full === lastAppliedEditorHtml
2080
+ ) {
2081
+ return
2082
+ }
2083
+ applyEditorHtml(full)
2084
+ //0920
2085
+ return
2086
+ }
2087
+
2088
+ if (props.typewriterPhase === 'idle') {
2089
+ applyEditorHtml('')
2090
+ return
2091
+ }
2092
+
2093
+ const { title, intro } = getStreamTargets()
2094
+ const hasIntro = !!intro
2095
+ const token = ++typewriterToken
2096
+ // 0924:下一段从上次打完的位置继续,避免整篇重画再从头打
2097
+ let introTyped = 0
2098
+ if (
2099
+ lastFinishedIntroLen > 0 &&
2100
+ intro.length > lastFinishedIntroLen &&
2101
+ !props.disableTypewriter
2102
+ ) {
2103
+ introTyped = lastFinishedIntroLen
2104
+ }
2105
+ lastFinishedIntroLen = 0
2106
+ // 章節標題保留不動:啟動時一次顯示完整標題,只對正文做流式打印
2107
+ const titleTyped = title.length
2108
+ streamSession = {
2109
+ titleTarget: title,
2110
+ introTarget: intro,
2111
+ titleTyped,
2112
+ introTyped,
2113
+ lastTs: 0,
2114
+ charCarry: 0,
2115
+ waiting: !hasIntro,
2116
+ startedWithContent: hasIntro,
2117
+ sawContentGrowth: false,
2118
+ }
2119
+ isTypewriting.value = true
2120
+ typewriterFollowPaused = false
2121
+ //0920:开打字机必须清掉静态 tip DOM + lastApplied,否则 diff 卡死、SSE 正文不渲染
2122
+ staticCommittedKey = ''
2123
+ lastAppliedEditorHtml = ''
2124
+ {
2125
+ const bootHtml = renderStreamHtml(streamSession).trim()
2126
+ if (bootHtml) editor.value.commands.setContent(bootHtml, { emitUpdate: false })
2127
+ else editor.value.commands.clearContent(true)
2128
+ lastAppliedEditorHtml = bootHtml
2129
+ removeTrailingEmptyParagraphs()
2130
+ }
2131
+ //0920
2132
+ bindTypewriterFollowGuards()
2133
+ scrollTypewriterAfterLayout(false)
2134
+ // 正文仍空:持續等待 props 更新;有正文則開始續打
2135
+ if (title || hasIntro) ensureTypewriterRaf(token)
2136
+ }
2137
+
2138
+ function isEmptyParagraphNode(node: ProseMirrorNode) {
2139
+ if (node.type.name !== 'paragraph') return false
2140
+ return node.content.size === 0 || !node.textContent.trim()
2141
+ }
2142
+
2143
+ /** TipTap 解析 table 等塊級節點後常自動補尾部空 <p>,造成章節間大片空白 */
2144
+ function removeTrailingEmptyParagraphs() {
2145
+ const ed = editor.value
2146
+ if (!ed) return
2147
+
2148
+ let guard = 0
2149
+ while (guard < 12) {
2150
+ guard += 1
2151
+ const { doc } = ed.state
2152
+ if (doc.childCount <= 1) break
2153
+ const last = doc.lastChild
2154
+ if (!last || !isEmptyParagraphNode(last)) break
2155
+ const from = doc.content.size - last.nodeSize
2156
+ const to = doc.content.size
2157
+ const ok = ed
2158
+ .chain()
2159
+ .command(({ tr, dispatch }: CommandProps) => {
2160
+ if (dispatch) {
2161
+ tr.delete(from, to)
2162
+ dispatch(tr)
2163
+ }
2164
+ return true
2165
+ })
2166
+ .run()
2167
+ if (!ok) break
2168
+ }
2169
+ }
2170
+
2171
+ function isPmDocNode(value: unknown): value is ProseMirrorNode {
2172
+ return (
2173
+ !!value &&
2174
+ typeof value === 'object' &&
2175
+ 'type' in value &&
2176
+ 'content' in value &&
2177
+ 'nodeSize' in value
2178
+ )
2179
+ }
2180
+
2181
+ function applyEditorHtmlDiff(ed: Editor, html: string) {
2182
+ const parsed = createNodeFromContent(html, ed.schema, { slice: false })
2183
+ if (!isPmDocNode(parsed)) {
2184
+ ed.commands.setContent(html, { emitUpdate: false })
2185
+ return
2186
+ }
2187
+
2188
+ ed.chain()
2189
+ .command(({ tr, dispatch }: CommandProps) => {
2190
+ if (!dispatch) return true
2191
+ const oldDoc = tr.doc
2192
+ const start = oldDoc.content.findDiffStart(parsed.content)
2193
+ if (start == null) return true
2194
+ const diffEnd = oldDoc.content.findDiffEnd(parsed.content)
2195
+ let endA = oldDoc.content.size
2196
+ let endB = parsed.content.size
2197
+ if (diffEnd) {
2198
+ endA = diffEnd.a
2199
+ endB = diffEnd.b
2200
+ const overlap = start - Math.min(endA, endB)
2201
+ if (overlap > 0) {
2202
+ endA += overlap
2203
+ endB += overlap
2204
+ }
2205
+ }
2206
+ try {
2207
+ tr.replace(start, endA, parsed.slice(start, endB))
2208
+ } catch {
2209
+ tr.replaceWith(0, oldDoc.content.size, parsed.content)
2210
+ }
2211
+ tr.setMeta('preventUpdate', true)
2212
+ tr.setMeta('addToHistory', false)
2213
+ dispatch(tr)
2214
+ return true
2215
+ })
2216
+ .run()
2217
+ }
2218
+
2219
+ let lastAppliedEditorHtml = ''
2220
+ //0920:静态满文指纹(仅挡同 HTML;tip 走 forceApplyTipHtml)
2221
+ let staticCommittedKey = ''
2222
+ /** // 0924:打字机追平后,下一段从这里续打,不从 0 清空重来 */
2223
+ let lastFinishedIntroLen = 0
2224
+
2225
+ function staticContentKey() {
2226
+ return `${props.chapterTitle}\0${props.chapterIntro}`
2227
+ }
2228
+
2229
+ /** DOM 里是否已有 citation tip 节点 */
2230
+ function editorHasCitationNodes() {
2231
+ const ed = editor.value
2232
+ if (!ed) return false
2233
+ let found = false
2234
+ ed.state.doc.descendants((node) => {
2235
+ if (node.type.name === 'citation') {
2236
+ found = true
2237
+ return false
2238
+ }
2239
+ return !found
2240
+ })
2241
+ return found
2242
+ }
2243
+
2244
+ /** DOM 里 citation 圆点个数 */
2245
+ function editorCitationCount() {
2246
+ const ed = editor.value
2247
+ if (!ed) return 0
2248
+ let count = 0
2249
+ ed.state.doc.descendants((node) => {
2250
+ if (node.type.name === 'citation') count += 1
2251
+ })
2252
+ return count
2253
+ }
2254
+
2255
+ function htmlCitationCount(html: string) {
2256
+ const matched = html.match(/class="report-tiptap-citation"/g)
2257
+ return matched ? matched.length : 0
2258
+ }
2259
+
2260
+ /**
2261
+ * //0920 tip/REF 专用灌文:必须 setContent,禁止 applyEditorHtmlDiff
2262
+ * 根因:diff 在 tip 原子节点场景可能 findDiffStart=null 直接 return,
2263
+ * 但 lastApplied 已被写成含 tip 的 HTML → 圆点永远不出现
2264
+ */
2265
+ function forceApplyTipHtml(html: string) {
2266
+ const ed = editor.value
2267
+ if (!ed) return
2268
+ const trimmed = String(html || '').trim()
2269
+ if (!trimmed) return
2270
+
2271
+ const htmlCount = htmlCitationCount(trimmed)
2272
+ const domCount = editorCitationCount()
2273
+ // 0924:同 HTML 且圆点数量已齐才跳过。DOM 少圆点时即使字符串记过也要补灌
2274
+ if (trimmed === lastAppliedEditorHtml && htmlCount <= domCount) return
2275
+ // 0924
2276
+
2277
+ const root = editorRootRef.value
2278
+ const scrollParent = root ? findTypewriterScrollParent(root) : null
2279
+ const prevTop = scrollParent?.scrollTop ?? 0
2280
+ const shouldPinBottom =
2281
+ pinBottomAfterNextApply || (isTypewriting.value && !typewriterFollowPaused)
2282
+
2283
+ ed.commands.setContent(trimmed, { emitUpdate: false })
2284
+ removeTrailingEmptyParagraphs()
2285
+ lastAppliedEditorHtml = trimmed
2286
+ if (props.disableTypewriter && !props.editable) {
2287
+ staticCommittedKey = staticContentKey()
2288
+ } else {
2289
+ staticCommittedKey = ''
2290
+ }
2291
+
2292
+ if (!scrollParent) return
2293
+ if (shouldPinBottom) {
2294
+ pinTypewriterScrollToBottom({
2295
+ smooth: isTypewriting.value && !pinBottomAfterNextApply,
2296
+ force: pinBottomAfterNextApply || !isTypewriting.value,
2297
+ })
2298
+ return
2299
+ }
2300
+ scrollParent.scrollTop = prevTop
2301
+ }
2302
+ //0920
2303
+
2304
+ function applyEditorHtml(html: string) {
2305
+ const ed = editor.value
2306
+ if (!ed) return
2307
+
2308
+ const trimmed = html.trim()
2309
+
2310
+ // 0924:打字中只走 diff 续打;圆点数量对不上时才整帧写入,写成功后再记
2311
+ if (!props.disableTypewriter && !props.editable && props.typewriterPhase !== 'done') {
2312
+ const tipCount = htmlCitationCount(trimmed)
2313
+ if (trimmed === lastAppliedEditorHtml) {
2314
+ if (tipCount > editorCitationCount()) forceApplyTipHtml(trimmed)
2315
+ return
2316
+ }
2317
+ staticCommittedKey = ''
2318
+ if (!trimmed) {
2319
+ lastAppliedEditorHtml = ''
2320
+ ed.commands.clearContent(true)
2321
+ return
2322
+ }
2323
+ applyEditorHtmlDiff(ed, trimmed)
2324
+ removeTrailingEmptyParagraphs()
2325
+ if (tipCount > editorCitationCount()) {
2326
+ forceApplyTipHtml(trimmed)
2327
+ return
2328
+ }
2329
+ lastAppliedEditorHtml = trimmed
2330
+ const root = editorRootRef.value
2331
+ const scrollParent = root ? findTypewriterScrollParent(root) : null
2332
+ if (!scrollParent) return
2333
+ const shouldPinBottom =
2334
+ pinBottomAfterNextApply || (isTypewriting.value && !typewriterFollowPaused)
2335
+ if (shouldPinBottom) {
2336
+ pinTypewriterScrollToBottom({
2337
+ smooth: isTypewriting.value && !pinBottomAfterNextApply,
2338
+ force: pinBottomAfterNextApply || !isTypewriting.value,
2339
+ })
2340
+ }
2341
+ return
2342
+ }
2343
+ // 0924
2344
+
2345
+ if (trimmed === lastAppliedEditorHtml) {
2346
+ // 0924:字符串已记过 tip,但 DOM 圆点数量不够 → 强制补灌
2347
+ if (htmlCitationCount(trimmed) > editorCitationCount()) {
2348
+ forceApplyTipHtml(trimmed)
2349
+ }
2350
+ // 0924
2351
+ return
2352
+ }
2353
+
2354
+ const htmlHasTip = trimmed.includes('report-tiptap-citation')
2355
+ const domHasTip = editorHasCitationNodes()
2356
+
2357
+ //0920:tip 灌文策略(不破坏打字机续打 / 静态防闪 / SSE 渲染)
2358
+ // - 打字中且 DOM 仍留着旧 tip、当前帧无 tip → 必须 setContent 清掉,否则 diff 卡死 SSE
2359
+ // - DOM 尚无 tip 且 HTML 有 tip → force setContent(diff 会吞 citation)
2360
+ // - 静态满文且 HTML 含 tip → force(REF 晚到)
2361
+ // - 打字中已有 tip 且 HTML 也有 tip → 继续 diff,避免每帧全量 setContent 闪动
2362
+ if (!props.disableTypewriter && !props.editable && domHasTip && !htmlHasTip) {
2363
+ if (trimmed) ed.commands.setContent(trimmed, { emitUpdate: false })
2364
+ else ed.commands.clearContent(true)
2365
+ removeTrailingEmptyParagraphs()
2366
+ lastAppliedEditorHtml = trimmed
2367
+ staticCommittedKey = ''
2368
+ return
2369
+ }
2370
+ if (htmlCitationCount(trimmed) > editorCitationCount()) {
2371
+ forceApplyTipHtml(trimmed)
2372
+ return
2373
+ }
2374
+ if (htmlHasTip && props.disableTypewriter && !props.editable) {
2375
+ forceApplyTipHtml(trimmed)
2376
+ return
2377
+ }
2378
+ if (props.disableTypewriter && !props.editable) {
2379
+ if (
2380
+ staticCommittedKey &&
2381
+ staticCommittedKey === staticContentKey() &&
2382
+ lastAppliedEditorHtml
2383
+ ) {
2384
+ return
2385
+ }
2386
+ }
2387
+ //0920
2388
+
2389
+ lastAppliedEditorHtml = trimmed
2390
+
2391
+ const root = editorRootRef.value
2392
+ const scrollParent = root ? findTypewriterScrollParent(root) : null
2393
+ const prevTop = scrollParent?.scrollTop ?? 0
2394
+ const shouldPinBottom =
2395
+ pinBottomAfterNextApply || (isTypewriting.value && !typewriterFollowPaused)
2396
+
2397
+ if (!trimmed) {
2398
+ ed.commands.clearContent(true)
2399
+ staticCommittedKey = ''
2400
+ return
2401
+ }
2402
+
2403
+ applyEditorHtmlDiff(ed, trimmed)
2404
+ removeTrailingEmptyParagraphs()
2405
+
2406
+ //0920:diff 后 tip 被吞则补一次 force(不影响无 tip 的防闪路径)
2407
+ if (htmlHasTip && !editorHasCitationNodes()) {
2408
+ lastAppliedEditorHtml = ''
2409
+ forceApplyTipHtml(trimmed)
2410
+ return
2411
+ }
2412
+ //0920
2413
+
2414
+ if (props.disableTypewriter && !props.editable) {
2415
+ staticCommittedKey = staticContentKey()
2416
+ } else {
2417
+ staticCommittedKey = ''
2418
+ }
2419
+
2420
+ if (!scrollParent) return
2421
+ if (shouldPinBottom) {
2422
+ pinTypewriterScrollToBottom({
2423
+ smooth: isTypewriting.value && !pinBottomAfterNextApply,
2424
+ force: pinBottomAfterNextApply || !isTypewriting.value,
2425
+ })
2426
+ return
2427
+ }
2428
+ scrollParent.scrollTop = prevTop
2429
+ }
2430
+
2431
+ /**
2432
+ * 斜體:瀏覽器對中文幾乎不做 italic/oblique 合成(即使 font-synthesis:style)。
2433
+ * 用 inline-block + skew 強制可見,樣式寫進 DOM 避免 scoped CSS 未命中。
2434
+ */
2435
+ const ReportItalic = ItalicExtension.extend({
2436
+ renderHTML({ HTMLAttributes }) {
2437
+ return [
2438
+ 'em',
2439
+ mergeAttributes(HTMLAttributes, {
2440
+ class: 'report-tiptap-italic',
2441
+ style: 'font-style:italic;font-synthesis:style;color:inherit',
2442
+ }),
2443
+ 0,
2444
+ ]
2445
+ },
2446
+ parseHTML() {
2447
+ return [
2448
+ { tag: 'span.report-tiptap-italic' },
2449
+ { tag: 'em.report-tiptap-italic' },
2450
+ { tag: 'em' },
2451
+ { tag: 'i' },
2452
+ { style: 'font-style=italic' },
2453
+ { style: 'font-style=oblique' },
2454
+ ]
2455
+ },
2456
+ })
2457
+
2458
+ const editor = useEditor({
2459
+ editable: false,
2460
+ extensions: [
2461
+ StarterKit.configure({
2462
+ italic: false,
2463
+ heading: { levels: [1, 2, 3, 4, 5, 6] },
2464
+ blockquote: {
2465
+ HTMLAttributes: {
2466
+ class: 'report-tiptap-remark',
2467
+ },
2468
+ },
2469
+ }),
2470
+ ReportItalic,
2471
+ Underline,
2472
+ CitationNode,
2473
+ LinkExtension.configure({
2474
+ openOnClick: false,
2475
+ autolink: true,
2476
+ HTMLAttributes: {
2477
+ class: 'report-tiptap-link',
2478
+ target: '_blank',
2479
+ rel: 'noopener noreferrer',
2480
+ },
2481
+ }),
2482
+ ResizableReportImage.configure({
2483
+ allowBase64: true,
2484
+ HTMLAttributes: { class: 'report-tiptap-image' },
2485
+ resize: {
2486
+ enabled: false,
2487
+ alwaysPreserveAspectRatio: true,
2488
+ minWidth: 48,
2489
+ minHeight: 48,
2490
+ },
2491
+ }),
2492
+ TableKit.configure({
2493
+ table: { resizable: true },
2494
+ }),
2495
+ StructureBlock,
2496
+ ],
2497
+ content: '',
2498
+ editorProps: {
2499
+ attributes: {
2500
+ class: 'report-tiptap-editor__prose',
2501
+ },
2502
+ handleTextInput: () => true,
2503
+ handlePaste: () => true,
2504
+ handleDrop: () => true,
2505
+ },
2506
+ onCreate: () => {
2507
+ // 等 useEditor 把實例寫入 ref 後再啟動(避免 onCreate 同步觸發時 editor 仍為 null)
2508
+ nextTick(() => applyPhaseContent())
2509
+ },
2510
+ })
2511
+
2512
+ watch(
2513
+ () => props.editable,
2514
+ () => {
2515
+ editor.value?.setEditable(false)
2516
+ }
2517
+ )
2518
+
2519
+ /** 僅更新已渲染標題文字(拖拽改序號時用,避免重跑打字機) */
2520
+ function patchChapterTitleOnly(title: string) {
2521
+ const ed = editor.value
2522
+ if (!ed || ed.isEmpty) return false
2523
+ const { doc } = ed.state
2524
+ let headingPos = -1
2525
+ doc.descendants((node: ProseMirrorNode, pos: number) => {
2526
+ if (headingPos >= 0) return false
2527
+ if (node.type.name === 'heading') {
2528
+ headingPos = pos
2529
+ return false
2530
+ }
2531
+ return true
2532
+ })
2533
+ if (headingPos < 0) return false
2534
+ const heading = doc.nodeAt(headingPos)
2535
+ if (!heading) return false
2536
+ const from = headingPos + 1
2537
+ const to = headingPos + heading.nodeSize - 1
2538
+ ed.chain()
2539
+ .command(({ tr, dispatch }: CommandProps) => {
2540
+ if (dispatch) {
2541
+ tr.insertText(title || '', from, to)
2542
+ dispatch(tr)
2543
+ }
2544
+ return true
2545
+ })
2546
+ .run()
2547
+ return true
2548
+ }
2549
+
2550
+ watch(
2551
+ () => props.typewriterPhase,
2552
+ (phase, prev) => {
2553
+ if (phase === prev) return
2554
+ // done:typing 剛打完時內容已是滿文,勿重設以免高度抖動、滾動丟到底
2555
+ if (phase === 'done') {
2556
+ stopTypewriter()
2557
+ if (prev === 'typing') return
2558
+ applyEditorHtml(buildContentHtml())
2559
+ return
2560
+ }
2561
+ if (phase === 'idle') {
2562
+ stopTypewriter()
2563
+ applyEditorHtml('')
2564
+ return
2565
+ }
2566
+ if (phase === 'typing' || phase === 'auto') {
2567
+ startTypewriterOrSetFull()
2568
+ }
2569
+ }
2570
+ )
2571
+
2572
+ watch(
2573
+ () => props.reviewMarkers,
2574
+ () => {
2575
+ syncMarkerSourceFiles()
2576
+ },
2577
+ { deep: true }
2578
+ )
2579
+
2580
+ watch(
2581
+ () =>
2582
+ [
2583
+ props.contentIdentity,
2584
+ props.chapterTitle,
2585
+ props.chapterIntro,
2586
+ props.showReviewMarkers,
2587
+ props.disableTypewriter,
2588
+ ] as const,
2589
+ (next, prev) => {
2590
+ if (props.typewriterPhase === 'done') {
2591
+ if (!editor.value) return
2592
+ if (!isTypewriting.value) applyEditorHtml(buildContentHtml())
2593
+ return
2594
+ }
2595
+ if (props.typewriterPhase === 'idle') {
2596
+ applyEditorHtml('')
2597
+ return
2598
+ }
2599
+ if (props.showReviewMarkers) syncMarkerSourceFiles()
2600
+
2601
+ //0918-1:disableTypewriter true→false 强制重开打字机(不 remount,避免生成后打字机失效)
2602
+ if (prev && next[4] !== prev[4]) {
2603
+ //0920:打字机收尾 false→true:强制落 tip
2604
+ if (prev[4] === false && next[4] === true) {
2605
+ forceApplyTipHtml(buildContentHtml())
2606
+ return
2607
+ }
2608
+ //0920:true→false(点生成章节)必须清指纹并重开打字机,保证 SSE 能续打
2609
+ staticCommittedKey = ''
2610
+ lastAppliedEditorHtml = ''
2611
+ lastFinishedIntroLen = 0
2612
+ startTypewriterOrSetFull()
2613
+ return
2614
+ }
2615
+ //0918-1
2616
+
2617
+ // 進入編輯 / 關閉打字機:立刻滿文
2618
+ if (props.disableTypewriter || props.editable) {
2619
+ //0920:含 tip → force;同指纹同 HTML 不灌;其余正常灌
2620
+ const full = buildContentHtml().trim()
2621
+ if (full.includes('report-tiptap-citation')) {
2622
+ forceApplyTipHtml(full)
2623
+ return
2624
+ }
2625
+ if (prev && staticCommittedKey && staticCommittedKey === staticContentKey()) {
2626
+ if (!full || full === lastAppliedEditorHtml) return
2627
+ staticCommittedKey = ''
2628
+ applyEditorHtml(full)
2629
+ return
2630
+ }
2631
+ if (prev && next[2] === prev[2] && next[1] === prev[1] && lastAppliedEditorHtml) {
2632
+ if (!full || full === lastAppliedEditorHtml) return
2633
+ staticCommittedKey = ''
2634
+ applyEditorHtml(full)
2635
+ return
2636
+ }
2637
+ if (
2638
+ prev &&
2639
+ String(prev[0] || '').startsWith('tw-') &&
2640
+ String(next[0] || '').startsWith('static-') &&
2641
+ lastAppliedEditorHtml
2642
+ ) {
2643
+ if (full && full !== lastAppliedEditorHtml) {
2644
+ staticCommittedKey = ''
2645
+ applyEditorHtml(full)
2646
+ return
2647
+ }
2648
+ staticCommittedKey = staticContentKey()
2649
+ return
2650
+ }
2651
+ //0920
2652
+ startTypewriterOrSetFull()
2653
+ return
2654
+ }
2655
+
2656
+ //0918:生成章节打字中禁止整段灌文——只续打 / 必要时重开打字机
2657
+ // contentIdentity 變化:整章重啟
2658
+ if (prev && (next[0] || prev[0]) && next[0] !== prev[0]) {
2659
+ //0920:打字机收尾 identity tw→static:不重开打字机,但强制落 tip
2660
+ if (
2661
+ String(prev[0] || '').startsWith('tw-') &&
2662
+ String(next[0] || '').startsWith('static-')
2663
+ ) {
2664
+ forceApplyTipHtml(buildContentHtml())
2665
+ return
2666
+ }
2667
+ //0920
2668
+ startTypewriterOrSetFull()
2669
+ return
2670
+ }
2671
+
2672
+ // 進行中:title/intro 斷續更新 → 合併緩衝續打(不整段重來)
2673
+ if (isTypewriting.value && streamSession) {
2674
+ if (prev && next[1] === prev[1] && next[2] === prev[2] && next[4] === prev[4]) return
2675
+ onStreamContentUpdated()
2676
+ return
2677
+ }
2678
+
2679
+ startTypewriterOrSetFull()
2680
+ }
2681
+ )
2682
+
2683
+ watch(
2684
+ () => props.chapterTitle,
2685
+ (title) => {
2686
+ // 有 contentIdentity 時,序號變化只改標題,正文與打字機進度保持不變
2687
+ if (!props.contentIdentity) return
2688
+ if (isTypewriting.value) return
2689
+ if (props.typewriterPhase === 'idle') return
2690
+ //0920:静态只补丁标题文字,禁止满文重灌(进页 title 晚到导致的二次渲染)
2691
+ if (props.disableTypewriter || props.editable) {
2692
+ if (!editor.value) return
2693
+ if (staticCommittedKey && staticCommittedKey === staticContentKey()) {
2694
+ patchChapterTitleOnly(title || '')
2695
+ staticCommittedKey = staticContentKey()
2696
+ return
2697
+ }
2698
+ if (patchChapterTitleOnly(title || '')) {
2699
+ staticCommittedKey = staticContentKey()
2700
+ return
2701
+ }
2702
+ applyEditorHtml(buildContentHtml())
2703
+ return
2704
+ }
2705
+ //0920
2706
+ patchChapterTitleOnly(title || '')
2707
+ }
2708
+ )
2709
+ </script>
2710
+
2711
+ <style scoped>
2712
+ .report-tiptap-editor {
2713
+ position: relative;
2714
+ display: flex;
2715
+ flex-direction: column;
2716
+ min-height: 0;
2717
+ height: auto;
2718
+ background: #fff;
2719
+ /* App 全域 font-synthesis:none + 僅有 Regular Noto 時,需在編輯器內恢復合成,否則粗體/斜體無視覺效果 */
2720
+ font-synthesis: weight style;
2721
+ }
2722
+
2723
+ /* DeepSeek 風格流式光標:細豎線、不掛在 TipTap 尾部空 p 上(空 p 矩形為 0 會把跟滾拽回頂) */
2724
+ .report-tiptap-editor.is-typing
2725
+ :deep(
2726
+ .ProseMirror > *:last-child:not(p:empty):not(p:has(> br.ProseMirror-trailingBreak:only-child))
2727
+ )::after {
2728
+ content: '';
2729
+ display: inline-block;
2730
+ width: 2px;
2731
+ height: 1.05em;
2732
+ margin-left: 2px;
2733
+ background: currentColor;
2734
+ opacity: 0.65;
2735
+ vertical-align: -0.12em;
2736
+ border-radius: 1px;
2737
+ animation: none;
2738
+ }
2739
+
2740
+ /* tip 為段末時關掉塊級光標,避免豎線貼進圓點成「2|」 */
2741
+ .report-tiptap-editor.is-typing
2742
+ :deep(.ProseMirror > *:last-child:has(> .report-tiptap-citation:last-child))::after,
2743
+ .report-tiptap-editor.is-typing
2744
+ :deep(
2745
+ .ProseMirror
2746
+ > *:last-child:has(> .report-tiptap-citation:nth-last-child(2) + br.ProseMirror-trailingBreak)
2747
+ )::after,
2748
+ .report-tiptap-editor.is-typing
2749
+ :deep(
2750
+ .ProseMirror
2751
+ > *:last-child:has(> .report-tiptap-citation:nth-last-child(2) + img.ProseMirror-separator)
2752
+ )::after {
2753
+ content: none !important;
2754
+ display: none !important;
2755
+ }
2756
+
2757
+ /* tip 自身永不掛 ::before/::after 光標 */
2758
+ .report-tiptap-editor.is-typing :deep(.report-tiptap-citation::before),
2759
+ .report-tiptap-editor.is-typing :deep(.report-tiptap-citation::after) {
2760
+ content: none !important;
2761
+ display: none !important;
2762
+ width: 0 !important;
2763
+ height: 0 !important;
2764
+ margin: 0 !important;
2765
+ background: none !important;
2766
+ border: none !important;
2767
+ }
2768
+
2769
+ .report-tiptap-editor.is-typing :deep(.ProseMirror > *:nth-last-child(2):has(+ p:empty))::after,
2770
+ .report-tiptap-editor.is-typing
2771
+ :deep(
2772
+ .ProseMirror > *:nth-last-child(2):has(+ p:has(> br.ProseMirror-trailingBreak:only-child))
2773
+ )::after {
2774
+ content: '';
2775
+ display: inline-block;
2776
+ width: 2px;
2777
+ height: 1.05em;
2778
+ margin-left: 2px;
2779
+ background: currentColor;
2780
+ opacity: 0.65;
2781
+ vertical-align: -0.12em;
2782
+ border-radius: 1px;
2783
+ animation: none;
2784
+ }
2785
+
2786
+ .report-tiptap-editor.is-typing :deep(.ProseMirror > table:last-child)::after,
2787
+ .report-tiptap-editor.is-typing :deep(.ProseMirror > table:nth-last-child(2):has(+ p:empty))::after,
2788
+ .report-tiptap-editor.is-typing
2789
+ :deep(
2790
+ .ProseMirror > table:nth-last-child(2):has(+ p:has(> br.ProseMirror-trailingBreak:only-child))
2791
+ )::after {
2792
+ content: none;
2793
+ }
2794
+
2795
+ .report-tiptap-editor__toolbar {
2796
+ --report-tiptap-toolbar-fg: oklch(0.145 0 0);
2797
+ /* 暫時隱藏工具欄;恢復時改回 display: flex */
2798
+ display: none;
2799
+ align-items: center;
2800
+ flex-wrap: nowrap;
2801
+ gap: 0;
2802
+ flex-shrink: 0;
2803
+ min-height: 40px;
2804
+ padding: 4px 8px;
2805
+ border-bottom: 1px solid #e5e7eb;
2806
+ background: #fff;
2807
+ overflow-x: auto;
2808
+ font-family: var(--gen-ai-font-family, 'Noto Sans', 'Noto Sans TC', sans-serif);
2809
+ font-size: 14px;
2810
+ color: var(--report-tiptap-toolbar-fg);
2811
+ }
2812
+
2813
+ .report-tiptap-editor__group {
2814
+ display: inline-flex;
2815
+ align-items: center;
2816
+ gap: 0;
2817
+ flex-shrink: 0;
2818
+ }
2819
+
2820
+ .report-tiptap-editor__tool {
2821
+ display: inline-flex;
2822
+ align-items: center;
2823
+ justify-content: center;
2824
+ gap: 2px;
2825
+ box-sizing: border-box;
2826
+ min-width: 32px;
2827
+ height: 32px;
2828
+ padding: 0 6px;
2829
+ border: none;
2830
+ border-radius: 4px;
2831
+ background: transparent;
2832
+ color: var(--report-tiptap-toolbar-fg);
2833
+ font-family: inherit;
2834
+ font-size: 14px;
2835
+ line-height: 1;
2836
+ cursor: pointer;
2837
+ transition:
2838
+ background 0.15s ease,
2839
+ color 0.15s ease;
2840
+ }
2841
+
2842
+ .report-tiptap-editor__tool :deep(svg) {
2843
+ display: block;
2844
+ flex-shrink: 0;
2845
+ color: inherit;
2846
+ stroke: currentColor;
2847
+ }
2848
+
2849
+ .report-tiptap-editor__tool:hover:not(:disabled) {
2850
+ background: #f3f4f6;
2851
+ color: var(--report-tiptap-toolbar-fg);
2852
+ }
2853
+
2854
+ .report-tiptap-editor__tool.is-active {
2855
+ background: #f3f4f6;
2856
+ color: var(--report-tiptap-toolbar-fg);
2857
+ }
2858
+
2859
+ .report-tiptap-editor__tool:disabled {
2860
+ opacity: 0.35;
2861
+ cursor: not-allowed;
2862
+ }
2863
+
2864
+ .report-tiptap-editor__divider {
2865
+ width: 1px;
2866
+ height: 16px;
2867
+ margin: 0 8px;
2868
+ background: #e5e7eb;
2869
+ flex-shrink: 0;
2870
+ }
2871
+
2872
+ .report-tiptap-editor__body {
2873
+ flex: 1 0 auto;
2874
+ min-height: 0;
2875
+ overflow: visible;
2876
+ }
2877
+
2878
+ .report-tiptap-editor__content {
2879
+ min-height: 0;
2880
+ }
2881
+
2882
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose) {
2883
+ outline: none;
2884
+ min-height: 360px;
2885
+ height: auto;
2886
+ color: #374151;
2887
+ font-size: 14px;
2888
+ line-height: 1.75;
2889
+ font-synthesis: weight style;
2890
+ }
2891
+
2892
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h1),
2893
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h2),
2894
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h3),
2895
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h4),
2896
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h5),
2897
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h6) {
2898
+ color: #1a1a1a;
2899
+ font-family: var(--gen-ai-font-family, 'Noto Sans', 'Noto Sans TC', sans-serif);
2900
+ font-weight: 700;
2901
+ line-height: 1.35;
2902
+ font-synthesis: weight;
2903
+ }
2904
+
2905
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h1) {
2906
+ margin: 0 0 32px;
2907
+ color: #111827;
2908
+ font-size: 36px;
2909
+ line-height: 1.11;
2910
+ }
2911
+
2912
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h2) {
2913
+ margin: 24px 0 !important;
2914
+ color: #111827;
2915
+ font-size: 24px;
2916
+ line-height: 1.33;
2917
+ }
2918
+
2919
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h3) {
2920
+ margin: 32px 0 12px;
2921
+ color: #111827;
2922
+ font-size: 20px;
2923
+ line-height: 1.6;
2924
+ }
2925
+
2926
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h4) {
2927
+ margin: 24px 0 10px;
2928
+ font-size: 18px;
2929
+ }
2930
+
2931
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h5),
2932
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h6) {
2933
+ margin: 20px 0 8px;
2934
+ font-size: 16px;
2935
+ }
2936
+
2937
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose hr) {
2938
+ margin: 24px 0;
2939
+ border: none;
2940
+ border-top: 1px solid #e5e7eb;
2941
+ }
2942
+
2943
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose pre) {
2944
+ margin: 16px 0;
2945
+ padding: 12px 14px;
2946
+ overflow-x: auto;
2947
+ border-radius: 6px;
2948
+ background: #f3f4f6;
2949
+ font-size: 13px;
2950
+ line-height: 1.5;
2951
+ }
2952
+
2953
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose code) {
2954
+ padding: 0.1em 0.35em;
2955
+ border-radius: 4px;
2956
+ background: #f3f4f6;
2957
+ font-size: 0.9em;
2958
+ }
2959
+
2960
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose pre code) {
2961
+ padding: 0;
2962
+ background: transparent;
2963
+ }
2964
+
2965
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose s),
2966
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose del) {
2967
+ text-decoration: line-through;
2968
+ }
2969
+
2970
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose p) {
2971
+ margin: 20px 0;
2972
+ color: #374151;
2973
+ font-size: 16px;
2974
+ font-weight: 400;
2975
+ word-spacing: normal;
2976
+ letter-spacing: normal;
2977
+ overflow-wrap: break-word;
2978
+ word-break: normal;
2979
+ }
2980
+
2981
+ /* 隱藏 TipTap 尾部空段落(含僅 trailingBreak 的空 p),避免章節結尾大片空白 */
2982
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose > p:empty),
2983
+ .report-tiptap-editor__content
2984
+ :deep(.report-tiptap-editor__prose > p:has(> br.ProseMirror-trailingBreak:only-child)) {
2985
+ display: none !important;
2986
+ margin: 0 !important;
2987
+ padding: 0 !important;
2988
+ height: 0 !important;
2989
+ min-height: 0 !important;
2990
+ overflow: hidden !important;
2991
+ border: 0 !important;
2992
+ }
2993
+
2994
+ /* 章節實際最後一個可見塊不再額外撐底邊距 */
2995
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose > *:last-child) {
2996
+ margin-bottom: 0;
2997
+ }
2998
+
2999
+ /* 粗體 / 斜體:強制可見;顏色必須繼承正文,勿用 text-stroke(會變深) */
3000
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose strong),
3001
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose b) {
3002
+ color: inherit !important;
3003
+ font-weight: 700 !important;
3004
+ font-synthesis: weight !important;
3005
+ -webkit-text-stroke: 0;
3006
+ }
3007
+
3008
+ /*
3009
+ * (中文合成斜體可能偏弱,但不再破壞詞間空格)
3010
+ */
3011
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose em),
3012
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose i),
3013
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose .report-tiptap-italic) {
3014
+ color: inherit !important;
3015
+ font-style: italic !important;
3016
+ font-synthesis: style !important;
3017
+ display: inline !important;
3018
+ transform: none !important;
3019
+ -webkit-transform: none !important;
3020
+ max-width: none;
3021
+ margin-inline: 0;
3022
+ vertical-align: baseline;
3023
+ }
3024
+
3025
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose ::selection),
3026
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose *::selection) {
3027
+ background: #b6002a;
3028
+ color: #fff;
3029
+ }
3030
+
3031
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose ::-moz-selection),
3032
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose *::-moz-selection) {
3033
+ background: #b6002a;
3034
+ color: #fff;
3035
+ }
3036
+
3037
+ .report-tiptap-editor__content :deep(.report-tiptap-remark) {
3038
+ margin: 0 0 14px;
3039
+ padding: 0;
3040
+ border: none;
3041
+ color: #374151;
3042
+ font-family: var(--gen-ai-font-family, 'Noto Sans', 'Noto Sans TC', sans-serif);
3043
+ font-size: 12.8px;
3044
+ font-style: normal;
3045
+ font-weight: 400;
3046
+ line-height: 1.4;
3047
+ }
3048
+
3049
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h2.ProseMirror-selectednode),
3050
+ .report-tiptap-editor__content :deep(.report-tiptap-editor__prose h3.ProseMirror-selectednode) {
3051
+ background: #fdecef;
3052
+ color: #c53355;
3053
+ border-radius: 4px;
3054
+ outline: none;
3055
+ box-shadow: 0 0 0 4px #fdecef;
3056
+ }
3057
+
3058
+ .report-tiptap-editor__content :deep(.report-structure-block.ProseMirror-selectednode) {
3059
+ outline: none;
3060
+ }
3061
+
3062
+ .report-tiptap-editor__content :deep(.report-tiptap-source) {
3063
+ display: flex;
3064
+ align-items: center;
3065
+ gap: 6px;
3066
+ margin: 0 0 18px;
3067
+ color: #909399;
3068
+ font-size: 13px;
3069
+ }
3070
+
3071
+ .report-tiptap-editor__content :deep(.report-tiptap-source__icon) {
3072
+ display: inline-block;
3073
+ width: 14px;
3074
+ height: 14px;
3075
+ border: 1.5px solid #c0c4cc;
3076
+ border-radius: 2px;
3077
+ background:
3078
+ linear-gradient(135deg, transparent 40%, #c0c4cc 40%, #c0c4cc 55%, transparent 55%),
3079
+ linear-gradient(#c0c4cc, #c0c4cc) 3px 4px / 8px 1.5px no-repeat,
3080
+ linear-gradient(#c0c4cc, #c0c4cc) 3px 7px / 8px 1.5px no-repeat;
3081
+ }
3082
+
3083
+ .report-tiptap-editor__content :deep(.report-tiptap-citation) {
3084
+ display: inline-flex;
3085
+ align-items: center;
3086
+ justify-content: center;
3087
+ width: 16px;
3088
+ height: 16px;
3089
+ margin-left: 4px;
3090
+ margin-right: 4px;
3091
+ /* //0918-1 */
3092
+ margin-top: -5px;
3093
+ /* //0918-1 */
3094
+ border: 1px solid #d1d1d1;
3095
+ border-radius: 50%;
3096
+ background: #d1d1d1;
3097
+ color: #374151;
3098
+ font-size: 10px;
3099
+ font-weight: 700;
3100
+ line-height: 1;
3101
+ vertical-align: middle;
3102
+ cursor: pointer;
3103
+ box-shadow: none;
3104
+ user-select: none;
3105
+ pointer-events: auto;
3106
+ overflow: hidden;
3107
+ flex-shrink: 0;
3108
+ box-sizing: border-box;
3109
+ position: relative;
3110
+ z-index: 1;
3111
+ isolation: isolate;
3112
+ white-space: nowrap;
3113
+ }
3114
+
3115
+ /* //0918-1:划过 tip 用系统红,与打字机选区一致 */
3116
+ .report-tiptap-editor__content :deep(.report-tiptap-citation:hover),
3117
+ .report-tiptap-editor__content :deep(.report-tiptap-citation:focus) {
3118
+ background: #b6002a;
3119
+ color: #fff;
3120
+ border-color: #b6002a;
3121
+ outline: none;
3122
+ }
3123
+ /* //0918-1 */
3124
+
3125
+ .report-tiptap-editor__content :deep(.report-tiptap-link) {
3126
+ color: #409eff;
3127
+ text-decoration: underline;
3128
+ cursor: pointer;
3129
+ }
3130
+
3131
+ .report-tiptap-editor__content :deep(.report-tiptap-link:hover) {
3132
+ color: #66b1ff;
3133
+ }
3134
+
3135
+ .report-tiptap-editor__content :deep(.report-tiptap-image) {
3136
+ display: block;
3137
+ max-width: 100%;
3138
+ border-radius: 4px;
3139
+ object-fit: contain;
3140
+ }
3141
+
3142
+ .report-tiptap-editor__content :deep(.report-tiptap-image-resize) {
3143
+ position: relative;
3144
+ display: inline-block;
3145
+ max-width: 100%;
3146
+ line-height: 0;
3147
+ margin: 12px 0;
3148
+ }
3149
+
3150
+ .report-tiptap-editor__content :deep(.report-tiptap-image-resize__wrap) {
3151
+ position: relative;
3152
+ display: block;
3153
+ max-width: 100%;
3154
+ line-height: 0;
3155
+ }
3156
+
3157
+ .report-tiptap-editor__content :deep(.report-tiptap-image-resize [data-resize-handle]) {
3158
+ position: absolute;
3159
+ z-index: 10;
3160
+ width: 8px;
3161
+ height: 8px;
3162
+ background: rgba(0, 0, 0, 0.55);
3163
+ border: 1px solid rgba(255, 255, 255, 0.9);
3164
+ border-radius: 2px;
3165
+ }
3166
+
3167
+ .report-tiptap-editor__content :deep(.report-tiptap-image-resize [data-resize-handle='top-left']) {
3168
+ top: -4px;
3169
+ left: -4px;
3170
+ cursor: nwse-resize;
3171
+ }
3172
+
3173
+ .report-tiptap-editor__content :deep(.report-tiptap-image-resize [data-resize-handle='top-right']) {
3174
+ top: -4px;
3175
+ right: -4px;
3176
+ cursor: nesw-resize;
3177
+ }
3178
+
3179
+ .report-tiptap-editor__content
3180
+ :deep(.report-tiptap-image-resize [data-resize-handle='bottom-left']) {
3181
+ bottom: -4px;
3182
+ left: -4px;
3183
+ cursor: nesw-resize;
3184
+ }
3185
+
3186
+ .report-tiptap-editor__content
3187
+ :deep(.report-tiptap-image-resize [data-resize-handle='bottom-right']) {
3188
+ bottom: -4px;
3189
+ right: -4px;
3190
+ cursor: nwse-resize;
3191
+ }
3192
+
3193
+ .report-tiptap-editor__content
3194
+ :deep(.report-tiptap-image-resize[data-resize-state='true'] .report-tiptap-image-resize__wrap) {
3195
+ outline: 1px solid rgba(0, 0, 0, 0.25);
3196
+ border-radius: 2px;
3197
+ }
3198
+
3199
+ /*
3200
+ * ===== 原表格樣式(備份,已註釋;對齊 el-table 前) =====
3201
+ * 勿另建備份 .vue;回滾時取消本塊註釋,並刪下方「現行表格樣式」即可
3202
+ .report-tiptap-editor__content :deep(table) {
3203
+ width: 100%;
3204
+ margin: 12px 0 20px;
3205
+ border-collapse: collapse;
3206
+ table-layout: fixed;
3207
+ overflow: hidden;
3208
+ border: 1px solid #d1d1d1;
3209
+ }
3210
+
3211
+ .report-tiptap-editor__content :deep(th),
3212
+ .report-tiptap-editor__content :deep(td) {
3213
+ box-sizing: border-box;
3214
+ border: 1px solid #d1d1d1;
3215
+ padding: 4px 8px;
3216
+ min-width: 48px;
3217
+ font-size: 14px;
3218
+ line-height: 1.4;
3219
+ font-family: var(--gen-ai-font-family, 'Noto Sans', 'Noto Sans TC', sans-serif);
3220
+ color: #374154;
3221
+ vertical-align: middle;
3222
+ background: #fff;
3223
+ }
3224
+
3225
+ .report-tiptap-editor__content :deep(th) {
3226
+ height: 68px;
3227
+ min-height: 68px;
3228
+ font-weight: 400;
3229
+ background: #fff;
3230
+ }
3231
+
3232
+ .report-tiptap-editor__content :deep(td) {
3233
+ min-height: 36px;
3234
+ height: auto;
3235
+ vertical-align: top;
3236
+ }
3237
+ */
3238
+
3239
+ /* ===== 現行表格樣式(本組件內):對齊 el-table,圓角 5px、表頭底色、全文左對齊、緊湊行高 ===== */
3240
+ .report-tiptap-editor__content :deep(table) {
3241
+ width: 100%;
3242
+ margin: 12px 0 20px;
3243
+ border-collapse: separate;
3244
+ border-spacing: 0;
3245
+ table-layout: fixed;
3246
+ overflow: hidden;
3247
+ border: 1px solid var(--el-table-border-color, #ebeef5);
3248
+ border-radius: 5px;
3249
+ background: #fff;
3250
+ }
3251
+
3252
+ .report-tiptap-editor__content :deep(th),
3253
+ .report-tiptap-editor__content :deep(td) {
3254
+ box-sizing: border-box;
3255
+ border-right: 1px solid var(--el-table-border-color, #ebeef5);
3256
+ border-bottom: 1px solid var(--el-table-border-color, #ebeef5);
3257
+ padding: 2px 12px;
3258
+ min-width: 48px;
3259
+ min-height: 16px;
3260
+ font-size: 14px;
3261
+ line-height: 1.3;
3262
+ font-family: var(--gen-ai-font-family, 'Noto Sans', 'Noto Sans TC', sans-serif);
3263
+ font-weight: 400;
3264
+ color: var(--el-table-text-color, #606266);
3265
+ text-align: left !important;
3266
+ vertical-align: middle;
3267
+ background: #fff;
3268
+ white-space: normal;
3269
+ word-break: break-word;
3270
+ overflow-wrap: anywhere;
3271
+ }
3272
+
3273
+ .report-tiptap-editor__content :deep(th:last-child),
3274
+ .report-tiptap-editor__content :deep(td:last-child) {
3275
+ border-right: none;
3276
+ }
3277
+
3278
+ .report-tiptap-editor__content :deep(tr:last-child > th),
3279
+ .report-tiptap-editor__content :deep(tr:last-child > td) {
3280
+ border-bottom: none;
3281
+ }
3282
+
3283
+ .report-tiptap-editor__content :deep(th) {
3284
+ height: auto;
3285
+ color: var(--el-table-header-text-color, #909399);
3286
+ background-color: var(--el-table-header-bg-color, #f5f7fa);
3287
+ }
3288
+
3289
+ .report-tiptap-editor__content :deep(td) {
3290
+ height: auto;
3291
+ vertical-align: middle;
3292
+ }
3293
+
3294
+ /* 表格內段落:上下 15px;允許換行 */
3295
+ .report-tiptap-editor__content :deep(th p),
3296
+ .report-tiptap-editor__content :deep(td p) {
3297
+ margin: 15px 0;
3298
+ font-size: inherit;
3299
+ line-height: inherit;
3300
+ color: inherit;
3301
+ font-weight: inherit;
3302
+ text-align: left !important;
3303
+ white-space: normal;
3304
+ word-break: break-word;
3305
+ overflow-wrap: anywhere;
3306
+ }
3307
+
3308
+ .report-tiptap-editor__content :deep(.selectedCell::after) {
3309
+ background: rgb(197 51 85 / 12%);
3310
+ }
3311
+
3312
+ .report-tiptap-editor__content :deep(ul),
3313
+ .report-tiptap-editor__content :deep(ol) {
3314
+ margin: 0 0 14px;
3315
+ padding-left: 1.4em;
3316
+ }
3317
+
3318
+ .report-tiptap-editor__content :deep(ol) {
3319
+ list-style-type: decimal;
3320
+ }
3321
+
3322
+ .report-tiptap-editor__content :deep(ul) {
3323
+ list-style-type: disc;
3324
+ }
3325
+
3326
+ .report-tiptap-editor__content :deep(li) {
3327
+ color: #374151;
3328
+ }
3329
+
3330
+ .report-tiptap-editor__content :deep(li::marker) {
3331
+ color: #999 !important;
3332
+ font-size: 16px !important;
3333
+ font-weight: 500 !important;
3334
+ font-family: var(--gen-ai-font-family, 'Noto Sans', 'Noto Sans TC', sans-serif);
3335
+ }
3336
+
3337
+ .report-tiptap-editor__content :deep(li > p) {
3338
+ margin: 0 0 2px;
3339
+ font-size: inherit;
3340
+ color: inherit;
3341
+ }
3342
+
3343
+ .report-tiptap-editor.is-readonly :deep(.report-tiptap-editor__prose) {
3344
+ cursor: default;
3345
+ caret-color: transparent;
3346
+ }
3347
+
3348
+ .report-tiptap-editor.is-readonly :deep(.ProseMirror) {
3349
+ caret-color: transparent;
3350
+ }
3351
+
3352
+ .report-tiptap-editor.is-readonly :deep(.report-tiptap-image-resize [data-resize-handle]) {
3353
+ display: none !important;
3354
+ pointer-events: none !important;
3355
+ }
3356
+ </style>
3357
+
3358
+ <style>
3359
+ /* //0918 tip popup 樣式:四邊 14px、>18 行灰膠囊滾動條(貼正文不蓋文件名) */
3360
+ .report-chapter-review-popover {
3361
+ position: relative;
3362
+ box-sizing: border-box;
3363
+ display: flex;
3364
+ flex-direction: column;
3365
+ /* //0918 上右下左永遠 14px,限高時也靠 border-box 把 padding 算進 maxHeight,不被裁掉 */
3366
+ padding: 14px;
3367
+ border: 1px solid #c4c9d3;
3368
+ border-radius: 8px;
3369
+ background: #fff;
3370
+ color: #374151;
3371
+ font-family: var(--gen-ai-font-family, 'Noto Sans', 'Noto Sans TC', sans-serif);
3372
+ font-size: 14px;
3373
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
3374
+ user-select: none;
3375
+ -webkit-user-select: none;
3376
+ overflow: hidden;
3377
+ caret-color: transparent;
3378
+ }
3379
+
3380
+ /* //0918 杜絕打字光標 ::after 或殘留「|」出現在 tip 正文末尾 */
3381
+ .report-chapter-review-popover::before,
3382
+ .report-chapter-review-popover::after,
3383
+ .report-chapter-review-popover .review-marker-popover__content::before,
3384
+ .report-chapter-review-popover .review-marker-popover__content::after {
3385
+ content: none !important;
3386
+ display: none !important;
3387
+ }
3388
+
3389
+ .review-marker-popover {
3390
+ display: flex;
3391
+ flex-direction: column;
3392
+ flex: 1 1 auto;
3393
+ min-width: 0;
3394
+ min-height: 0;
3395
+ overflow: visible; /* //0918 軌道伸进右側 padding */
3396
+ }
3397
+
3398
+ .review-marker-popover__meta {
3399
+ display: flex;
3400
+ align-items: center;
3401
+ gap: 8px;
3402
+ margin: 0 0 10px;
3403
+ padding: 0;
3404
+ min-width: 0;
3405
+ flex-shrink: 0;
3406
+ }
3407
+
3408
+ .review-marker-popover__id {
3409
+ display: inline-flex;
3410
+ align-items: center;
3411
+ justify-content: center;
3412
+ width: 20px;
3413
+ height: 20px;
3414
+ min-width: 20px;
3415
+ flex-shrink: 0;
3416
+ border-radius: 50%;
3417
+ background: #c53355;
3418
+ color: #fff !important;
3419
+ font-size: 11px;
3420
+ font-weight: 700;
3421
+ line-height: 1;
3422
+ box-sizing: border-box;
3423
+ }
3424
+
3425
+ .review-marker-popover__source-name {
3426
+ flex: 1;
3427
+ min-width: 0;
3428
+ padding: 0;
3429
+ border: none;
3430
+ background: none;
3431
+ text-align: left;
3432
+ font-family: inherit;
3433
+ font-size: 14px;
3434
+ font-weight: 600;
3435
+ line-height: 1.4;
3436
+ color: #409eff;
3437
+ text-decoration: none;
3438
+ cursor: pointer;
3439
+ overflow: hidden;
3440
+ text-overflow: ellipsis;
3441
+ white-space: nowrap;
3442
+ }
3443
+
3444
+ .review-marker-popover__source-name:hover {
3445
+ text-decoration: underline;
3446
+ }
3447
+
3448
+ .review-marker-popover__author {
3449
+ font-size: 14px;
3450
+ font-weight: 600;
3451
+ color: #374151;
3452
+ }
3453
+
3454
+ .review-marker-popover__body {
3455
+ position: relative;
3456
+ display: block;
3457
+ flex: 0 0 auto;
3458
+ width: 100%;
3459
+ min-width: 0;
3460
+ min-height: 0;
3461
+ /* //0918 visible:軌道伸进右側 padding;裁切靠外層 popover overflow:hidden */
3462
+ overflow: visible;
3463
+ }
3464
+
3465
+ .review-marker-popover__scroll {
3466
+ display: block;
3467
+ width: 100%;
3468
+ max-width: 100%;
3469
+ min-width: 0;
3470
+ min-height: 0;
3471
+ max-height: 403.2px; /* //0918 14 * 1.6 * 18 */
3472
+ overflow-x: hidden;
3473
+ overflow-y: auto;
3474
+ padding: 0;
3475
+ margin: 0;
3476
+ box-sizing: border-box;
3477
+ scrollbar-width: none;
3478
+ -ms-overflow-style: none;
3479
+ overscroll-behavior: contain;
3480
+ touch-action: pan-y;
3481
+ -webkit-overflow-scrolling: touch;
3482
+ }
3483
+
3484
+ .review-marker-popover__scroll::-webkit-scrollbar {
3485
+ width: 0;
3486
+ height: 0;
3487
+ display: none;
3488
+ }
3489
+
3490
+ /*
3491
+ * //0918 掛在 body:top/bottom 貼正文,不蓋文件名。
3492
+ * right:-10px 落入 popover 右側 14px 邊距(距邊框約 4px)。
3493
+ * 與中欄主滾動條同款:opacity + #999 !important。
3494
+ */
3495
+ .review-marker-popover__thumb-track {
3496
+ position: absolute !important;
3497
+ top: 0 !important;
3498
+ bottom: 0 !important;
3499
+ right: -10px !important;
3500
+ width: 8px !important;
3501
+ z-index: 20 !important;
3502
+ overflow: hidden !important;
3503
+ box-sizing: border-box !important;
3504
+ opacity: 0;
3505
+ pointer-events: none;
3506
+ cursor: pointer;
3507
+ transition: opacity 0.12s ease;
3508
+ }
3509
+
3510
+ .review-marker-popover__thumb-track.is-visible {
3511
+ opacity: 1 !important;
3512
+ pointer-events: auto !important;
3513
+ }
3514
+
3515
+ .review-marker-popover__thumb {
3516
+ position: absolute !important;
3517
+ top: 0 !important;
3518
+ left: 0 !important;
3519
+ width: 8px !important;
3520
+ min-height: 50px !important;
3521
+ max-height: 100% !important;
3522
+ border-radius: 9999px !important;
3523
+ background-color: #999 !important;
3524
+ cursor: grab !important;
3525
+ pointer-events: auto !important;
3526
+ box-sizing: border-box !important;
3527
+ will-change: transform;
3528
+ }
3529
+
3530
+ .review-marker-popover__thumb:active {
3531
+ cursor: grabbing;
3532
+ background-color: #777 !important;
3533
+ }
3534
+
3535
+ .review-marker-popover__thumb:hover {
3536
+ background-color: #777 !important;
3537
+ }
3538
+
3539
+ .review-marker-popover__content {
3540
+ margin: 0;
3541
+ padding: 0;
3542
+ font-family: var(--gen-ai-font-family, 'Noto Sans', 'Noto Sans TC', sans-serif);
3543
+ font-size: 14px;
3544
+ line-height: 1.6;
3545
+ color: #374151;
3546
+ user-select: none;
3547
+ -webkit-user-select: none;
3548
+ caret-color: transparent;
3549
+ white-space: normal;
3550
+ word-break: break-word;
3551
+ overflow-wrap: anywhere;
3552
+ }
3553
+
3554
+ /* //0920:popup 内 Markdown 表格 */
3555
+ .review-marker-popover__content table {
3556
+ width: 100%;
3557
+ border-collapse: collapse;
3558
+ margin: 8px 0;
3559
+ font-size: 13px;
3560
+ }
3561
+
3562
+ .review-marker-popover__content th,
3563
+ .review-marker-popover__content td {
3564
+ border: 1px solid #e5e6eb;
3565
+ padding: 6px 8px;
3566
+ text-align: left;
3567
+ vertical-align: top;
3568
+ word-break: break-word;
3569
+ }
3570
+
3571
+ .review-marker-popover__content th {
3572
+ background: #f7f8fa;
3573
+ font-weight: 600;
3574
+ }
3575
+
3576
+ .review-marker-popover__content p {
3577
+ margin: 0 0 8px;
3578
+ }
3579
+
3580
+ .review-marker-popover__content p:last-child {
3581
+ margin-bottom: 0;
3582
+ }
3583
+ /* //0920 */
3584
+
3585
+ .review-marker-popover__preview-link {
3586
+ display: inline;
3587
+ padding: 0;
3588
+ border: none;
3589
+ background: none;
3590
+ text-align: left;
3591
+ font-family: inherit;
3592
+ font-size: 14px;
3593
+ line-height: 1.5;
3594
+ color: #409eff;
3595
+ text-decoration: underline;
3596
+ cursor: pointer;
3597
+ word-break: break-all;
3598
+ }
3599
+
3600
+ .review-marker-popover__preview-link:hover {
3601
+ color: #66b1ff;
3602
+ }
3603
+ /* //0918 */
3604
+ </style>
3605
+
3606
+ <!-- 非 scoped:確保斜體 skew、列表標記色一定打到 ProseMirror -->
3607
+ <style>
3608
+ .report-tiptap-editor .ProseMirror strong,
3609
+ .report-tiptap-editor .ProseMirror b,
3610
+ .report-tiptap-editor .ProseMirror em,
3611
+ .report-tiptap-editor .ProseMirror i,
3612
+ .report-tiptap-editor .report-tiptap-italic,
3613
+ .report-tiptap-editor em.report-tiptap-italic,
3614
+ .report-tiptap-editor span.report-tiptap-italic {
3615
+ color: inherit !important;
3616
+ }
3617
+
3618
+ .report-tiptap-editor .ProseMirror em,
3619
+ .report-tiptap-editor .ProseMirror i,
3620
+ .report-tiptap-editor .report-tiptap-italic,
3621
+ .report-tiptap-editor em.report-tiptap-italic,
3622
+ .report-tiptap-editor span.report-tiptap-italic {
3623
+ font-style: italic !important;
3624
+ font-synthesis: style !important;
3625
+ display: inline !important;
3626
+ transform: none !important;
3627
+ -webkit-transform: none !important;
3628
+ max-width: none;
3629
+ margin-inline: 0;
3630
+ vertical-align: baseline;
3631
+ }
3632
+
3633
+ .report-tiptap-editor .ProseMirror h2 {
3634
+ margin: 24px 0 !important;
3635
+ }
3636
+
3637
+ .report-tiptap-editor .ProseMirror ul,
3638
+ .report-tiptap-editor .ProseMirror ol {
3639
+ margin: 0 0 14px;
3640
+ padding-left: 1.4em;
3641
+ }
3642
+
3643
+ .report-tiptap-editor .ProseMirror ol {
3644
+ list-style-type: decimal;
3645
+ }
3646
+
3647
+ .report-tiptap-editor .ProseMirror ul {
3648
+ list-style-type: disc;
3649
+ }
3650
+
3651
+ .report-tiptap-editor .ProseMirror li::marker {
3652
+ color: #999 !important;
3653
+ font-size: 16px !important;
3654
+ font-weight: 500 !important;
3655
+ font-family: var(--gen-ai-font-family, 'Noto Sans', 'Noto Sans TC', sans-serif);
3656
+ }
3657
+
3658
+ .report-tiptap-editor .ProseMirror li > p {
3659
+ margin: 0 0 2px;
3660
+ }
3661
+
3662
+ .report-tiptap-editor .ProseMirror .report-tiptap-citation,
3663
+ .report-tiptap-editor .report-tiptap-citation {
3664
+ display: inline-flex;
3665
+ align-items: center;
3666
+ justify-content: center;
3667
+ width: 16px;
3668
+ height: 16px;
3669
+ margin-left: 4px;
3670
+ margin-right: 4px;
3671
+ /* //0918-1 */
3672
+ margin-top: -5px;
3673
+ /* //0918-1 */
3674
+ border: 1px solid #d1d1d1;
3675
+ border-radius: 50%;
3676
+ background: #d1d1d1;
3677
+ color: #374151;
3678
+ font-size: 10px;
3679
+ font-weight: 700;
3680
+ line-height: 1;
3681
+ vertical-align: middle;
3682
+ cursor: pointer;
3683
+ box-shadow: none;
3684
+ user-select: none;
3685
+ pointer-events: auto;
3686
+ overflow: hidden;
3687
+ flex-shrink: 0;
3688
+ box-sizing: border-box;
3689
+ position: relative;
3690
+ z-index: 1;
3691
+ isolation: isolate;
3692
+ white-space: nowrap;
3693
+ }
3694
+
3695
+ /* //0918-1:划过 tip 系统红 */
3696
+ .report-tiptap-editor .ProseMirror .report-tiptap-citation:hover,
3697
+ .report-tiptap-editor .ProseMirror .report-tiptap-citation:focus,
3698
+ .report-tiptap-editor .report-tiptap-citation:hover,
3699
+ .report-tiptap-editor .report-tiptap-citation:focus {
3700
+ background: #b6002a;
3701
+ color: #fff;
3702
+ border-color: #b6002a;
3703
+ outline: none;
3704
+ }
3705
+ /* //0918-1 */
3706
+
3707
+ .report-tiptap-editor .ProseMirror a.report-tiptap-link,
3708
+ .report-tiptap-editor a.report-tiptap-link {
3709
+ color: #409eff;
3710
+ text-decoration: underline;
3711
+ cursor: pointer;
3712
+ }
3713
+
3714
+ .report-tiptap-editor .ProseMirror a.report-tiptap-link:hover,
3715
+ .report-tiptap-editor a.report-tiptap-link:hover {
3716
+ color: #66b1ff;
3717
+ }
3718
+ </style>