富文本编辑

从welcome进入
This commit is contained in:
Qiubo Huang 2026-02-07 10:38:55 +08:00
parent b08bbc5550
commit de98e82461
15 changed files with 3175 additions and 35 deletions

13
content.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Content Editor</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/content-main.ts"></script>
</body>
</html>

1
docs/~$设计文档.docx Normal file
View File

@ -0,0 +1 @@
Administrator Administrator0_r» ÿÒôOý <> <> «ƒ5ýpÆÏ[

BIN
docs/~WRL0542.tmp Normal file

Binary file not shown.

87
docs/设计文档.docx Normal file
View File

@ -0,0 +1,87 @@
设计文档
树形结构:
表结构
node:节点表:
字段名
含义
类型
说明
id
int
AUTO_INCREMENT PRIMARY KEY
uuid
节点标识
VARCHAR(36)
type
类型
VARCHAR(50)
以下之一product scenario, product definition, product target, attribute, function, system, component
owner_id
所有者id
int
用户表id
title
标题
create_time
创建时间
TIMESTAMP
hierarchy
层次
int
product scenario及子树1
product definition及子树2
product target及子树3
attribute, function及子树4
system及子树5
component及子树6
说明预先创建树形结构一级节点product scenario, product definition, product target, attribute, function, system, component赋好层次的值。在创建子节点时继承父节点的层次值。
node_relation: 节点关系表
字段名
含义
类型
说明
id
int
AUTO_INCREMENT PRIMARY KEY
parent_id
父节点ID
int
node表id为0表示1级节点
child_id
子节点ID
int
node表id
sort_order
排序权重
越小越靠前
relation_type
关系类型
VARCHAR(20)
direct直接子节点reference引用节点。DEFAULT 'direct'
确保同一父节点下,同一子节点不会重复添加:
UNIQUE KEY uk_parent_child (parent_id, child_id),
说明:
1.分离 nodes 和 node_relations的原因
支持DAG结构一个节点只能是一个父节点的direct子节点但可以是多个父节点的reference子节点。如果存在reference子节点那么不允许删除direct子节点。
2、sort_order的作用
支持调整显示顺序sort_order小的显示在前。在界面上允许拖拽调整顺序拖拽不能改变父节点。

916
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,11 +9,21 @@
"preview": "vite preview"
},
"dependencies": {
"@tiptap/extension-image": "^3.19.0",
"@tiptap/extension-table": "^3.19.0",
"@tiptap/extension-table-cell": "^3.19.0",
"@tiptap/extension-table-header": "^3.19.0",
"@tiptap/extension-table-row": "^3.19.0",
"@tiptap/extension-text-align": "^3.19.0",
"@tiptap/extension-underline": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"@tiptap/vue-3": "^3.19.0",
"@vueuse/core": "^10.11.0",
"lucide-vue-next": "^0.562.0",
"quill": "^2.0.2",
"uuid": "^9.0.1",
"vue": "^3.5.13"
"vue": "^3.5.13",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/uuid": "^10.0.0",

View File

@ -16,13 +16,20 @@ const handleUpdateNodeContent = (nodeId: string, content: any) => {
// TreeView
treeViewRef.value?.updateNodeContent(nodeId, content)
}
const handleMenuClick = (label: string) => {
if (label === 'Welcome') {
// content.vue
window.open('/content.html', '_blank')
}
}
</script>
<template>
<div class="app">
<!-- Top Ribbon Menu -->
<header class="app-header">
<RibbonMenu />
<RibbonMenu @menu-click="handleMenuClick" />
</header>
<!-- Main Content Area -->

View File

@ -0,0 +1,583 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { Maximize2, GripVertical, Trash2, ZoomIn, X, Maximize, Minimize } from 'lucide-vue-next'
const props = defineProps(nodeViewProps)
const isSelected = computed(() => props.selected)
const isResizing = ref(false)
const imageRef = ref<HTMLImageElement | null>(null)
//
const width = computed(() => {
return props.node.attrs.width || '400px'
})
const height = computed(() => {
return props.node.attrs.height || 'auto'
})
//
const showZoomButton = ref(false)
const showPreviewModal = ref(false)
const isFullscreen = ref(false)
const previewMode = ref<'fit' | 'original'>('fit')
//
const startResize = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
isResizing.value = true
const startX = e.clientX
const startY = e.clientY
const img = imageRef.value
if (!img) return
const startWidth = img.offsetWidth
const startHeight = img.offsetHeight
const aspectRatio = startWidth / startHeight
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing.value) return
const deltaX = e.clientX - startX
const newWidth = Math.max(100, startWidth + deltaX)
const newHeight = newWidth / aspectRatio
props.updateAttributes({
width: `${newWidth}px`,
height: `${newHeight}px`
})
}
const handleMouseUp = () => {
isResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
//
const deleteImage = () => {
props.deleteNode()
}
//
const openPreview = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
showPreviewModal.value = true
previewMode.value = 'fit'
isFullscreen.value = false
}
//
const closePreview = () => {
showPreviewModal.value = false
if (isFullscreen.value && document.fullscreenElement) {
document.exitFullscreen()
}
isFullscreen.value = false
}
//
const toggleFullscreen = async () => {
const modal = document.querySelector('.preview-modal-content') as HTMLElement
if (!modal) return
if (!document.fullscreenElement) {
await modal.requestFullscreen()
isFullscreen.value = true
} else {
await document.exitFullscreen()
isFullscreen.value = false
}
}
//
const togglePreviewMode = () => {
previewMode.value = previewMode.value === 'fit' ? 'original' : 'fit'
}
//
const handleDragStart = (e: DragEvent) => {
if (isResizing.value) {
e.preventDefault()
return
}
}
//
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && showPreviewModal.value) {
closePreview()
}
}
</script>
<template>
<NodeViewWrapper
class="image-resize-wrapper"
:class="{ 'is-selected': isSelected }"
>
<div
class="image-container"
@mouseenter="showZoomButton = true"
@mouseleave="showZoomButton = false"
>
<img
ref="imageRef"
:src="node.attrs.src"
:style="{ width: width, height: height }"
class="resizable-image"
draggable="false"
/>
<!-- 放大按钮 - 悬停时显示点击放大 -->
<button
v-if="showZoomButton && !isSelected"
class="zoom-button"
@mousedown.stop.prevent="openPreview"
title="查看大图"
>
<ZoomIn :size="20" />
</button>
<!-- 缩放手柄 - 仅在选中时显示 -->
<div
v-if="isSelected"
class="resize-handle"
@mousedown="startResize"
title="拖拽调整大小"
>
<Maximize2 :size="12" />
</div>
<!-- 删除按钮 - 仅在选中时显示 -->
<button
v-if="isSelected"
class="delete-image-btn"
@click.stop="deleteImage"
title="删除图片"
>
<Trash2 :size="14" />
</button>
<!-- 拖拽指示器 -->
<div v-if="isSelected" class="drag-indicator">
<GripVertical :size="14" />
</div>
</div>
<!-- 图片预览弹窗 -->
<Teleport to="body">
<div
v-if="showPreviewModal"
class="preview-modal-overlay"
@click.self="closePreview"
@keydown="handleKeydown"
tabindex="0"
>
<div
class="preview-modal-content"
:class="{ 'is-fullscreen': isFullscreen }"
>
<!-- 工具栏 -->
<div class="preview-toolbar">
<div class="preview-info">
<span class="preview-title">图片预览</span>
<span class="preview-mode">{{ previewMode === 'fit' ? '适应屏幕' : '原始尺寸' }}</span>
</div>
<div class="preview-actions">
<button
class="preview-btn"
@click="togglePreviewMode"
:title="previewMode === 'fit' ? '切换到原始尺寸' : '切换到适应屏幕'"
>
<Maximize2 :size="16" />
<span>{{ previewMode === 'fit' ? '1:1' : '适应' }}</span>
</button>
<button
class="preview-btn"
@click="toggleFullscreen"
:title="isFullscreen ? '退出全屏' : '全屏显示'"
>
<Minimize v-if="isFullscreen" :size="16" />
<Maximize v-else :size="16" />
<span>{{ isFullscreen ? '退出' : '全屏' }}</span>
</button>
<button class="preview-btn close-btn" @click="closePreview" title="关闭 (ESC)">
<X :size="18" />
</button>
</div>
</div>
<!-- 图片显示区 -->
<div
class="preview-image-container"
:class="{ 'original-size': previewMode === 'original' }"
@click.self="closePreview"
>
<img
:src="node.attrs.src"
class="preview-image"
:class="{ 'fit-screen': previewMode === 'fit', 'original': previewMode === 'original' }"
@click.stop
/>
</div>
<!-- 底部信息 -->
<div class="preview-footer">
<span class="image-info">
提示使用鼠标滚轮缩放拖拽移动ESC键关闭
</span>
</div>
</div>
</div>
</Teleport>
</NodeViewWrapper>
</template>
<style scoped>
.image-resize-wrapper {
display: inline-block;
position: relative;
margin: 8px 0;
cursor: pointer;
transition: all 0.2s;
}
.image-resize-wrapper:hover {
outline: 1px dashed #0078d4;
}
.image-resize-wrapper.is-selected {
outline: 2px solid #0078d4;
outline-offset: 2px;
}
.image-container {
position: relative;
display: inline-block;
cursor: default;
}
.resizable-image {
display: block;
border-radius: 4px;
user-select: none;
transition: box-shadow 0.2s;
cursor: default;
}
.resizable-image:hover {
cursor: default;
}
.image-resize-wrapper.is-selected .resizable-image {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* 放大按钮 */
.zoom-button {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50px;
height: 50px;
background: rgba(0, 120, 212, 0.95);
border: 2px solid #fff;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
transition: transform 0.2s ease, background 0.2s ease, opacity 0.2s ease;
padding: 0;
z-index: 100;
opacity: 0;
pointer-events: auto;
}
.image-container:hover .zoom-button {
opacity: 1;
}
.zoom-button:hover {
transform: translate(-50%, -50%) scale(1.1);
background: rgba(0, 120, 212, 1);
}
/* 缩放手柄 */
.resize-handle {
position: absolute;
bottom: -6px;
right: -6px;
width: 20px;
height: 20px;
background: linear-gradient(to bottom, #0078d4, #005a9e);
border: 2px solid #fff;
border-radius: 50%;
cursor: nwse-resize;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
z-index: 10;
transition: transform 0.15s;
}
.resize-handle:hover {
transform: scale(1.1);
}
/* 删除按钮 */
.delete-image-btn {
position: absolute;
top: -10px;
right: -10px;
width: 24px;
height: 24px;
background: linear-gradient(to bottom, #dc3545, #c82333);
border: 2px solid #fff;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
z-index: 10;
transition: transform 0.15s;
padding: 0;
}
.delete-image-btn:hover {
transform: scale(1.1);
background: linear-gradient(to bottom, #c82333, #bd2130);
}
/* 拖拽指示器 */
.drag-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 32px;
height: 32px;
background: rgba(0, 120, 212, 0.8);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
z-index: 5;
}
.image-resize-wrapper:hover .drag-indicator {
opacity: 1;
}
.image-resize-wrapper.is-selected .drag-indicator {
opacity: 0;
}
/* ========== 预览弹窗样式 ========== */
.preview-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.preview-modal-content {
width: 90vw;
height: 90vh;
background: #1a1a1a;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: slideIn 0.3s ease;
}
.preview-modal-content.is-fullscreen {
width: 100vw;
height: 100vh;
border-radius: 0;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 工具栏 */
.preview-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: linear-gradient(to bottom, #2a2a2a, #1f1f1f);
border-bottom: 1px solid #333;
}
.preview-info {
display: flex;
align-items: center;
gap: 12px;
}
.preview-title {
color: #fff;
font-size: 16px;
font-weight: 600;
}
.preview-mode {
color: #999;
font-size: 13px;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
.preview-actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.preview-btn:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
.preview-btn.close-btn {
background: rgba(220, 53, 69, 0.8);
border-color: rgba(220, 53, 69, 0.9);
padding: 8px;
}
.preview-btn.close-btn:hover {
background: rgba(220, 53, 69, 1);
}
/* 图片容器 */
.preview-image-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
overflow: auto;
background: #0a0a0a;
cursor: grab;
}
.preview-image-container:active {
cursor: grabbing;
}
.preview-image-container.original-size {
align-items: flex-start;
justify-content: flex-start;
}
/* 预览图片 */
.preview-image {
transition: all 0.3s ease;
user-select: none;
}
.preview-image.fit-screen {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.preview-image.original {
width: auto;
height: auto;
max-width: none;
max-height: none;
}
/* 底部信息 */
.preview-footer {
padding: 10px 20px;
background: #1f1f1f;
border-top: 1px solid #333;
display: flex;
align-items: center;
justify-content: center;
}
.image-info {
color: #888;
font-size: 12px;
}
/* 滚动条样式 */
.preview-image-container::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.preview-image-container::-webkit-scrollbar-track {
background: #1a1a1a;
}
.preview-image-container::-webkit-scrollbar-thumb {
background: #444;
border-radius: 5px;
}
.preview-image-container::-webkit-scrollbar-thumb:hover {
background: #555;
}
</style>

View File

@ -22,10 +22,6 @@ import {
Bell,
Zap,
BarChart3,
Users,
Link2,
Paperclip,
MessageSquare
} from 'lucide-vue-next'
interface MenuItem {
@ -90,30 +86,25 @@ const toolbarGroups: ToolbarGroup[] = [
{ icon: CheckSquare, label: 'Status' },
]
},
{
title: 'Tools',
buttons: [
{ icon: Zap, label: 'Quick' },
{ icon: BarChart3, label: 'Reports' },
{ icon: Users, label: 'Team' },
{ icon: Settings, label: 'Settings' },
]
},
{
title: 'Actions',
buttons: [
{ icon: Link2, label: 'Links' },
{ icon: Paperclip, label: 'Attach' },
{ icon: MessageSquare, label: 'Notes' },
{ icon: Bell, label: 'Alerts' },
]
}
]
{
title: 'Tools',
buttons: [
{ icon: Zap, label: 'Quick' },
{ icon: BarChart3, label: 'Reports' },
{ icon: Settings, label: 'Settings' },
]
},
]
const activeMenu = ref('Welcome')
const emit = defineEmits<{
(e: 'menu-click', label: string): void
}>()
const setActiveMenu = (label: string) => {
activeMenu.value = label
emit('menu-click', label)
}
</script>

View File

@ -670,7 +670,7 @@ const addNodeDialog = ref({
})
//
const nodeTypes = ['Product Scenario', 'Attribute', 'System', 'Requirement', 'Component']
const nodeTypes = ['product scenario', 'product definition', 'product target', 'attribute', 'function', 'system', 'component']
const initExpanded = (nodes: TreeNode[]) => {
nodes.forEach(node => {

View File

@ -0,0 +1,735 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { Play, Pause, Volume2, VolumeX, Maximize, Trash2, GripVertical, Maximize2 } from 'lucide-vue-next'
const props = defineProps(nodeViewProps)
const videoRef = ref<HTMLVideoElement | null>(null)
const previewVideoRef = ref<HTMLVideoElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const containerRef = ref<HTMLDivElement | null>(null)
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(1)
const isMuted = ref(false)
const isFullscreen = ref(false)
const showControls = ref(true)
const controlsTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const isSelected = computed(() => props.selected)
//
const isResizing = ref(false)
const videoWidth = ref(100) //
// -
const showHoverPreview = ref(false)
const hoverTime = ref(0)
const hoverPosition = ref(0)
const hoverImage = ref('')
//
const isDragging = ref(false)
const dragPosition = ref(0)
//
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
// -
const progressPercent = computed(() => {
if (duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
onMounted(() => {
if (videoRef.value) {
videoRef.value.addEventListener('loadedmetadata', () => {
duration.value = videoRef.value?.duration || 0
})
videoRef.value.addEventListener('timeupdate', () => {
if (!isDragging.value) {
currentTime.value = videoRef.value?.currentTime || 0
}
})
videoRef.value.addEventListener('play', () => { isPlaying.value = true })
videoRef.value.addEventListener('pause', () => { isPlaying.value = false })
videoRef.value.addEventListener('ended', () => { isPlaying.value = false })
}
if (props.node.attrs.width) {
videoWidth.value = parseInt(props.node.attrs.width)
}
})
onUnmounted(() => {
if (controlsTimeout.value) {
clearTimeout(controlsTimeout.value)
}
//
if (previewVideoRef.value && previewVideoRef.value.parentNode) {
previewVideoRef.value.parentNode.removeChild(previewVideoRef.value)
}
})
// /
const togglePlay = () => {
if (videoRef.value) {
if (isPlaying.value) {
videoRef.value.pause()
} else {
videoRef.value.play()
}
}
}
// /
const startDrag = (e: MouseEvent) => {
isDragging.value = true
updateDragPosition(e)
//
if (videoRef.value && duration.value) {
videoRef.value.currentTime = dragPosition.value * duration.value
}
document.addEventListener('mousemove', handleDragMove)
document.addEventListener('mouseup', stopDrag)
}
//
const handleDragMove = (e: MouseEvent) => {
if (!isDragging.value) return
updateDragPosition(e)
}
//
const stopDrag = () => {
isDragging.value = false
document.removeEventListener('mousemove', handleDragMove)
document.removeEventListener('mouseup', stopDrag)
}
//
const updateDragPosition = (e: MouseEvent) => {
const progressBar = document.querySelector('.progress-bar-container') as HTMLElement
if (!progressBar) return
const rect = progressBar.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
dragPosition.value = percent
}
//
const initPreviewVideo = () => {
if (!previewVideoRef.value && videoRef.value) {
previewVideoRef.value = document.createElement('video')
previewVideoRef.value.src = videoRef.value.src
previewVideoRef.value.preload = 'metadata'
previewVideoRef.value.style.display = 'none'
document.body.appendChild(previewVideoRef.value)
}
}
// -
const handleProgressHover = async (e: MouseEvent) => {
if (isDragging.value) return
if (!videoRef.value || !duration.value || !canvasRef.value) return
//
initPreviewVideo()
if (!previewVideoRef.value) return
const progressBar = e.currentTarget as HTMLElement
const rect = progressBar.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
hoverTime.value = percent * duration.value
hoverPosition.value = percent * 100
// 使
const previewVideo = previewVideoRef.value
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (ctx && previewVideo) {
//
previewVideo.currentTime = hoverTime.value
//
await new Promise(resolve => {
const handleSeeked = () => {
previewVideo.removeEventListener('seeked', handleSeeked)
resolve(true)
}
previewVideo.addEventListener('seeked', handleSeeked)
})
// canvas
canvas.width = 160
canvas.height = 90
ctx.drawImage(previewVideo, 0, 0, canvas.width, canvas.height)
//
hoverImage.value = canvas.toDataURL('image/jpeg', 0.5)
}
showHoverPreview.value = true
}
const hidePreview = () => {
showHoverPreview.value = false
}
//
const toggleMute = () => {
if (videoRef.value) {
isMuted.value = !isMuted.value
videoRef.value.muted = isMuted.value
}
}
const setVolume = (e: Event) => {
const target = e.target as HTMLInputElement
volume.value = parseFloat(target.value)
if (videoRef.value) {
videoRef.value.volume = volume.value
isMuted.value = volume.value === 0
}
}
//
const toggleFullscreen = () => {
if (!containerRef.value) return
if (!document.fullscreenElement) {
containerRef.value.requestFullscreen()
isFullscreen.value = true
} else {
document.exitFullscreen()
isFullscreen.value = false
}
}
//
const showControlsBar = () => {
showControls.value = true
if (controlsTimeout.value) {
clearTimeout(controlsTimeout.value)
}
if (isPlaying.value) {
controlsTimeout.value = setTimeout(() => {
showControls.value = false
}, 3000)
}
}
//
const deleteVideo = () => {
props.deleteNode()
}
//
const startResize = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
isResizing.value = true
const startX = e.clientX
const startWidth = videoWidth.value
const container = containerRef.value
if (!container) return
const containerWidth = container.parentElement?.offsetWidth || 800
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing.value) return
const deltaX = e.clientX - startX
const newWidthPercent = Math.max(20, Math.min(100, startWidth + (deltaX / containerWidth * 100)))
videoWidth.value = newWidthPercent
props.updateAttributes({
width: `${videoWidth.value}%`,
height: 'auto'
})
}
const handleMouseUp = () => {
isResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
</script>
<template>
<NodeViewWrapper
class="video-player-wrapper"
:class="{ 'is-selected': isSelected }"
draggable="true"
:style="{ width: videoWidth + '%' }"
>
<div
ref="containerRef"
class="video-container"
@mousemove="showControlsBar"
@mouseleave="showControls = false"
>
<video
ref="videoRef"
:src="node.attrs.src"
class="video-element"
preload="metadata"
/>
<!-- 隐藏的 canvas 用于帧提取 -->
<canvas ref="canvasRef" style="display: none;"></canvas>
<!-- 删除按钮 -->
<button
v-if="isSelected"
class="delete-video-btn"
@click.stop="deleteVideo"
title="删除视频"
>
<Trash2 :size="14" />
</button>
<!-- 拖拽指示器 -->
<div v-if="isSelected" class="drag-indicator">
<GripVertical :size="14" />
</div>
<!-- 缩放控制 -->
<div
v-if="isSelected"
class="resize-handle"
@mousedown="startResize"
title="拖拽调整大小"
>
<Maximize2 :size="12" />
</div>
<!-- 播放按钮覆盖层 - 只有点击播放按钮才播放 -->
<div v-if="!isPlaying && showControls" class="play-overlay">
<div class="play-button" @click.stop="togglePlay">
<Play :size="32" fill="white" />
</div>
</div>
<!-- 控制栏 -->
<div v-show="showControls" class="video-controls">
<!-- 进度条 - 鼠标悬停预览帧(不改变进度条位置)点击/拖拽跳转 -->
<div
class="progress-bar-container"
@mousedown="startDrag"
@mousemove="handleProgressHover"
@mouseleave="hidePreview"
>
<div class="progress-bar">
<!-- 进度条始终显示实际播放进度不受悬停影响 -->
<div
class="progress-fill"
:style="{ width: progressPercent + '%' }"
></div>
<div
class="progress-handle"
:style="{ left: progressPercent + '%' }"
></div>
</div>
<!-- 悬停预览框 - 显示鼠标位置对应的帧 -->
<div
v-if="showHoverPreview && !isDragging"
class="frame-preview hover-preview"
:style="{ left: `calc(${hoverPosition}% - 80px)` }"
>
<img v-if="hoverImage" :src="hoverImage" class="preview-image" />
<div class="preview-time">{{ formatTime(hoverTime) }}</div>
</div>
<!-- 拖拽预览框 - 显示拖拽位置对应的帧 -->
<div
v-if="isDragging"
class="frame-preview drag-preview"
:style="{ left: `calc(${dragPosition * 100}% - 80px)` }"
>
<div class="preview-time">{{ formatTime(dragPosition * duration) }}</div>
</div>
</div>
<!-- 控制按钮 -->
<div class="controls-row">
<div class="controls-left">
<button class="control-btn" @click="togglePlay">
<Play v-if="!isPlaying" :size="20" />
<Pause v-else :size="20" />
</button>
<span class="time-display">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
</div>
<div class="controls-right">
<!-- 音量控制 -->
<div class="volume-control">
<button class="control-btn" @click="toggleMute">
<VolumeX v-if="isMuted || volume === 0" :size="18" />
<Volume2 v-else :size="18" />
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
:value="volume"
@input="setVolume"
class="volume-slider"
/>
</div>
<button class="control-btn" @click="toggleFullscreen">
<Maximize :size="18" />
</button>
</div>
</div>
</div>
</div>
</NodeViewWrapper>
</template>
<style scoped>
.video-player-wrapper {
display: block;
position: relative;
margin: 16px 0;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
background: #000;
transition: all 0.2s;
min-width: 300px;
}
.video-player-wrapper:hover {
outline: 1px dashed #0078d4;
}
.video-player-wrapper.is-selected {
outline: 2px solid #0078d4;
outline-offset: 2px;
}
.video-container {
position: relative;
width: 100%;
aspect-ratio: 16/9;
background: #000;
}
.video-element {
width: 100%;
height: 100%;
object-fit: contain;
}
/* 删除按钮 */
.delete-video-btn {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
background: linear-gradient(to bottom, #dc3545, #c82333);
border: 2px solid #fff;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
z-index: 20;
transition: transform 0.15s;
padding: 0;
}
.delete-video-btn:hover {
transform: scale(1.1);
}
/* 拖拽指示器 */
.drag-indicator {
position: absolute;
top: 8px;
left: 8px;
width: 28px;
height: 28px;
background: rgba(0, 120, 212, 0.9);
border: 2px solid #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
opacity: 0;
transition: opacity 0.2s;
z-index: 15;
}
.video-player-wrapper:hover .drag-indicator {
opacity: 1;
}
.video-player-wrapper.is-selected .drag-indicator {
opacity: 1;
}
/* 缩放手柄 */
.resize-handle {
position: absolute;
bottom: 50px;
right: -6px;
width: 20px;
height: 20px;
background: linear-gradient(to bottom, #0078d4, #005a9e);
border: 2px solid #fff;
border-radius: 50%;
cursor: nwse-resize;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
z-index: 25;
transition: transform 0.15s;
}
.resize-handle:hover {
transform: scale(1.1);
}
/* 播放覆盖层 */
.play-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
cursor: pointer;
z-index: 10;
}
.play-button {
width: 64px;
height: 64px;
background: rgba(0, 120, 212, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: transform 0.2s, background 0.2s;
}
.play-button:hover {
transform: scale(1.1);
background: rgba(0, 120, 212, 1);
}
/* 控制栏 */
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent);
padding: 10px 12px;
z-index: 20;
transition: opacity 0.3s;
}
/* 进度条 */
.progress-bar-container {
width: 100%;
height: 20px;
cursor: pointer;
margin-bottom: 8px;
display: flex;
align-items: center;
position: relative;
}
.progress-bar {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
position: relative;
transition: height 0.2s;
}
.progress-bar-container:hover .progress-bar {
height: 6px;
}
.progress-fill {
height: 100%;
background: #0078d4;
border-radius: 2px;
transition: width 0.1s linear;
}
.progress-handle {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s;
}
.progress-bar-container:hover .progress-handle {
opacity: 1;
}
/* 帧预览 */
.frame-preview {
position: absolute;
bottom: 24px;
width: 160px;
height: 100px;
background: #000;
border: 2px solid #fff;
border-radius: 4px;
overflow: hidden;
z-index: 30;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.frame-preview.hover-preview {
cursor: default;
}
.frame-preview.drag-preview {
height: 30px;
background: rgba(0, 0, 0, 0.9);
}
.preview-image {
width: 100%;
height: 70px;
object-fit: cover;
}
.frame-preview.drag-preview .preview-image {
display: none;
}
.preview-time {
height: 30px;
background: rgba(0, 0, 0, 0.9);
color: #fff;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Courier New', monospace;
}
.frame-preview.drag-preview .preview-time {
height: 100%;
}
/* 控制按钮行 */
.controls-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.controls-left,
.controls-right {
display: flex;
align-items: center;
gap: 12px;
}
.control-btn {
background: none;
border: none;
color: #fff;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.9;
transition: opacity 0.2s, transform 0.1s;
}
.control-btn:hover {
opacity: 1;
transform: scale(1.1);
}
.time-display {
color: #fff;
font-size: 13px;
font-family: 'Courier New', monospace;
opacity: 0.9;
}
/* 音量控制 */
.volume-control {
display: flex;
align-items: center;
gap: 8px;
}
.volume-slider {
width: 80px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
outline: none;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
cursor: pointer;
border: none;
}
</style>

5
src/content-main.ts Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './index.css'
import ContentPage from './content.vue'
createApp(ContentPage).mount('#app')

878
src/content.vue Normal file
View File

@ -0,0 +1,878 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import { Table } from '@tiptap/extension-table'
import { TableRow } from '@tiptap/extension-table-row'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import TextAlign from '@tiptap/extension-text-align'
import { Node } from '@tiptap/core'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import ImageResizeComponent from './components/ImageResizeComponent.vue'
import VideoPlayerComponent from './components/VideoPlayerComponent.vue'
import {
Bold,
Italic,
Underline as UnderlineIcon,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Image as ImageIcon,
Table as TableIcon,
Video,
Undo,
Redo,
AlignLeft,
AlignCenter,
AlignRight,
Maximize2,
Move,
Trash2
} from 'lucide-vue-next'
// ========== ==========
const CustomVideo = Node.create({
name: 'customVideo',
group: 'block',
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
src: { default: null },
width: { default: '100%' },
height: { default: 'auto' },
controls: { default: true },
}
},
parseHTML() {
return [
{
tag: 'div[data-custom-video]',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['div', { 'data-custom-video': '', ...HTMLAttributes }]
},
addNodeView() {
return VueNodeViewRenderer(VideoPlayerComponent)
},
})
// ========== ==========
const ResizableImage = Image.extend({
name: 'resizableImage',
addAttributes() {
return {
...this.parent?.(),
width: { default: null },
height: { default: null },
style: { default: null },
}
},
addNodeView() {
return VueNodeViewRenderer(ImageResizeComponent)
},
})
//
type DisplayMode = 'json' | 'html' | null
const displayMode = ref<DisplayMode>(null)
//
const showTableDialog = ref(false)
const tableRows = ref(3)
const tableCols = ref(3)
const hasHeaderRow = ref(true)
//
const selectedNode = ref<{ type: string; node: any } | null>(null)
// TipTap
const editor = ref<Editor | null>(null)
onMounted(() => {
editor.value = new Editor({
content: '<p>在这里开始编辑内容...</p>',
extensions: [
StarterKit.configure({
// StarterKit underline
}),
ResizableImage.configure({
allowBase64: true,
}),
CustomVideo,
Table.configure({
resizable: true,
}),
TableRow,
TableCell,
TableHeader,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
],
onSelectionUpdate: ({ editor }) => {
//
const { from, to } = editor.state.selection
if (from === to) {
const node = editor.state.doc.nodeAt(from)
if (node) {
if (node.type.name === 'resizableImage') {
selectedNode.value = { type: 'image', node }
} else if (node.type.name === 'customVideo') {
selectedNode.value = { type: 'video', node }
} else {
selectedNode.value = null
}
} else {
selectedNode.value = null
}
}
},
})
})
onBeforeUnmount(() => {
editor.value?.destroy()
})
//
const jsonContent = computed(() => {
return editor.value ? JSON.stringify(editor.value.getJSON(), null, 2) : ''
})
const htmlContent = computed(() => {
return editor.value?.getHTML() || ''
})
//
const showJson = () => { displayMode.value = 'json' }
const showHtml = () => { displayMode.value = 'html' }
const currentDisplayContent = computed(() => {
if (displayMode.value === 'json') return jsonContent.value
if (displayMode.value === 'html') return htmlContent.value
return ''
})
// ========== ==========
const toggleBold = () => editor.value?.chain().focus().toggleBold().run()
const toggleItalic = () => editor.value?.chain().focus().toggleItalic().run()
const toggleUnderline = () => editor.value?.chain().focus().toggleUnderline().run()
const toggleStrike = () => editor.value?.chain().focus().toggleStrike().run()
const toggleHeading1 = () => editor.value?.chain().focus().toggleHeading({ level: 1 }).run()
const toggleHeading2 = () => editor.value?.chain().focus().toggleHeading({ level: 2 }).run()
const toggleHeading3 = () => editor.value?.chain().focus().toggleHeading({ level: 3 }).run()
const toggleBulletList = () => editor.value?.chain().focus().toggleBulletList().run()
const toggleOrderedList = () => editor.value?.chain().focus().toggleOrderedList().run()
const toggleBlockquote = () => editor.value?.chain().focus().toggleBlockquote().run()
const toggleCode = () => editor.value?.chain().focus().toggleCode().run()
const toggleCodeBlock = () => editor.value?.chain().focus().toggleCodeBlock().run()
const setTextAlignLeft = () => editor.value?.chain().focus().setTextAlign('left').run()
const setTextAlignCenter = () => editor.value?.chain().focus().setTextAlign('center').run()
const setTextAlignRight = () => editor.value?.chain().focus().setTextAlign('right').run()
const undo = () => editor.value?.chain().focus().undo().run()
const redo = () => editor.value?.chain().focus().redo().run()
const isActive = (name: string, options?: any) => {
return editor.value?.isActive(name, options) || false
}
// ========== ==========
const insertImage = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file && editor.value) {
const reader = new FileReader()
reader.onload = (event) => {
const src = event.target?.result as string
editor.value?.chain().focus().insertContent({
type: 'resizableImage',
attrs: { src, width: '400px', height: 'auto' }
}).run()
}
reader.readAsDataURL(file)
}
}
input.click()
}
// ========== ==========
const insertVideo = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'video/*'
input.onchange = (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file && editor.value) {
const url = URL.createObjectURL(file)
editor.value.chain().focus().insertContent({
type: 'customVideo',
attrs: { src: url, width: '100%', height: 'auto' }
}).run()
}
}
input.click()
}
// ========== ==========
const deleteSelectedNode = () => {
if (editor.value && selectedNode.value) {
const { from } = editor.value.state.selection
editor.value.chain().focus().deleteRange({ from, to: from + 1 }).run()
selectedNode.value = null
}
}
// ========== ==========
const openTableDialog = () => { showTableDialog.value = true }
const cancelTableDialog = () => { showTableDialog.value = false }
const insertTable = () => {
editor.value?.chain().focus().insertTable({
rows: tableRows.value,
cols: tableCols.value,
withHeaderRow: hasHeaderRow.value
}).run()
showTableDialog.value = false
}
</script>
<template>
<div class="content-page">
<h1 class="page-title">Content Editor</h1>
<!-- 按钮区域 -->
<div class="button-group">
<button
:class="['action-btn', { active: displayMode === 'json' }]"
@click="showJson"
>
显示 Raw JSON
</button>
<button
:class="['action-btn', { active: displayMode === 'html' }]"
@click="showHtml"
>
显示 Rendered HTML
</button>
</div>
<!-- 选中节点操作栏 -->
<div v-if="selectedNode" class="selected-node-toolbar">
<span class="selected-label">
已选中: {{ selectedNode.type === 'image' ? '图片' : '视频' }}
</span>
<button class="toolbar-btn delete-btn" @click="deleteSelectedNode" title="删除">
<Trash2 :size="16" />
</button>
</div>
<!-- TipTap 编辑器 -->
<div class="editor-section">
<h3 class="section-title">编辑器</h3>
<div class="editor-container">
<!-- 工具栏 -->
<div class="editor-toolbar">
<!-- 撤销/重做 -->
<div class="toolbar-group">
<button class="toolbar-btn" @click="undo" title="撤销">
<Undo :size="16" />
</button>
<button class="toolbar-btn" @click="redo" title="重做">
<Redo :size="16" />
</button>
</div>
<div class="toolbar-divider"></div>
<!-- 文本格式化 -->
<div class="toolbar-group">
<button :class="['toolbar-btn', { active: isActive('bold') }]" @click="toggleBold" title="加粗">
<Bold :size="16" />
</button>
<button :class="['toolbar-btn', { active: isActive('italic') }]" @click="toggleItalic" title="斜体">
<Italic :size="16" />
</button>
<button :class="['toolbar-btn', { active: isActive('underline') }]" @click="toggleUnderline" title="下划线">
<UnderlineIcon :size="16" />
</button>
<button :class="['toolbar-btn', { active: isActive('strike') }]" @click="toggleStrike" title="删除线">
<Strikethrough :size="16" />
</button>
</div>
<div class="toolbar-divider"></div>
<!-- 标题 -->
<div class="toolbar-group">
<button :class="['toolbar-btn', { active: isActive('heading', { level: 1 }) }]" @click="toggleHeading1" title="标题 1">
<Heading1 :size="16" />
</button>
<button :class="['toolbar-btn', { active: isActive('heading', { level: 2 }) }]" @click="toggleHeading2" title="标题 2">
<Heading2 :size="16" />
</button>
<button :class="['toolbar-btn', { active: isActive('heading', { level: 3 }) }]" @click="toggleHeading3" title="标题 3">
<Heading3 :size="16" />
</button>
</div>
<div class="toolbar-divider"></div>
<!-- 对齐 -->
<div class="toolbar-group">
<button :class="['toolbar-btn', { active: isActive({ textAlign: 'left' }) }]" @click="setTextAlignLeft" title="左对齐">
<AlignLeft :size="16" />
</button>
<button :class="['toolbar-btn', { active: isActive({ textAlign: 'center' }) }]" @click="setTextAlignCenter" title="居中">
<AlignCenter :size="16" />
</button>
<button :class="['toolbar-btn', { active: isActive({ textAlign: 'right' }) }]" @click="setTextAlignRight" title="右对齐">
<AlignRight :size="16" />
</button>
</div>
<div class="toolbar-divider"></div>
<!-- 列表 -->
<div class="toolbar-group">
<button :class="['toolbar-btn', { active: isActive('bulletList') }]" @click="toggleBulletList" title="无序列表">
<List :size="16" />
</button>
<button :class="['toolbar-btn', { active: isActive('orderedList') }]" @click="toggleOrderedList" title="有序列表">
<ListOrdered :size="16" />
</button>
</div>
<div class="toolbar-divider"></div>
<!-- 引用和代码 -->
<div class="toolbar-group">
<button :class="['toolbar-btn', { active: isActive('blockquote') }]" @click="toggleBlockquote" title="引用">
<Quote :size="16" />
</button>
<button :class="['toolbar-btn', { active: isActive('code') }]" @click="toggleCode" title="代码">
<Code :size="16" />
</button>
</div>
<div class="toolbar-divider"></div>
<!-- 插入媒体 -->
<div class="toolbar-group">
<button class="toolbar-btn" @click="insertImage" title="插入图片">
<ImageIcon :size="16" />
</button>
<button class="toolbar-btn" @click="openTableDialog" title="插入表格">
<TableIcon :size="16" />
</button>
<button class="toolbar-btn" @click="insertVideo" title="插入视频">
<Video :size="16" />
</button>
</div>
</div>
<!-- 编辑器内容 -->
<editor-content v-if="editor" :editor="editor" class="tiptap-editor" />
</div>
</div>
<!-- 内容显示区域 -->
<div v-if="displayMode" class="display-section">
<h3 class="section-title">
{{ displayMode === 'json' ? 'Raw JSON' : 'Rendered HTML' }}
</h3>
<pre v-if="displayMode === 'json'" class="content-display json-display">{{ currentDisplayContent }}</pre>
<div v-else class="content-display html-display">
<div class="html-preview" v-html="currentDisplayContent"></div>
<div class="html-source">
<h4>HTML 源码</h4>
<pre>{{ currentDisplayContent }}</pre>
</div>
</div>
</div>
<!-- 表格对话框 -->
<div v-if="showTableDialog" class="dialog-overlay" @click.self="cancelTableDialog">
<div class="dialog">
<div class="dialog-header">
<span class="dialog-title">插入表格</span>
<button class="dialog-close" @click="cancelTableDialog">&times;</button>
</div>
<div class="dialog-body">
<div class="form-row">
<div class="form-group">
<label class="form-label">行数</label>
<input type="number" class="form-input" v-model.number="tableRows" min="1" max="50" />
</div>
<div class="form-group">
<label class="form-label">列数</label>
<input type="number" class="form-input" v-model.number="tableCols" min="1" max="20" />
</div>
</div>
<div class="form-group">
<label class="form-label checkbox-label">
<input type="checkbox" v-model="hasHeaderRow" />
<span>包含表头行</span>
</label>
</div>
</div>
<div class="dialog-footer">
<button class="btn btn-secondary" @click="cancelTableDialog">取消</button>
<button class="btn btn-primary" @click="insertTable">插入</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.content-page {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
}
.button-group {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.action-btn {
padding: 10px 20px;
border: 2px solid #0078d4;
background: #fff;
color: #0078d4;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.action-btn:hover {
background: #f0f8ff;
}
.action-btn.active {
background: #0078d4;
color: #fff;
}
/* 选中节点工具栏 */
.selected-node-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(to bottom, #e8f4fc, #d0e8f8);
border: 1px solid #0078d4;
border-radius: 4px;
margin-bottom: 16px;
}
.selected-label {
font-size: 14px;
color: #0078d4;
font-weight: 500;
}
.delete-btn {
background: linear-gradient(to bottom, #dc3545, #c82333) !important;
border-color: #dc3545 !important;
color: #fff !important;
}
.delete-btn:hover {
background: linear-gradient(to bottom, #c82333, #bd2130) !important;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #555;
margin-bottom: 12px;
}
.editor-section {
margin-bottom: 24px;
}
.editor-container {
border: 1px solid #d0d0d0;
border-radius: 6px;
overflow: hidden;
}
/* 工具栏样式 */
.editor-toolbar {
display: flex;
align-items: center;
padding: 8px 12px;
background: linear-gradient(to bottom, #f8f8f8, #e8e8e8);
border-bottom: 1px solid #d0d0d0;
gap: 4px;
flex-wrap: wrap;
}
.toolbar-group {
display: flex;
gap: 2px;
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: linear-gradient(to bottom, #ffffff, #f0f0f0);
border: 1px solid #c0c0c0;
border-radius: 3px;
cursor: pointer;
color: #333;
transition: all 0.15s;
}
.toolbar-btn:hover {
background: linear-gradient(to bottom, #f0f8ff, #d0e8f8);
border-color: #0078d4;
}
.toolbar-btn.active {
background: linear-gradient(to bottom, #0078d4, #005a9e);
border-color: #0078d4;
color: #fff;
}
.toolbar-divider {
width: 1px;
height: 24px;
background-color: #d0d0d0;
margin: 0 4px;
}
/* TipTap 编辑器样式 */
:deep(.tiptap) {
min-height: 400px;
padding: 16px;
outline: none;
}
:deep(.tiptap p) {
margin: 0 0 12px 0;
line-height: 1.6;
}
:deep(.tiptap p:last-child) {
margin-bottom: 0;
}
:deep(.tiptap h1) {
font-size: 28px;
font-weight: 600;
margin: 0 0 16px 0;
}
:deep(.tiptap h2) {
font-size: 22px;
font-weight: 600;
margin: 0 0 14px 0;
}
:deep(.tiptap h3) {
font-size: 18px;
font-weight: 600;
margin: 0 0 12px 0;
}
:deep(.tiptap ul),
:deep(.tiptap ol) {
margin: 0 0 12px 0;
padding-left: 24px;
}
:deep(.tiptap li) {
margin-bottom: 4px;
}
:deep(.tiptap blockquote) {
border-left: 4px solid #ddd;
padding-left: 16px;
margin: 0 0 12px 0;
color: #666;
}
:deep(.tiptap code) {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
:deep(.tiptap pre) {
background: #f4f4f4;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
}
:deep(.tiptap pre code) {
background: none;
padding: 0;
}
:deep(.tiptap hr) {
border: none;
border-top: 2px solid #ddd;
margin: 16px 0;
}
:deep(.tiptap img) {
max-width: 100%;
height: auto;
border-radius: 4px;
}
:deep(.tiptap table) {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
}
:deep(.tiptap table th),
:deep(.tiptap table td) {
border: 1px solid #d0d0d0;
padding: 8px 12px;
text-align: left;
}
:deep(.tiptap table th) {
background: linear-gradient(to bottom, #f5f5f5, #e8e8e8);
font-weight: 600;
}
/* ProseMirror 选中样式 */
:deep(.ProseMirror-selectednode) {
outline: 2px solid #0078d4;
outline-offset: 2px;
}
.display-section {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 20px;
background: #fafafa;
}
.content-display {
background: #fff;
border: 1px solid #d0d0d0;
border-radius: 4px;
padding: 16px;
overflow-x: auto;
}
.json-display {
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
color: #333;
}
.html-display {
display: flex;
flex-direction: column;
gap: 20px;
}
.html-preview {
padding: 16px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.html-source {
border-top: 1px solid #e0e0e0;
padding-top: 16px;
}
.html-source h4 {
font-size: 14px;
font-weight: 600;
color: #666;
margin: 0 0 12px 0;
}
.html-source pre {
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
color: #333;
margin: 0;
}
/* 对话框样式 */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background-color: #ffffff;
border-radius: 4px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
min-width: 400px;
max-width: 90%;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #a0a0a0;
}
.dialog-title {
font-size: 14px;
font-weight: 600;
}
.dialog-close {
background: transparent;
border: none;
cursor: pointer;
color: #666;
font-size: 20px;
}
.dialog-body {
padding: 16px;
}
.form-group {
margin-bottom: 12px;
}
.form-label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
cursor: pointer;
}
.form-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #c0c0c0;
border-radius: 3px;
font-size: 13px;
}
.form-input:focus {
outline: none;
border-color: #0078d4;
}
.form-row {
display: flex;
gap: 12px;
}
.form-row .form-group {
flex: 1;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #a0a0a0;
background-color: #e8e8e8;
}
.btn {
padding: 6px 16px;
border: 1px solid #a0a0a0;
border-radius: 3px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-primary {
background: linear-gradient(to bottom, #0078d4, #005a9e);
color: #ffffff;
border-color: #0078d4;
}
.btn-primary:hover {
background: linear-gradient(to bottom, #106ebe, #004578);
}
.btn-secondary {
background: linear-gradient(to bottom, #ffffff, #e8e8e8);
color: #333;
}
.btn-secondary:hover {
background: linear-gradient(to bottom, #f0f8ff, #d0e8f8);
border-color: #0078d4;
}
</style>

View File

@ -2,4 +2,4 @@ import { createApp } from 'vue'
import './index.css'
import App from './App.vue'
createApp(App).mount('#app')
createApp(App).mount('#app')

View File

@ -1,6 +1,5 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
@ -10,9 +9,18 @@ export default defineConfig({
port: 3000,
open: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
build: {
rollupOptions: {
input: {
main: './index.html',
content: './content.html',
},
},
},
})
optimizeDeps: {
include: ['@tiptap/vue-3', '@tiptap/starter-kit', '@tiptap/extension-image',
'@tiptap/extension-table', '@tiptap/extension-table-row',
'@tiptap/extension-table-cell', '@tiptap/extension-table-header',
'@tiptap/extension-text-align', '@tiptap/extension-underline']
}
})