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