251 lines
6.7 KiB
Vue
251 lines
6.7 KiB
Vue
<template>
|
|
<div class="doc-block-crepe" :class="{ collapsed: isCollapsed }">
|
|
<div class="doc-header">
|
|
<div class="doc-icon">
|
|
<svg v-if="docType === 'pdf'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<polyline points="14 2 14 8 20 8"/>
|
|
<path d="M9 15v-2h6v2"/>
|
|
<path d="M12 13v4"/>
|
|
</svg>
|
|
<svg v-else-if="docType === 'doc'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<polyline points="14 2 14 8 20 8"/>
|
|
<path d="M16 13H8"/>
|
|
<path d="M16 17H8"/>
|
|
<path d="M10 9H8"/>
|
|
</svg>
|
|
<svg v-else-if="docType === 'ppt'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
|
<path d="M8 21h8"/>
|
|
<path d="M12 17v4"/>
|
|
</svg>
|
|
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<polyline points="14 2 14 8 20 8"/>
|
|
<path d="M16 13H8"/>
|
|
<path d="M16 17H8"/>
|
|
</svg>
|
|
</div>
|
|
<div class="doc-name">{{ docName }}</div>
|
|
<div class="doc-actions">
|
|
<button @click="downloadDoc" class="action-btn" title="下载文档">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|
<polyline points="7 10 12 15 17 10"/>
|
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
|
</svg>
|
|
</button>
|
|
<button @click="toggleCollapse" class="action-btn collapse-btn" :title="isCollapsed ? '展开' : '折叠'">
|
|
<svg v-if="isCollapsed" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="9 18 15 12 9 6"/>
|
|
</svg>
|
|
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="6 9 12 15 18 9"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="doc-editor" v-show="!isCollapsed">
|
|
<div ref="editorRoot" class="inner-crepe"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
|
import { Crepe } from '@milkdown/crepe'
|
|
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
|
|
import { copilotPlugin, copilotConfigCtx, setCopilotEnabled } from '../plugins/copilotPlugin'
|
|
import { fetchSuggestion } from '../utils/api.js'
|
|
|
|
const props = defineProps({
|
|
docType: { type: String, default: 'text' },
|
|
docName: { type: String, default: 'document.txt' },
|
|
uploadTime: { type: String, default: '' },
|
|
initialContent: { type: String, default: '' }
|
|
})
|
|
|
|
const emit = defineEmits(['update:content', 'delete'])
|
|
|
|
const editorRoot = ref(null)
|
|
const isCollapsed = ref(false)
|
|
let crepe = null
|
|
let internalChangeTimer = null
|
|
|
|
const toggleCollapse = () => {
|
|
isCollapsed.value = !isCollapsed.value
|
|
}
|
|
|
|
const downloadDoc = () => {
|
|
if (!crepe) return
|
|
crepe.getMarkdown().then(markdown => {
|
|
const blob = new Blob([markdown], { type: 'text/plain;charset=utf-8' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = props.docName
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
a.remove()
|
|
URL.revokeObjectURL(url)
|
|
})
|
|
}
|
|
|
|
const syncContent = () => {
|
|
if (!crepe) return
|
|
if (internalChangeTimer) clearTimeout(internalChangeTimer)
|
|
internalChangeTimer = setTimeout(async () => {
|
|
const markdown = await crepe.getMarkdown()
|
|
emit('update:content', markdown)
|
|
}, 120)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (!editorRoot.value) return
|
|
crepe = new Crepe({
|
|
root: editorRoot.value,
|
|
defaultValue: props.initialContent || '',
|
|
features: {
|
|
[Crepe.Feature.Latex]: true,
|
|
[Crepe.Feature.ImageBlock]: true,
|
|
[Crepe.Feature.Table]: true,
|
|
[Crepe.Feature.ListCheck]: true,
|
|
},
|
|
config: { showLineNumber: false }
|
|
})
|
|
|
|
crepe.editor.config(ctx => {
|
|
ctx.set(copilotConfigCtx.key, {
|
|
fetchSuggestion,
|
|
debounceMs: 1000
|
|
})
|
|
})
|
|
|
|
crepe.editor.use(copilotPlugin)
|
|
await crepe.create()
|
|
|
|
crepe.on(listener => {
|
|
listener.updated(() => {
|
|
syncContent()
|
|
})
|
|
})
|
|
|
|
crepe.editor.action(ctx => {
|
|
const view = ctx.get(editorViewCtx)
|
|
setCopilotEnabled(view, true)
|
|
})
|
|
})
|
|
|
|
watch(() => props.initialContent, (newVal) => {
|
|
if (crepe && newVal !== undefined) {
|
|
crepe.editor.action(ctx => {
|
|
const view = ctx.get(editorViewCtx)
|
|
const currentPos = view.state.selection.from
|
|
view.dispatch(view.state.tr.insertText(newVal))
|
|
})
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (internalChangeTimer) clearTimeout(internalChangeTimer)
|
|
if (crepe) {
|
|
crepe.destroy()
|
|
crepe = null
|
|
}
|
|
})
|
|
|
|
defineExpose({
|
|
getContent: () => crepe ? crepe.getMarkdown() : Promise.resolve(''),
|
|
getEditor: () => crepe
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.doc-block-crepe {
|
|
margin: 12px 0;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
background: var(--crepe-color-surface-low);
|
|
border: 1px solid var(--panel-border);
|
|
}
|
|
|
|
.doc-block-crepe.collapsed .doc-editor {
|
|
display: none;
|
|
}
|
|
|
|
.doc-header {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 10px 12px;
|
|
background: var(--crepe-color-surface);
|
|
border-bottom: 1px solid var(--panel-border);
|
|
gap: 10px;
|
|
}
|
|
|
|
.doc-icon {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--crepe-color-primary);
|
|
}
|
|
|
|
.doc-name {
|
|
flex: 1;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--crepe-color-on-surface);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.doc-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.action-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
padding: 0;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--crepe-color-on-surface-variant);
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.action-btn:hover {
|
|
background: var(--crepe-color-hover);
|
|
opacity: 1;
|
|
}
|
|
|
|
.doc-editor {
|
|
padding: 8px;
|
|
background: var(--crepe-color-surface-low);
|
|
min-height: 120px;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.inner-crepe {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.inner-crepe :deep(.milkdown) {
|
|
background: transparent !important;
|
|
}
|
|
|
|
.inner-crepe :deep(.ProseMirror) {
|
|
min-height: 80px;
|
|
padding: 8px !important;
|
|
}
|
|
</style>
|