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