|
|
@@ -1,1453 +0,0 @@
|
|
|
-<template>
|
|
|
- <div class="rich-text-editor" ref="containerRef">
|
|
|
- <!-- 工具栏:左侧格式按钮 + 右侧插入功能 -->
|
|
|
- <div class="toolbar">
|
|
|
- <div class="toolbar_left">
|
|
|
- <!-- 撤销 / 重做 -->
|
|
|
- <a-button class="btn" type="text" @click="undo" :disabled="historyIndex <= 0" title="撤销">
|
|
|
- <img class="btn-icon" :src="UndoIcon" alt="撤销" />
|
|
|
- </a-button>
|
|
|
- <a-button
|
|
|
- class="btn"
|
|
|
- type="text"
|
|
|
- @click="redo"
|
|
|
- :disabled="historyIndex >= historyStack.length - 1"
|
|
|
- title="重做"
|
|
|
- >
|
|
|
- <img class="btn-icon" :src="RedoIcon" alt="重做" />
|
|
|
- </a-button>
|
|
|
-
|
|
|
- <!-- 加粗、斜体、下划线 -->
|
|
|
- <a-button class="btn" type="text" :class="{ active: isBold }" @click="toggleFormat('bold')" title="加粗">
|
|
|
- <icon-bold />
|
|
|
- </a-button>
|
|
|
- <a-button class="btn" type="text" :class="{ active: isItalic }" @click="toggleFormat('italic')" title="斜体">
|
|
|
- <icon-italic />
|
|
|
- </a-button>
|
|
|
- <a-button
|
|
|
- class="btn"
|
|
|
- type="text"
|
|
|
- :class="{ active: isUnderline }"
|
|
|
- @click="toggleFormat('underline')"
|
|
|
- title="下划线"
|
|
|
- >
|
|
|
- <icon-underline />
|
|
|
- </a-button>
|
|
|
-
|
|
|
- <!-- 标题选择下拉框 -->
|
|
|
- <select v-model="headingLevel" @change="applyHeading">
|
|
|
- <option value="">正文</option>
|
|
|
- <option value="H1">标题 1</option>
|
|
|
- <option value="H2">标题 2</option>
|
|
|
- <option value="H3">标题 3</option>
|
|
|
- <option value="H4">标题 4</option>
|
|
|
- <option value="H5">标题 5</option>
|
|
|
- <option value="H6">标题 6</option>
|
|
|
- </select>
|
|
|
-
|
|
|
- <!-- 字号选择下拉框 -->
|
|
|
- <select v-model="fontSize" @change="applyFontSize">
|
|
|
- <option value="">默认</option>
|
|
|
- <option value="12">小 (12px)</option>
|
|
|
- <option value="14">常规 (14px)</option>
|
|
|
- <option value="16">中 (16px)</option>
|
|
|
- <option value="18">大 (18px)</option>
|
|
|
- <option value="20">更大 (20px)</option>
|
|
|
- <option value="24">超大 (24px)</option>
|
|
|
- <option value="28">特大 (28px)</option>
|
|
|
- </select>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 右侧:插入图表等扩展功能 -->
|
|
|
- <div class="toolbar_right">
|
|
|
- <div class="chat-select" ref="chartButtonRef">
|
|
|
- <a-button class="insert_btn" @click="toggleChartPanelDropdown">
|
|
|
- <img class="btn-icon" :src="ChatICON_a" alt="" />
|
|
|
- 插入图表 <icon-down size="16" v-if="!showChartPanelDropdown" />
|
|
|
- <icon-up size="16" v-else />
|
|
|
- </a-button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 可编辑区域 -->
|
|
|
- <div
|
|
|
- ref="editorRef"
|
|
|
- class="editor"
|
|
|
- :contenteditable="true"
|
|
|
- @dragover="onDragOverMetric"
|
|
|
- @drop="onDropMetric"
|
|
|
- @input="
|
|
|
- (e) => {
|
|
|
- onInput(e);
|
|
|
- handleInputForMention(e);
|
|
|
- }
|
|
|
- "
|
|
|
- @blur="flushPendingSave"
|
|
|
- @mouseup="updateToolbar"
|
|
|
- @keyup="updateToolbar"
|
|
|
- @keydown="handleKeydownForMention"
|
|
|
- @click="handleEditorClick"
|
|
|
- ></div>
|
|
|
-
|
|
|
- <!-- @mention 下拉菜单 -->
|
|
|
- <MentionDropdown
|
|
|
- :visible="showDropdown"
|
|
|
- :position="dropdownPosition"
|
|
|
- :options="metrics_list"
|
|
|
- @select="insertOption"
|
|
|
- ref="mentionDropdownRef"
|
|
|
- />
|
|
|
-
|
|
|
- <!-- mention 操作菜单 -->
|
|
|
- <MentionActionMenu
|
|
|
- ref="actionMenuRef"
|
|
|
- :visible="showActionMenu"
|
|
|
- :position="actionMenuPosition"
|
|
|
- :actions="target_action"
|
|
|
- :actionConfig="current_action_config"
|
|
|
- @action="handleAction"
|
|
|
- />
|
|
|
- <ChartPanelDropdown
|
|
|
- ref="chartPanelDropdownRef"
|
|
|
- :visible="showChartPanelDropdown"
|
|
|
- :position="chartPanelDropdownPosition"
|
|
|
- @chart-click="handleChartPanelDropdownClick"
|
|
|
- />
|
|
|
- </div>
|
|
|
- <TipsModel v-model:visible="showTips" @ok="toSaveSetting" @cancel="toNotSaveSetting" />
|
|
|
-</template>
|
|
|
-
|
|
|
-<script setup>
|
|
|
-/**
|
|
|
- * 富文本编辑器组件(基于 contenteditable + document.execCommand)
|
|
|
- * 支持:撤销/重做、加粗/斜体/下划线、标题、字号、光标位置记忆 + @mention 功能
|
|
|
- */
|
|
|
-
|
|
|
-import { ref, onMounted, onUnmounted, nextTick, watch, computed, createApp, provide } from 'vue';
|
|
|
-import TableChart from '../editor-chart/TableChart.vue';
|
|
|
-
|
|
|
-import { generateUID } from './common-tools';
|
|
|
-
|
|
|
-import RedoIcon from '../../../assets/report-template/redo.svg';
|
|
|
-import UndoIcon from '../../../assets/report-template/undo.svg';
|
|
|
-import ChatICON_a from '../../../assets/report-template/chat_icon_a.svg';
|
|
|
-
|
|
|
-import MentionDropdown from '../editor-select/MentionDropdown.vue';
|
|
|
-import MentionActionMenu from '../editor-select/MentionActionMenu.vue';
|
|
|
-import ChartPanelDropdown from '../editor-select/ChartPanelDropdown.vue';
|
|
|
-
|
|
|
-import TipsModel from './TipsModel.vue';
|
|
|
-
|
|
|
-import { useReportEditor } from '../../../base/store/use-report-editor';
|
|
|
-
|
|
|
-const reportEditor = useReportEditor();
|
|
|
-console.log(reportEditor);
|
|
|
-
|
|
|
-// ============ Props & Emits ============
|
|
|
-const props = defineProps({
|
|
|
- modelValue: {
|
|
|
- type: String,
|
|
|
- default: ''
|
|
|
- },
|
|
|
- metrics_list: Array,
|
|
|
- extracted_metrics: Array
|
|
|
-});
|
|
|
-
|
|
|
-const emit = defineEmits(['update:modelValue', 'mention-click', 'close-filter-model', 'to-save-setting', 'submitHtml']);
|
|
|
-
|
|
|
-// ============ Refs ============
|
|
|
-const editorRef = ref(null);
|
|
|
-const containerRef = ref(null); // 👈 新增:用于定位浮层
|
|
|
-const mentionDropdownRef = ref(null);
|
|
|
-
|
|
|
-// 图表数据存储,使用chartId作为唯一标识
|
|
|
-const chartDataStore = ref({});
|
|
|
-
|
|
|
-// 提供图表数据存储给子组件
|
|
|
-provide('chartDataStore', chartDataStore);
|
|
|
-
|
|
|
-// 工具栏状态
|
|
|
-const headingLevel = ref(''); // 当前段落是否为 H1-H6
|
|
|
-const fontSize = ref(''); // 当前选中字号(如 "14")
|
|
|
-const isBold = ref(false);
|
|
|
-const isItalic = ref(false);
|
|
|
-const isUnderline = ref(false);
|
|
|
-
|
|
|
-// ============ 历史栈(用于撤销/重做)===========
|
|
|
-const historyStack = ref([]); // 存储 { html, cursor } 快照
|
|
|
-const historyIndex = ref(-1); // 当前历史指针
|
|
|
-let isRestoring = false; // 是否正在恢复历史(避免重复保存)
|
|
|
-let pendingSaveTimeout = null; // 防抖定时器
|
|
|
-const SAVE_DELAY = 500; // 输入后延迟 500ms 保存
|
|
|
-
|
|
|
-// ========== Mention 功能(增量添加)==========
|
|
|
-const dropdownRef = ref(null);
|
|
|
-const actionMenuRef = ref(null);
|
|
|
-// ========= 图表面板功能 ==========
|
|
|
-const chartPanelDropdownRef = ref(null);
|
|
|
-const chartButtonRef = ref(null);
|
|
|
-
|
|
|
-const showDropdown = ref(false);
|
|
|
-const dropdownPosition = ref({ top: 0, left: 0 });
|
|
|
-const chartPanelDropdownPosition = ref({ top: 0, left: 0 });
|
|
|
-// 保存当前光标位置,用于处理点击插入图表按钮时光标丢失的问题
|
|
|
-const savedCursorPosition = ref(null);
|
|
|
-// 原始数据
|
|
|
-// const options = [
|
|
|
-// {
|
|
|
-// id: 'oU12ESIeGAD5qCw',
|
|
|
-// metric_name: '管控指标',
|
|
|
-// data_source_type: 1,
|
|
|
-// api_description: '决策平台-管控指标',
|
|
|
-// table_metadata: null,
|
|
|
-// is_supports_top_bottos: 0,
|
|
|
-// default_top_n: 0,
|
|
|
-// default_bottom_n: 0,
|
|
|
-// default_sort_order: 'desc',
|
|
|
-// created_at: '1995-07-18T16:22:15',
|
|
|
-// updated_at: '1995-07-18T16:22:15',
|
|
|
-// flexible_template_metric_fields: [
|
|
|
-// {
|
|
|
-// id: 'r67yzUE8c7lfhjE',
|
|
|
-// metric_id: 'oU12ESIeGAD5qCw',
|
|
|
-// field_name: '组织机构',
|
|
|
-// field_description: '组织机构',
|
|
|
-// field_type: 'str',
|
|
|
-// is_aggregatable: 0,
|
|
|
-// is_enum_filterable: 1,
|
|
|
-// enum_type_id: '2',
|
|
|
-// is_multi_select: 0,
|
|
|
-// is_and_or_condition: 0,
|
|
|
-// created_at: '2019-01-23T20:40:12',
|
|
|
-// updated_at: '1980-11-02T04:09:49'
|
|
|
-// },
|
|
|
-// {
|
|
|
-// id: '6Pva5pKIyaJn25P',
|
|
|
-// metric_id: 'oU12ESIeGAD5qCw',
|
|
|
-// field_name: '开始时间',
|
|
|
-// field_description: '开始时间',
|
|
|
-// field_type: 'date',
|
|
|
-// is_aggregatable: 0,
|
|
|
-// is_enum_filterable: 0,
|
|
|
-// enum_type_id: '0',
|
|
|
-// is_multi_select: 0,
|
|
|
-// is_and_or_condition: 0,
|
|
|
-// created_at: '1985-11-06T11:01:42',
|
|
|
-// updated_at: '2025-05-17T10:43:39'
|
|
|
-// },
|
|
|
-// {
|
|
|
-// id: '10qOzrfpUnIgxt9',
|
|
|
-// metric_id: 'oU12ESIeGAD5qCw',
|
|
|
-// field_name: '结束时间',
|
|
|
-// field_description: '结束时间',
|
|
|
-// field_type: 'date',
|
|
|
-// is_aggregatable: 0,
|
|
|
-// is_enum_filterable: 0,
|
|
|
-// enum_type_id: '0',
|
|
|
-// is_multi_select: 0,
|
|
|
-// is_and_or_condition: 0,
|
|
|
-// created_at: '1987-03-29T09:04:29',
|
|
|
-// updated_at: '1980-08-31T16:07:42'
|
|
|
-// },
|
|
|
-// {
|
|
|
-// id: 'i2P3lbpYvfPZeYT',
|
|
|
-// metric_id: 'oU12ESIeGAD5qCw',
|
|
|
-// field_name: '查询指标',
|
|
|
-// field_description: '需要查询的指标-提供枚举',
|
|
|
-// field_type: 'str',
|
|
|
-// is_aggregatable: 0,
|
|
|
-// is_enum_filterable: 1,
|
|
|
-// enum_type_id: '3',
|
|
|
-// is_multi_select: 1,
|
|
|
-// is_and_or_condition: 0,
|
|
|
-// created_at: '2017-06-09T20:49:27',
|
|
|
-// updated_at: '2024-11-14T07:21:16'
|
|
|
-// }
|
|
|
-// ]
|
|
|
-// }
|
|
|
-// ];
|
|
|
-// 替换为你的用户列表
|
|
|
-
|
|
|
-const mentionsData = ref(props.extracted_metrics || []); // 格式为 { uid: 'mention_xxx', option }
|
|
|
-const mentions_metrics_Data = computed(() => {
|
|
|
- return reportEditor.metrics_list;
|
|
|
-}); // 格式为 { uid: 'mention_xxx', option }
|
|
|
-
|
|
|
-const showActionMenu = ref(false);
|
|
|
-const actionMenuPosition = ref({ top: 0, left: 0 });
|
|
|
-const currentMentionElement = ref(null);
|
|
|
-const target_action = ref(null); //当前点击的指标的advanced_computing_config配置项
|
|
|
-// 图表面板展示
|
|
|
-const showChartPanelDropdown = ref(false);
|
|
|
-
|
|
|
-const showTips = ref(false);
|
|
|
-
|
|
|
-//当前指标的advanced_computing_config配置
|
|
|
-const current_action_config = computed(() => {
|
|
|
- return reportEditor.QueryTargetMetricConfig;
|
|
|
-});
|
|
|
-
|
|
|
-// ============ 光标位置管理 ============
|
|
|
-function getCursorPath() {
|
|
|
- const selection = window.getSelection();
|
|
|
- if (!selection.rangeCount || !editorRef.value) return null;
|
|
|
-
|
|
|
- const range = selection.getRangeAt(0);
|
|
|
- const startContainer = range.startContainer;
|
|
|
- if (!editorRef.value.contains(startContainer)) return null;
|
|
|
-
|
|
|
- const path = [];
|
|
|
- let node = startContainer;
|
|
|
- while (node !== editorRef.value) {
|
|
|
- const parent = node.parentNode;
|
|
|
- if (!parent) break;
|
|
|
- let index = 0;
|
|
|
- let sibling = node.previousSibling;
|
|
|
- while (sibling) {
|
|
|
- index++;
|
|
|
- sibling = sibling.previousSibling;
|
|
|
- }
|
|
|
- path.unshift(index);
|
|
|
- node = parent;
|
|
|
- }
|
|
|
- return { path, offset: range.startOffset };
|
|
|
-}
|
|
|
-
|
|
|
-function setCursorPath(saved) {
|
|
|
- if (!saved || !editorRef.value) return;
|
|
|
- const { path, offset } = saved;
|
|
|
- let node = editorRef.value;
|
|
|
-
|
|
|
- for (let i = 0; i < path.length; i++) {
|
|
|
- const idx = path[i];
|
|
|
- if (idx < node.childNodes.length) {
|
|
|
- node = node.childNodes[idx];
|
|
|
- } else {
|
|
|
- break;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (node.nodeType === Node.ELEMENT_NODE) {
|
|
|
- const textNode = findFirstTextNode(node);
|
|
|
- if (textNode) {
|
|
|
- setSelection(textNode, Math.min(offset, textNode.textContent.length));
|
|
|
- } else {
|
|
|
- setSelection(node, node.childNodes.length);
|
|
|
- }
|
|
|
- } else if (node.nodeType === Node.TEXT_NODE) {
|
|
|
- setSelection(node, Math.min(offset, node.textContent.length));
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-function findFirstTextNode(el) {
|
|
|
- for (const child of el.childNodes) {
|
|
|
- if (child.nodeType === Node.TEXT_NODE && child.textContent.trim() !== '') {
|
|
|
- return child;
|
|
|
- }
|
|
|
- if (child.nodeType === Node.ELEMENT_NODE) {
|
|
|
- const found = findFirstTextNode(child);
|
|
|
- if (found) return found;
|
|
|
- }
|
|
|
- }
|
|
|
- return null;
|
|
|
-}
|
|
|
-
|
|
|
-function setSelection(node, offset) {
|
|
|
- const range = document.createRange();
|
|
|
- const sel = window.getSelection();
|
|
|
- try {
|
|
|
- range.setStart(node, offset);
|
|
|
- range.collapse(true);
|
|
|
- sel.removeAllRanges();
|
|
|
- sel.addRange(range);
|
|
|
- } catch (e) {
|
|
|
- // 容错:节点可能已被移除
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// ============ 历史保存逻辑 ============
|
|
|
-function debouncedSave() {
|
|
|
- if (isRestoring) return;
|
|
|
- if (pendingSaveTimeout) clearTimeout(pendingSaveTimeout);
|
|
|
- pendingSaveTimeout = setTimeout(() => {
|
|
|
- const content = editorRef.value.innerHTML;
|
|
|
- const cursor = getCursorPath();
|
|
|
- const mentionsDataSnapshot = JSON.parse(JSON.stringify(mentionsData.value));
|
|
|
- saveToHistory(content, cursor, mentionsDataSnapshot);
|
|
|
- pendingSaveTimeout = null;
|
|
|
- }, SAVE_DELAY);
|
|
|
-}
|
|
|
-
|
|
|
-function immediateSave() {
|
|
|
- if (isRestoring) return;
|
|
|
- if (pendingSaveTimeout) {
|
|
|
- clearTimeout(pendingSaveTimeout);
|
|
|
- pendingSaveTimeout = null;
|
|
|
- }
|
|
|
- const content = editorRef.value.innerHTML;
|
|
|
- const cursor = getCursorPath();
|
|
|
- const mentionsDataSnapshot = JSON.parse(JSON.stringify(mentionsData.value));
|
|
|
- saveToHistory(content, cursor, mentionsDataSnapshot);
|
|
|
- syncMentionsData();
|
|
|
-}
|
|
|
-
|
|
|
-function saveToHistory(content, cursor, mentionsDataSnapshot) {
|
|
|
- const current = historyStack.value[historyIndex.value];
|
|
|
- if (current && current.html === content) return;
|
|
|
-
|
|
|
- if (historyIndex.value < historyStack.value.length - 1) {
|
|
|
- historyStack.value = historyStack.value.slice(0, historyIndex.value + 1);
|
|
|
- }
|
|
|
- historyStack.value.push({
|
|
|
- html: content,
|
|
|
- cursor,
|
|
|
- mentionsData: mentionsDataSnapshot ? JSON.parse(JSON.stringify(mentionsDataSnapshot)) : null
|
|
|
- });
|
|
|
- historyIndex.value = historyStack.value.length - 1;
|
|
|
-}
|
|
|
-
|
|
|
-// ============ 撤销 / 重做 ============
|
|
|
-function undo() {
|
|
|
- if (historyIndex.value <= 0 || !editorRef.value) return;
|
|
|
- isRestoring = true;
|
|
|
- historyIndex.value--;
|
|
|
- const { html, cursor, mentionsData: savedMentionsData } = historyStack.value[historyIndex.value];
|
|
|
- editorRef.value.innerHTML = html;
|
|
|
- emit('submitHtml', html);
|
|
|
- emit('update:modelValue', html);
|
|
|
- nextTick(() => {
|
|
|
- updateToolbar();
|
|
|
- setCursorPath(cursor);
|
|
|
- if (savedMentionsData) {
|
|
|
- mentionsData.value = JSON.parse(JSON.stringify(savedMentionsData));
|
|
|
- } else {
|
|
|
- syncMentionsData();
|
|
|
- }
|
|
|
- isRestoring = false;
|
|
|
- });
|
|
|
-}
|
|
|
-
|
|
|
-function redo() {
|
|
|
- if (historyIndex.value >= historyStack.value.length - 1 || !editorRef.value) return;
|
|
|
- isRestoring = true;
|
|
|
- historyIndex.value++;
|
|
|
- const { html, cursor, mentionsData: savedMentionsData } = historyStack.value[historyIndex.value];
|
|
|
- editorRef.value.innerHTML = html;
|
|
|
- emit('submitHtml', html);
|
|
|
- emit('update:modelValue', html);
|
|
|
- nextTick(() => {
|
|
|
- updateToolbar();
|
|
|
- setCursorPath(cursor);
|
|
|
- if (savedMentionsData) {
|
|
|
- mentionsData.value = JSON.parse(JSON.stringify(savedMentionsData));
|
|
|
- } else {
|
|
|
- syncMentionsData();
|
|
|
- }
|
|
|
- isRestoring = false;
|
|
|
- });
|
|
|
-}
|
|
|
-
|
|
|
-function flushPendingSave() {
|
|
|
- if (pendingSaveTimeout) {
|
|
|
- clearTimeout(pendingSaveTimeout);
|
|
|
- pendingSaveTimeout = null;
|
|
|
- const content = editorRef.value.innerHTML;
|
|
|
- const cursor = getCursorPath();
|
|
|
- saveToHistory(content, cursor);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// ============ 生命周期 ============
|
|
|
-onMounted(() => {
|
|
|
- if (editorRef.value) {
|
|
|
- const initialContent = props.modelValue || '';
|
|
|
- editorRef.value.innerHTML = initialContent;
|
|
|
- saveToHistory(initialContent, null);
|
|
|
- updateToolbar();
|
|
|
- syncMentionsData(); // 👈
|
|
|
- }
|
|
|
-
|
|
|
- document.addEventListener('selectionchange', handleSelectionChange);
|
|
|
- document.addEventListener('click', handleDocumentClick);
|
|
|
- mentionsData.value = props.extracted_metrics;
|
|
|
- emit('submitHtml', editorRef.value.innerHTML);
|
|
|
-});
|
|
|
-
|
|
|
-onUnmounted(() => {
|
|
|
- if (pendingSaveTimeout) clearTimeout(pendingSaveTimeout);
|
|
|
- document.removeEventListener('selectionchange', handleSelectionChange);
|
|
|
- document.removeEventListener('click', handleDocumentClick);
|
|
|
-});
|
|
|
-
|
|
|
-// 处理document点击事件,关闭图表面板下拉框
|
|
|
-function handleDocumentClick(e) {
|
|
|
- if (
|
|
|
- showChartPanelDropdown.value &&
|
|
|
- chartPanelDropdownRef.value &&
|
|
|
- !chartPanelDropdownRef.value.$el?.contains(e.target) &&
|
|
|
- !chartButtonRef.value?.contains(e.target)
|
|
|
- ) {
|
|
|
- showChartPanelDropdown.value = false;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-function handleSelectionChange() {
|
|
|
- if (editorRef.value && document.activeElement === editorRef.value) {
|
|
|
- updateToolbar();
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// ============ 工具栏状态同步 ============
|
|
|
-const updateToolbar = () => {
|
|
|
- const selection = window.getSelection();
|
|
|
- if (!selection.rangeCount || !editorRef.value) return;
|
|
|
-
|
|
|
- const range = selection.getRangeAt(0);
|
|
|
- let node = range.commonAncestorContainer;
|
|
|
- if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
|
|
-
|
|
|
- isBold.value = document.queryCommandState('bold');
|
|
|
- isItalic.value = document.queryCommandState('italic');
|
|
|
- isUnderline.value = document.queryCommandState('underline');
|
|
|
-
|
|
|
- let headingNode = node;
|
|
|
- while (headingNode && headingNode !== editorRef.value) {
|
|
|
- if (headingNode.nodeType === Node.ELEMENT_NODE) {
|
|
|
- const tagName = headingNode.tagName;
|
|
|
- if (tagName === 'P' || (tagName.startsWith('H') && /^[1-6]$/.test(tagName.slice(1)))) {
|
|
|
- headingLevel.value = tagName === 'P' ? '' : tagName;
|
|
|
- break;
|
|
|
- }
|
|
|
- }
|
|
|
- headingNode = headingNode.parentElement;
|
|
|
- }
|
|
|
- if (!headingNode || headingNode === editorRef.value) {
|
|
|
- headingLevel.value = '';
|
|
|
- }
|
|
|
-
|
|
|
- let fontSizeNode = node;
|
|
|
- let detectedSize = '';
|
|
|
- while (fontSizeNode && fontSizeNode !== editorRef.value) {
|
|
|
- if (fontSizeNode.nodeType === Node.ELEMENT_NODE) {
|
|
|
- const style = window.getComputedStyle(fontSizeNode);
|
|
|
- const fs = style.fontSize;
|
|
|
- if (fs && fs !== '16px') {
|
|
|
- detectedSize = parseInt(fs, 10).toString();
|
|
|
- break;
|
|
|
- }
|
|
|
- }
|
|
|
- fontSizeNode = fontSizeNode.parentElement;
|
|
|
- }
|
|
|
- fontSize.value = detectedSize;
|
|
|
-};
|
|
|
-
|
|
|
-// ============ 格式操作 ============
|
|
|
-const toggleFormat = (command) => {
|
|
|
- if (!editorRef.value) return;
|
|
|
- editorRef.value.focus();
|
|
|
- flushPendingSave();
|
|
|
-
|
|
|
- document.execCommand(command);
|
|
|
-
|
|
|
- nextTick(() => {
|
|
|
- const content = editorRef.value.innerHTML;
|
|
|
- emit('submitHtml', content);
|
|
|
- emit('update:modelValue', content);
|
|
|
- immediateSave();
|
|
|
- updateToolbar();
|
|
|
- });
|
|
|
-};
|
|
|
-
|
|
|
-const applyHeading = () => {
|
|
|
- if (!editorRef.value) return;
|
|
|
- editorRef.value.focus();
|
|
|
- flushPendingSave();
|
|
|
-
|
|
|
- const tagName = headingLevel.value || 'P';
|
|
|
- document.execCommand('formatBlock', false, `<${tagName}>`);
|
|
|
-
|
|
|
- nextTick(() => {
|
|
|
- const content = editorRef.value.innerHTML;
|
|
|
- emit('submitHtml', content);
|
|
|
- emit('update:modelValue', content);
|
|
|
- immediateSave();
|
|
|
- updateToolbar();
|
|
|
- });
|
|
|
-};
|
|
|
-
|
|
|
-const applyFontSize = () => {
|
|
|
- if (!editorRef.value) return;
|
|
|
- editorRef.value.focus();
|
|
|
- flushPendingSave();
|
|
|
-
|
|
|
- const size = fontSize.value;
|
|
|
- const selection = window.getSelection();
|
|
|
- if (selection.rangeCount === 0) return;
|
|
|
-
|
|
|
- const range = selection.getRangeAt(0);
|
|
|
- const extracted = range.extractContents();
|
|
|
-
|
|
|
- function unwrapFontSizeSpans(parent) {
|
|
|
- const walker = document.createTreeWalker(parent, NodeFilter.SHOW_ELEMENT, {
|
|
|
- acceptNode(node) {
|
|
|
- if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SPAN' && node.style.fontSize) {
|
|
|
- return NodeFilter.FILTER_ACCEPT;
|
|
|
- }
|
|
|
- return NodeFilter.FILTER_SKIP;
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- const nodesToRemove = [];
|
|
|
- let node;
|
|
|
- while ((node = walker.nextNode())) {
|
|
|
- nodesToRemove.push(node);
|
|
|
- }
|
|
|
-
|
|
|
- for (let i = nodesToRemove.length - 1; i >= 0; i--) {
|
|
|
- const el = nodesToRemove[i];
|
|
|
- const parent = el.parentNode;
|
|
|
- while (el.firstChild) {
|
|
|
- parent.insertBefore(el.firstChild, el);
|
|
|
- }
|
|
|
- parent.removeChild(el);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const fragment = document.createDocumentFragment();
|
|
|
- fragment.appendChild(extracted);
|
|
|
- unwrapFontSizeSpans(fragment);
|
|
|
-
|
|
|
- if (size) {
|
|
|
- const newSpan = document.createElement('span');
|
|
|
- newSpan.style.fontSize = size + 'px';
|
|
|
- newSpan.appendChild(fragment);
|
|
|
- range.insertNode(newSpan);
|
|
|
-
|
|
|
- const newRange = document.createRange();
|
|
|
- newRange.selectNodeContents(newSpan);
|
|
|
- selection.removeAllRanges();
|
|
|
- selection.addRange(newRange);
|
|
|
- } else {
|
|
|
- range.insertNode(fragment);
|
|
|
-
|
|
|
- const commonAncestor = range.commonAncestorContainer;
|
|
|
- let container = commonAncestor.nodeType === Node.TEXT_NODE ? commonAncestor.parentElement : commonAncestor;
|
|
|
-
|
|
|
- while (container && container !== editorRef.value) {
|
|
|
- if (container.tagName && /^H[1-6]$/.test(container.tagName)) {
|
|
|
- container.style.fontSize = '';
|
|
|
- break;
|
|
|
- }
|
|
|
- container = container.parentElement;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- nextTick(() => {
|
|
|
- const content = editorRef.value.innerHTML;
|
|
|
- emit('submitHtml', content);
|
|
|
- emit('update:modelValue', content);
|
|
|
- immediateSave();
|
|
|
- updateToolbar();
|
|
|
- });
|
|
|
-};
|
|
|
-
|
|
|
-// ============ 输入监听(原有)===========
|
|
|
-const onInput = () => {
|
|
|
- if (editorRef.value && !isRestoring) {
|
|
|
- emit('submitHtml', editorRef.value.innerHTML);
|
|
|
- emit('update:modelValue', editorRef.value.innerHTML);
|
|
|
- debouncedSave();
|
|
|
- syncMentionsData();
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// ============ 外部数据更新监听 ============
|
|
|
-watch(
|
|
|
- () => props.modelValue,
|
|
|
- (newVal) => {
|
|
|
- if (!editorRef.value) return;
|
|
|
- const finalContent = newVal || '<p>请输入内容...</p>';
|
|
|
- if (editorRef.value.innerHTML !== finalContent) {
|
|
|
- editorRef.value.innerHTML = finalContent;
|
|
|
- saveToHistory(finalContent, null);
|
|
|
- updateToolbar();
|
|
|
- syncMentionsData();
|
|
|
- emit('submitHtml', finalContent);
|
|
|
- }
|
|
|
- }
|
|
|
-);
|
|
|
-watch(
|
|
|
- () => props.extracted_metrics,
|
|
|
- () => {
|
|
|
- mentionsData.value = props.extracted_metrics;
|
|
|
- }
|
|
|
-);
|
|
|
-watch(
|
|
|
- () => mentionsData.value,
|
|
|
- (list) => {
|
|
|
- console.log('mentionsData', list);
|
|
|
- reportEditor.updateMetricsList(list);
|
|
|
- },
|
|
|
- {
|
|
|
- deep: true
|
|
|
- }
|
|
|
-);
|
|
|
-
|
|
|
-// ============ 扩展功能占位 ============
|
|
|
-const handleSelect = (value) => {
|
|
|
- console.log('Selected:', value);
|
|
|
-};
|
|
|
-
|
|
|
-function triggerDropdown() {
|
|
|
- const range = window.getSelection()?.getRangeAt(0);
|
|
|
- if (!range || !editorRef.value?.contains(range.startContainer)) return;
|
|
|
-
|
|
|
- const rect = range.getBoundingClientRect();
|
|
|
- const containerRect = containerRef.value.getBoundingClientRect();
|
|
|
-
|
|
|
- dropdownPosition.value = {
|
|
|
- top: rect.bottom - containerRect.top + window.scrollY,
|
|
|
- left: rect.left - containerRect.left + window.scrollX
|
|
|
- };
|
|
|
-
|
|
|
- showDropdown.value = true;
|
|
|
-}
|
|
|
-
|
|
|
-function insertOption(opt) {
|
|
|
- const sel = window.getSelection();
|
|
|
- if (sel.rangeCount === 0 || !editorRef.value) return;
|
|
|
-
|
|
|
- const range = sel.getRangeAt(0);
|
|
|
-
|
|
|
- if (range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
|
|
|
- const testRange = range.cloneRange();
|
|
|
- testRange.setStart(range.startContainer, range.startOffset - 1);
|
|
|
- const charBefore = testRange.toString();
|
|
|
- if (charBefore === '@') {
|
|
|
- range.setStart(range.startContainer, range.startOffset - 1);
|
|
|
- range.deleteContents();
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const uid = generateUID();
|
|
|
-
|
|
|
- const placeholder = document.createElement('span');
|
|
|
- // placeholder.textContent = `{{${opt.metric_name}}} `;
|
|
|
- placeholder.setAttribute('contenteditable', 'false');
|
|
|
- placeholder.setAttribute('data-mention', 'true');
|
|
|
- placeholder.setAttribute('data-uid', uid);
|
|
|
- placeholder.style.cssText = `
|
|
|
- display: inline-flex;
|
|
|
- align-items: center;
|
|
|
- background-color: var(--question_bg);
|
|
|
- border-radius: 4px;
|
|
|
- padding: 0 4px;
|
|
|
- margin: 0 2px;}
|
|
|
- user-select: none;
|
|
|
- -webkit-user-select: none;
|
|
|
- cursor: pointer;
|
|
|
- color: var(--primary-default);
|
|
|
- `;
|
|
|
- const svg_icon = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
|
- fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_2_674"><rect x="0" y="0"
|
|
|
- width="16" height="26" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_2_674)"><g>
|
|
|
- <path d="M12.30009745625,5.94999981C12.15009785625,5.799999714,11.95009705625,5.75,11.75009725625,5.75L4.25009751625
|
|
|
- ,5.800000191C4.05009770625,5.800000191,3.85009741625,5.85000038,3.70009755625,6C3.40009760825,6.30000019,3.40009760825,6.75,3.70009755625,
|
|
|
- 7.0500001999999995L7.45009735625,10.8000002C7.50009725625,10.850000399999999,7.60009765625,10.9000006,7.65009685625,10.9499998C7.65009685625,
|
|
|
- 10.9499998,7.70009705625,11,7.70009705625,11C7.95009705625,11.100000399999999,8.30009745625,11.0500002,8.500097256250001,10.850000399999999L12.25009725625,
|
|
|
- 7.0500001999999995C12.60009765625,6.69999981,12.55009745625,6.25,12.30009745625,5.94999981Z" fill="var(--primary-default)" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></svg>`;
|
|
|
- placeholder.innerHTML = `{{${opt.metric_name}}} ${svg_icon}`;
|
|
|
- const zeroWidth = '\u200B';
|
|
|
- const after = document.createTextNode(zeroWidth);
|
|
|
- range.insertNode(after);
|
|
|
- range.insertNode(placeholder);
|
|
|
-
|
|
|
- const newRange = document.createRange();
|
|
|
- newRange.setStartAfter(after);
|
|
|
- newRange.collapse(true);
|
|
|
- sel.removeAllRanges();
|
|
|
- sel.addRange(newRange);
|
|
|
- // 保存到MentionData列表中
|
|
|
-
|
|
|
- mentionsData.value.push({
|
|
|
- uid,
|
|
|
- option: opt
|
|
|
- });
|
|
|
-
|
|
|
- hideDropdown();
|
|
|
- hideActionMenu();
|
|
|
- immediateSave(); // 👈 接入你的历史机制
|
|
|
- emit('submitHtml', editorRef.value.innerHTML);
|
|
|
-}
|
|
|
-// 当编辑器内容改变时,同步数据
|
|
|
-function syncMentionsData() {
|
|
|
- if (!editorRef.value) return;
|
|
|
-
|
|
|
- // 获取当前 DOM 中所有 mention 元素的 UID 集合
|
|
|
- const currentUIDs = new Set();
|
|
|
- const mentionsInDOM = editorRef.value.querySelectorAll('[data-mention][data-uid]');
|
|
|
- // 使用for循环替代forEach以提高性能
|
|
|
- for (let i = 0, len = mentionsInDOM.length; i < len; i++) {
|
|
|
- const uid = mentionsInDOM[i].getAttribute('data-uid');
|
|
|
- if (uid) currentUIDs.add(uid);
|
|
|
- }
|
|
|
- // 过滤 mentionsData,只保留还在 DOM 中的
|
|
|
- mentionsData.value = mentionsData.value.filter((item) => currentUIDs.has(item.uid));
|
|
|
-}
|
|
|
-function handleInputForMention(e) {
|
|
|
- if (isRestoring) return;
|
|
|
-
|
|
|
- const selection = window.getSelection();
|
|
|
- if (!selection?.rangeCount || !editorRef.value) return;
|
|
|
-
|
|
|
- const range = selection.getRangeAt(0);
|
|
|
- if (!editorRef.value.contains(range.startContainer)) return;
|
|
|
-
|
|
|
- let charBeforeCursor = '';
|
|
|
- if (range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
|
|
|
- const testRange = range.cloneRange();
|
|
|
- testRange.setStart(range.startContainer, range.startOffset - 1);
|
|
|
- charBeforeCursor = testRange.toString();
|
|
|
- }
|
|
|
-
|
|
|
- if (charBeforeCursor === '@') {
|
|
|
- if (!showDropdown.value) {
|
|
|
- triggerDropdown();
|
|
|
- }
|
|
|
- } else {
|
|
|
- hideDropdown();
|
|
|
- hideActionMenu();
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-function handleKeydownForMention(e) {
|
|
|
- if (showDropdown.value && mentionDropdownRef.value) {
|
|
|
- mentionDropdownRef.value.handleKeydown(e);
|
|
|
- if (e.key === 'Escape') {
|
|
|
- hideDropdown();
|
|
|
- hideActionMenu();
|
|
|
- }
|
|
|
- // 如果已处理(如 Enter),阻止默认
|
|
|
- if (['ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) {
|
|
|
- e.preventDefault();
|
|
|
- }
|
|
|
- } else if (e.key === 'Escape') {
|
|
|
- hideDropdown();
|
|
|
- hideActionMenu();
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-function hideDropdown() {
|
|
|
- showDropdown.value = false;
|
|
|
-}
|
|
|
-
|
|
|
-function hideActionMenu() {
|
|
|
- showActionMenu.value = false;
|
|
|
- currentMentionElement.value = null;
|
|
|
-}
|
|
|
-// 编辑器中的点击事件合集
|
|
|
-function handleEditorClick(e) {
|
|
|
- console.log('handleEditorClick', e.target);
|
|
|
- console.log('handleEditorClick', actionMenuRef.value.$el);
|
|
|
- // console.log('handleEditorClick', actionMenuRef.value?.contains(e.target));
|
|
|
- if (actionMenuRef.value && !actionMenuRef.value.$el?.contains(e.target)) {
|
|
|
- hideActionMenu();
|
|
|
- }
|
|
|
-
|
|
|
- // 关闭图表面板下拉框
|
|
|
- if (
|
|
|
- chartPanelDropdownRef.value &&
|
|
|
- !chartPanelDropdownRef.value.$el?.contains(e.target) &&
|
|
|
- !chartButtonRef.value?.contains(e.target)
|
|
|
- ) {
|
|
|
- showChartPanelDropdown.value = false;
|
|
|
- }
|
|
|
- // console.log('reportEditor.is_need_save', reportEditor.QueryNeedSave);
|
|
|
- // if (reportEditor.QueryNeedSave) {
|
|
|
- // showTips.value = true;
|
|
|
- // return;
|
|
|
- // }
|
|
|
-
|
|
|
- // 移除所有已有的高亮样式
|
|
|
- const allMentions = editorRef.value.querySelectorAll('[data-mention]');
|
|
|
- allMentions.forEach((mention) => {
|
|
|
- mention.classList.remove('mention-highlighted');
|
|
|
- });
|
|
|
- // 关闭过滤器
|
|
|
- closeFilterModel();
|
|
|
-
|
|
|
- // 如果点击的是提及
|
|
|
- const mention = e.target.closest('[data-mention]');
|
|
|
- if (mention) {
|
|
|
- const uid = mention?.getAttribute('data-uid');
|
|
|
- const option = mentionsData.value.find((item) => item.uid === uid);
|
|
|
- reportEditor.setTargetMetricId(uid);
|
|
|
-
|
|
|
- // 添加高亮样式
|
|
|
- mention.classList.add('mention-highlighted');
|
|
|
- // console.log(option.option.advanced_computing_config);
|
|
|
- // console.log(JSON.parse(option.option.advanced_computing_config));
|
|
|
- target_action.value = JSON.parse(option.option.advanced_computing_config);
|
|
|
- // 给父组件一个触发一个事件,显示过滤器
|
|
|
- emit('mention-click', option);
|
|
|
- nextTick(() => {
|
|
|
- currentMentionElement.value = mention;
|
|
|
-
|
|
|
- const rect = mention.getBoundingClientRect();
|
|
|
- const containerRect = containerRef.value.getBoundingClientRect();
|
|
|
-
|
|
|
- actionMenuPosition.value = {
|
|
|
- top: rect.bottom - containerRect.top + window.scrollY,
|
|
|
- left: rect.left - containerRect.left + window.scrollX
|
|
|
- };
|
|
|
-
|
|
|
- nextTick(() => {
|
|
|
- showActionMenu.value = true;
|
|
|
- });
|
|
|
- e.stopPropagation();
|
|
|
- });
|
|
|
- }
|
|
|
-}
|
|
|
-const closeFilterModel = () => {
|
|
|
- emit('close-filter-model');
|
|
|
-};
|
|
|
-
|
|
|
-const toSaveSetting = () => {
|
|
|
- showTips.value = false;
|
|
|
- emit('to-save-setting');
|
|
|
-};
|
|
|
-const toNotSaveSetting = () => {
|
|
|
- showTips.value = false;
|
|
|
-};
|
|
|
-
|
|
|
-// ============ 拖拽接收逻辑 ============
|
|
|
-const onDragOverMetric = (event) => {
|
|
|
- // 可选:只允许特定类型拖入
|
|
|
- event.preventDefault();
|
|
|
- event.stopPropagation();
|
|
|
- // if (event.dataTransfer.types.includes('application/x-metrics')) {
|
|
|
- // event.dataTransfer.dropEffect = 'copy';
|
|
|
- // } else {
|
|
|
- // event.dataTransfer.dropEffect = 'none';
|
|
|
- // }
|
|
|
- if (editorRef.value && document.activeElement !== editorRef.value) {
|
|
|
- editorRef.value.focus({ preventScroll: true });
|
|
|
- }
|
|
|
- event.dataTransfer.dropEffect = 'copy'; // 不再判断类型
|
|
|
-};
|
|
|
-
|
|
|
-// 根据 drop 坐标获取富文本内的有效 Range
|
|
|
-function getDropRangeInEditor(x, y) {
|
|
|
- if (!editorRef.value) return null;
|
|
|
-
|
|
|
- // 1. 使用标准 API(Chrome / Safari)
|
|
|
- if (document.caretRangeFromPoint) {
|
|
|
- const range = document.caretRangeFromPoint(x, y);
|
|
|
- if (range && editorRef.value.contains(range.startContainer)) {
|
|
|
- return range;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 2. Firefox 或 fallback:用 elementFromPoint
|
|
|
- const el = document.elementFromPoint(x, y);
|
|
|
- if (!el || !editorRef.value.contains(el)) return null;
|
|
|
-
|
|
|
- // 如果点中的是 mention 元素(不可编辑),找最近的可插入位置
|
|
|
- let target = el.closest('[contenteditable="false"]') ? el.parentElement : el;
|
|
|
-
|
|
|
- // 确保 target 在 editor 内
|
|
|
- while (target && target !== editorRef.value && !target.isEqualNode(editorRef.value)) {
|
|
|
- if (target.nodeType === Node.ELEMENT_NODE) break;
|
|
|
- target = target.parentNode;
|
|
|
- }
|
|
|
-
|
|
|
- if (!target || target === editorRef.value) {
|
|
|
- // 直接插入到编辑器末尾
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- const range = document.createRange();
|
|
|
- try {
|
|
|
- range.selectNodeContents(target);
|
|
|
- range.collapse(false); // 插入到元素末尾
|
|
|
- } catch (e) {
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- return range;
|
|
|
-}
|
|
|
-
|
|
|
-const onDropMetric = (event) => {
|
|
|
- event.preventDefault();
|
|
|
- event.stopPropagation();
|
|
|
-
|
|
|
- // 强制聚焦富文本(即使没光标)
|
|
|
- if (editorRef.value && document.activeElement !== editorRef.value) {
|
|
|
- editorRef.value.focus({ preventScroll: true });
|
|
|
- }
|
|
|
-
|
|
|
- const text = event.dataTransfer.getData('text/plain');
|
|
|
- if (!text?.startsWith('__METRIC__')) return;
|
|
|
-
|
|
|
- let item;
|
|
|
- try {
|
|
|
- item = JSON.parse(text.slice('__METRIC__'.length));
|
|
|
- if (!item || !item.metric_name) return;
|
|
|
- } catch (e) {
|
|
|
- console.warn('Drop parse error:', e);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 👇 关键:根据鼠标位置获取插入点
|
|
|
- let range = getDropRangeInEditor(event.clientX, event.clientY);
|
|
|
-
|
|
|
- if (!range) {
|
|
|
- // Fallback: 插入到富文本末尾
|
|
|
- const editor = editorRef.value;
|
|
|
- const lastChild = editor.lastChild;
|
|
|
-
|
|
|
- range = document.createRange();
|
|
|
- if (lastChild) {
|
|
|
- range.selectNodeContents(lastChild);
|
|
|
- range.collapse(false);
|
|
|
- } else {
|
|
|
- // 编辑器完全为空
|
|
|
- range.selectNodeContents(editor);
|
|
|
- range.collapse(true);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 设置 selection 到目标位置
|
|
|
- const sel = window.getSelection();
|
|
|
- sel.removeAllRanges();
|
|
|
- sel.addRange(range);
|
|
|
-
|
|
|
- // 调用你已有的 insertOption(它会基于当前 selection 插入)
|
|
|
- insertOption(item);
|
|
|
-};
|
|
|
-function handleAction(action) {
|
|
|
- const mention = currentMentionElement.value;
|
|
|
- if (!mention || !editorRef.value) return;
|
|
|
-
|
|
|
- switch (action) {
|
|
|
- case 'delete':
|
|
|
- mention.remove();
|
|
|
- break;
|
|
|
-
|
|
|
- case 'edit':
|
|
|
- const rawText = mention.textContent.slice(1, -1);
|
|
|
- const textNode = document.createTextNode(`@${rawText}`);
|
|
|
- mention.replaceWith(textNode);
|
|
|
-
|
|
|
- const range = document.createRange();
|
|
|
- range.setStartAfter(textNode);
|
|
|
- range.collapse(true);
|
|
|
- const sel = window.getSelection();
|
|
|
- sel.removeAllRanges();
|
|
|
- sel.addRange(range);
|
|
|
-
|
|
|
- nextTick(() => {
|
|
|
- editorRef.value.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
- });
|
|
|
- return;
|
|
|
-
|
|
|
- case 'view':
|
|
|
- const name = mention.textContent.slice(1, -1);
|
|
|
- alert(`查看详情: ${name}`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- hideActionMenu();
|
|
|
- immediateSave();
|
|
|
- emit('submitHtml', editorRef.value.innerHTML);
|
|
|
-}
|
|
|
-
|
|
|
-// 处理图表面板下拉菜单点击-插入图表节点
|
|
|
-function handleChartPanelDropdownClick(item) {
|
|
|
- showChartPanelDropdown.value = false;
|
|
|
-
|
|
|
- if (!editorRef.value) return;
|
|
|
-
|
|
|
- // 强制聚焦富文本
|
|
|
- editorRef.value.focus({ preventScroll: true });
|
|
|
-
|
|
|
- const selection = window.getSelection();
|
|
|
- const editor = editorRef.value;
|
|
|
- let insertRange;
|
|
|
-
|
|
|
- // 优先使用保存的光标位置
|
|
|
- if (savedCursorPosition.value && editor.contains(savedCursorPosition.value.startContainer)) {
|
|
|
- insertRange = savedCursorPosition.value;
|
|
|
- } else if (selection.rangeCount && editor.contains(selection.getRangeAt(0).startContainer)) {
|
|
|
- const currentRange = selection.getRangeAt(0);
|
|
|
-
|
|
|
- // 判断光标是否在编辑器开头
|
|
|
- let isAtStart = false;
|
|
|
-
|
|
|
- // 情况1:编辑器为空
|
|
|
- if (editor.innerHTML.trim() === '') {
|
|
|
- isAtStart = true;
|
|
|
- } else {
|
|
|
- // 情况2:光标在第一个节点的开头
|
|
|
- const firstChild = editor.firstChild;
|
|
|
- if (firstChild) {
|
|
|
- // 获取编辑器的第一个文本节点
|
|
|
- let firstTextNode = null;
|
|
|
- let node = firstChild;
|
|
|
-
|
|
|
- // 查找第一个文本节点
|
|
|
- while (node && !firstTextNode) {
|
|
|
- if (node.nodeType === Node.TEXT_NODE) {
|
|
|
- firstTextNode = node;
|
|
|
- } else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
|
- if (node.firstChild) {
|
|
|
- node = node.firstChild;
|
|
|
- } else {
|
|
|
- // 空元素,直接判断
|
|
|
- firstTextNode = node;
|
|
|
- }
|
|
|
- } else {
|
|
|
- node = node.nextSibling;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 比较当前光标位置与第一个文本节点的位置
|
|
|
- if (firstTextNode) {
|
|
|
- isAtStart = currentRange.startContainer === firstTextNode && currentRange.startOffset === 0;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (isAtStart) {
|
|
|
- // 光标在开头,插入到末尾
|
|
|
- insertRange = document.createRange();
|
|
|
- const lastChild = editor.lastChild;
|
|
|
-
|
|
|
- if (lastChild) {
|
|
|
- insertRange.selectNodeContents(lastChild);
|
|
|
- insertRange.collapse(false); // 折叠到末尾
|
|
|
- } else {
|
|
|
- insertRange.selectNodeContents(editor);
|
|
|
- insertRange.collapse(true);
|
|
|
- }
|
|
|
- } else {
|
|
|
- // 光标不在开头,使用当前光标位置
|
|
|
- insertRange = currentRange;
|
|
|
- }
|
|
|
- } else {
|
|
|
- // 没有有效光标位置,插入到末尾
|
|
|
- insertRange = document.createRange();
|
|
|
- const lastChild = editor.lastChild;
|
|
|
-
|
|
|
- if (lastChild) {
|
|
|
- insertRange.selectNodeContents(lastChild);
|
|
|
- insertRange.collapse(false); // 折叠到末尾
|
|
|
- } else {
|
|
|
- insertRange.selectNodeContents(editor);
|
|
|
- insertRange.collapse(true);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 创建图表div元素
|
|
|
- const chartDiv = document.createElement('div');
|
|
|
- const chartId = generateUID();
|
|
|
- chartDiv.className = 'chart-container';
|
|
|
- chartDiv.setAttribute('data-chart-id', chartId);
|
|
|
- chartDiv.setAttribute('data-chart-type', item.type);
|
|
|
- chartDiv.setAttribute('contenteditable', 'false');
|
|
|
- chartDiv.style.cssText = `
|
|
|
- display: inline-block;
|
|
|
- border: 1px dashed #d9d9d9;
|
|
|
- border-radius: 4px;
|
|
|
- padding: 20px;
|
|
|
- margin: 10px 0;
|
|
|
- background-color: #fafafa;
|
|
|
- width: calc(99% - 40px);
|
|
|
- min-height: 200px;
|
|
|
- text-align: center;
|
|
|
- cursor: pointer;
|
|
|
- vertical-align: bottom;
|
|
|
- `;
|
|
|
-
|
|
|
- // 先插入图表div
|
|
|
- insertRange.insertNode(chartDiv);
|
|
|
-
|
|
|
- // 初始化图表数据并存储到chartDataStore中
|
|
|
- chartDataStore.value[chartId] = {
|
|
|
- type: item.type,
|
|
|
- data: [],
|
|
|
- columns: [],
|
|
|
- isLoading: false
|
|
|
- };
|
|
|
-
|
|
|
- // 如果是表格类型,初始加载TableChart组件
|
|
|
- if (item.type === 'table') {
|
|
|
- // 创建表格组件实例
|
|
|
- const app = createApp(TableChart, {
|
|
|
- chartId: chartId,
|
|
|
- chartData: chartDataStore.value[chartId].data,
|
|
|
- chartColumns: chartDataStore.value[chartId].columns,
|
|
|
- isLoading: chartDataStore.value[chartId].isLoading
|
|
|
- });
|
|
|
- // 挂载到chartDiv
|
|
|
- app.mount(chartDiv);
|
|
|
- } else {
|
|
|
- // 其他图表类型显示默认内容
|
|
|
- chartDiv.innerHTML = `
|
|
|
- // <div style="font-size: 14px; color: #666; margin-bottom: 10px;">${item.name}</div>
|
|
|
- // <div style="font-size: 12px; color: #999;">点击编辑图表</div>
|
|
|
- `;
|
|
|
- }
|
|
|
-
|
|
|
- // 创建零宽度空格节点,用于定位光标
|
|
|
- // const zeroWidthSpace = document.createTextNode('\u200B');
|
|
|
-
|
|
|
- // 然后在图表后面插入零宽度空格节点
|
|
|
- // chartDiv.parentNode.insertBefore(zeroWidthSpace, chartDiv.nextSibling);
|
|
|
-
|
|
|
- // 确保光标在零宽度空格后面
|
|
|
- const newRange = document.createRange();
|
|
|
- // newRange.setStartAfter(zeroWidthSpace);
|
|
|
- newRange.collapse(true);
|
|
|
-
|
|
|
- // 清除所有选择范围并添加新范围
|
|
|
- selection.removeAllRanges();
|
|
|
- selection.addRange(newRange);
|
|
|
-
|
|
|
- // 滚动到插入的图表位置
|
|
|
- chartDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
-
|
|
|
- // 重置保存的光标位置
|
|
|
- savedCursorPosition.value = null;
|
|
|
-
|
|
|
- // 保存到历史记录
|
|
|
- immediateSave();
|
|
|
-
|
|
|
- // 更新父组件
|
|
|
- emit('submitHtml', editor.innerHTML);
|
|
|
- emit('update:modelValue', editor.innerHTML);
|
|
|
-}
|
|
|
-
|
|
|
-// 更新图表数据的示例函数
|
|
|
-function updateChartData(chartId, data, columns) {
|
|
|
- if (chartDataStore.value[chartId]) {
|
|
|
- chartDataStore.value[chartId].data = data;
|
|
|
- chartDataStore.value[chartId].columns = columns;
|
|
|
- chartDataStore.value[chartId].isLoading = false;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// 设置图表加载状态的示例函数
|
|
|
-function setChartLoading(chartId, isLoading) {
|
|
|
- if (chartDataStore.value[chartId]) {
|
|
|
- chartDataStore.value[chartId].isLoading = isLoading;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// 切换图表面板下拉框显示状态
|
|
|
-function toggleChartPanelDropdown() {
|
|
|
- showChartPanelDropdown.value = !showChartPanelDropdown.value;
|
|
|
- if (showChartPanelDropdown.value) {
|
|
|
- // 保存当前光标位置,防止点击按钮后光标丢失
|
|
|
- const selection = window.getSelection();
|
|
|
- if (selection.rangeCount && editorRef.value.contains(selection.getRangeAt(0).startContainer)) {
|
|
|
- savedCursorPosition.value = selection.getRangeAt(0).cloneRange();
|
|
|
- }
|
|
|
- calculateChartPanelPosition();
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// 计算图表面板下拉框位置
|
|
|
-function calculateChartPanelPosition() {
|
|
|
- if (!chartButtonRef.value || !containerRef.value) return;
|
|
|
-
|
|
|
- const buttonRect = chartButtonRef.value.getBoundingClientRect();
|
|
|
- const containerRect = containerRef.value.getBoundingClientRect();
|
|
|
-
|
|
|
- chartPanelDropdownPosition.value = {
|
|
|
- top: buttonRect.bottom - containerRect.top,
|
|
|
- left: buttonRect.left - containerRect.left
|
|
|
- };
|
|
|
-}
|
|
|
-</script>
|
|
|
-
|
|
|
-<style scoped>
|
|
|
-/* 原有样式保持不变 */
|
|
|
-.rich-text-editor {
|
|
|
- border: 1px solid #d8d8d8;
|
|
|
- border-radius: 20px;
|
|
|
- width: calc(100% - 30px);
|
|
|
- height: calc(100% - 72px);
|
|
|
- font-family: sans-serif;
|
|
|
- margin: 0px 10px 10px 20px;
|
|
|
- box-sizing: border-box;
|
|
|
- position: relative; /* 👈 关键:使浮层定位基于此 */
|
|
|
-}
|
|
|
-
|
|
|
-.toolbar {
|
|
|
- display: flex;
|
|
|
- flex-direction: row;
|
|
|
- justify-content: space-between;
|
|
|
- align-items: center;
|
|
|
-}
|
|
|
-
|
|
|
-.toolbar_left,
|
|
|
-.toolbar_right {
|
|
|
- display: flex;
|
|
|
- flex-direction: row;
|
|
|
- align-items: center;
|
|
|
- gap: 8px;
|
|
|
- padding: 8px;
|
|
|
-}
|
|
|
-
|
|
|
-.toolbar .btn-icon {
|
|
|
- width: 20px;
|
|
|
-}
|
|
|
-.toolbar .toolbar_left button {
|
|
|
- border: none;
|
|
|
- padding: 5px;
|
|
|
-}
|
|
|
-
|
|
|
-.toolbar .btn.active {
|
|
|
- color: var(--primary-default);
|
|
|
- background-color: rgba(0, 0, 0, 0.05);
|
|
|
- border-radius: 4px;
|
|
|
-}
|
|
|
-
|
|
|
-.editor {
|
|
|
- height: calc(100% - 68px);
|
|
|
- min-height: 150px;
|
|
|
- overflow-y: auto;
|
|
|
- padding: 12px;
|
|
|
- padding-top: 0px;
|
|
|
- outline: none;
|
|
|
- line-height: 1.5;
|
|
|
- pointer-events: auto;
|
|
|
- user-select: text;
|
|
|
-}
|
|
|
-
|
|
|
-.editor:focus {
|
|
|
- outline: none;
|
|
|
-}
|
|
|
-
|
|
|
-:deep(.editor img) {
|
|
|
- width: 100% !important;
|
|
|
- height: auto !important;
|
|
|
- display: block;
|
|
|
- max-width: 100%;
|
|
|
- object-fit: contain;
|
|
|
-}
|
|
|
-
|
|
|
-.toolbar select {
|
|
|
- padding: 6px 10px;
|
|
|
- font-size: 14px;
|
|
|
- border: 1px solid #ccc;
|
|
|
- border-radius: 6px;
|
|
|
- background-color: #fff;
|
|
|
- cursor: pointer;
|
|
|
- outline: none;
|
|
|
- appearance: none;
|
|
|
- background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3e%3cpath d='M6 9l6 6 6-6'/%3e%3c/svg%3e");
|
|
|
- background-repeat: no-repeat;
|
|
|
- background-position: right 8px center;
|
|
|
- background-size: 12px;
|
|
|
- padding-right: 30px;
|
|
|
- min-width: 90px;
|
|
|
- box-sizing: border-box;
|
|
|
-}
|
|
|
-
|
|
|
-.toolbar select:hover {
|
|
|
- border-color: var(--bg-title);
|
|
|
-}
|
|
|
-
|
|
|
-.toolbar select:focus {
|
|
|
- border-color: var(--primary-default);
|
|
|
- box-shadow: 0 0 0 2px var(--bg-title);
|
|
|
-}
|
|
|
-
|
|
|
-.toolbar .chat-select {
|
|
|
- width: 120px;
|
|
|
-}
|
|
|
-
|
|
|
-.chat-select .insert_btn {
|
|
|
- width: 100%;
|
|
|
-}
|
|
|
-
|
|
|
-.insert_btn .btn-icon {
|
|
|
- width: 16px;
|
|
|
- margin-right: 5px;
|
|
|
-}
|
|
|
-
|
|
|
-.arco-dropdown-open .arco-icon-down {
|
|
|
- transform: rotate(180deg);
|
|
|
-}
|
|
|
-
|
|
|
-:deep(.editor h1) {
|
|
|
- font-size: 28px;
|
|
|
-}
|
|
|
-:deep(.editor h2) {
|
|
|
- font-size: 20px;
|
|
|
-}
|
|
|
-:deep(.editor h3) {
|
|
|
- font-size: 16px;
|
|
|
-}
|
|
|
-
|
|
|
-/* ========== Mention 样式 ==========
|
|
|
-.floating-dropdown {
|
|
|
- position: absolute;
|
|
|
- z-index: 1000;
|
|
|
- background: white;
|
|
|
- border: 1px solid #d9d9d9;
|
|
|
- border-radius: 4px;
|
|
|
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
|
- min-width: 120px;
|
|
|
- max-height: 200px;
|
|
|
- overflow-y: auto;
|
|
|
-}
|
|
|
-
|
|
|
-/* 指标高亮样式 */
|
|
|
-:deep(.editor [data-mention].mention-highlighted) {
|
|
|
- background-color: var(--primary-light-1);
|
|
|
- border: 1px solid var(--primary-default);
|
|
|
- box-shadow: 0 0 0 2px var(--primary-light-2);
|
|
|
-}
|
|
|
-
|
|
|
-.dropdown-option {
|
|
|
- display: block;
|
|
|
- width: 100%;
|
|
|
- text-align: left;
|
|
|
- padding: 6px 10px;
|
|
|
- border: none;
|
|
|
- background: white;
|
|
|
- cursor: pointer;
|
|
|
-}
|
|
|
-
|
|
|
-.dropdown-option:hover {
|
|
|
- background-color: #f5f5f5;
|
|
|
-}
|
|
|
-
|
|
|
-/* .mention-action-menu {
|
|
|
- position: absolute;
|
|
|
- z-index: 1001;
|
|
|
- background: white;
|
|
|
- border: 1px solid #ccc;
|
|
|
- border-radius: 4px;
|
|
|
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
|
|
- min-width: 100px;
|
|
|
-}
|
|
|
-
|
|
|
-.mention-action-menu button {
|
|
|
- display: block;
|
|
|
- width: 100%;
|
|
|
- padding: 6px 10px;
|
|
|
- border: none;
|
|
|
- background: white;
|
|
|
- text-align: left;
|
|
|
- cursor: pointer;
|
|
|
-}
|
|
|
-
|
|
|
-.mention-action-menu button:hover {
|
|
|
- background-color: #f0f0f0;
|
|
|
-} */
|
|
|
-</style>
|