新增: 资料导入增强+剪贴板+框选多选+未保存自动暂存+图片占位
This commit is contained in:
+160
-3
@@ -2,8 +2,10 @@
|
|||||||
App.vue — 根组件:编辑/演示模式切换、工具栏、三栏布局、弹窗
|
App.vue — 根组件:编辑/演示模式切换、工具栏、三栏布局、弹窗
|
||||||
===================================================================== -->
|
===================================================================== -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { store } from './core/store'
|
import { store } from './core/store'
|
||||||
|
import { readAsDataURL, fileToImageElement } from './core/importer'
|
||||||
|
import { checkImageQuota } from './core/ai'
|
||||||
import Toolbar from './components/editor/Toolbar.vue'
|
import Toolbar from './components/editor/Toolbar.vue'
|
||||||
import ThumbBar from './components/editor/ThumbBar.vue'
|
import ThumbBar from './components/editor/ThumbBar.vue'
|
||||||
import Canvas from './components/editor/Canvas.vue'
|
import Canvas from './components/editor/Canvas.vue'
|
||||||
@@ -35,6 +37,11 @@ const templateVisible = ref(false)
|
|||||||
const importVisible = ref(false)
|
const importVisible = ref(false)
|
||||||
const printVisible = ref(false)
|
const printVisible = ref(false)
|
||||||
const deckLoaded = ref(false) // 文库/导入是否加载了新 deck,防止误切
|
const deckLoaded = ref(false) // 文库/导入是否加载了新 deck,防止误切
|
||||||
|
const importModalRef = ref<InstanceType<typeof ImportModal> | null>(null)
|
||||||
|
|
||||||
|
function dropFilesIntoImport(fl: FileList) {
|
||||||
|
importModalRef.value?.acceptDroppedFiles(fl)
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- toast ---------- */
|
/* ---------- toast ---------- */
|
||||||
const toastText = ref('')
|
const toastText = ref('')
|
||||||
@@ -75,7 +82,6 @@ function onSave() {
|
|||||||
libraryVisible.value = true
|
libraryVisible.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function openAi() { switchTab('ai') }
|
|
||||||
|
|
||||||
/* ---------- 导出/导入 ---------- */
|
/* ---------- 导出/导入 ---------- */
|
||||||
function onExportJson() {
|
function onExportJson() {
|
||||||
@@ -143,6 +149,8 @@ function onKey(e: KeyboardEvent) {
|
|||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (settingsVisible.value) { settingsVisible.value = false; return }
|
if (settingsVisible.value) { settingsVisible.value = false; return }
|
||||||
if (libraryVisible.value) { libraryVisible.value = false; return }
|
if (libraryVisible.value) { libraryVisible.value = false; return }
|
||||||
|
// 多选态:先退多选
|
||||||
|
if (store.getSelection().length > 1) { store.selectElement(null); return }
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
|
||||||
@@ -158,7 +166,38 @@ function onKey(e: KeyboardEvent) {
|
|||||||
e.preventDefault(); if (!inField) toast('已自动保存到本地'); return
|
e.preventDefault(); if (!inField) toast('已自动保存到本地'); return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 元素剪贴:输入框内放行原生行为(复制文本等);多选时作用于整组 */
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C') && !inField) {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
const ids = sel.length > 1 ? sel : (store.getSelectedId() ? [store.getSelectedId()!] : [])
|
||||||
|
if (ids.length && store.copyElements(ids)) { e.preventDefault(); toast(`已复制 ${ids.length} 个元素`) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'x' || e.key === 'X') && !inField) {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
const ids = sel.length > 1 ? sel : (store.getSelectedId() ? [store.getSelectedId()!] : [])
|
||||||
|
if (ids.length && store.cutElements(ids)) { e.preventDefault(); toast(`已剪切 ${ids.length} 个元素`) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V') && !inField) {
|
||||||
|
if (store.pasteElements()) { e.preventDefault(); }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D') && !inField) {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
const ids = sel.length > 1 ? sel : (store.getSelectedId() ? [store.getSelectedId()!] : [])
|
||||||
|
if (ids.length && store.copyElements(ids) && store.pasteElements()) { e.preventDefault(); toast('已复制副本') }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A') && !inField) {
|
||||||
|
e.preventDefault()
|
||||||
|
store.selectAllElements()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === 'Delete' && !inField) {
|
if (e.key === 'Delete' && !inField) {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
if (sel.length > 1) { e.preventDefault(); store.delElements(sel); return }
|
||||||
const id = store.getSelectedId()
|
const id = store.getSelectedId()
|
||||||
if (id) { e.preventDefault(); store.delElement(id) }
|
if (id) { e.preventDefault(); store.delElement(id) }
|
||||||
}
|
}
|
||||||
@@ -166,8 +205,113 @@ function onKey(e: KeyboardEvent) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('keydown', onKey)
|
document.addEventListener('keydown', onKey)
|
||||||
|
document.addEventListener('paste', onPaste)
|
||||||
|
window.addEventListener('dragover', onGlobalDragOver)
|
||||||
|
window.addEventListener('drop', onGlobalDrop)
|
||||||
|
window.addEventListener('dragleave', onGlobalDragLeave)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/* ---------- 系统剪贴板粘贴:按内容类型分发(截图/图片URL/文本) ---------- */
|
||||||
|
async function onPaste(e: ClipboardEvent) {
|
||||||
|
if (document.body.dataset.mode === 'present') return
|
||||||
|
// 输入框内保持原生粘贴文本
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable) return
|
||||||
|
const cd = e.clipboardData
|
||||||
|
if (!cd) return
|
||||||
|
|
||||||
|
// 1) 图片(截图/复制的图片文件)→ 图片元素
|
||||||
|
const items = cd.items
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
const dataUrl = await readAsDataURL(file)
|
||||||
|
const quotaErr = checkImageQuota(dataUrl)
|
||||||
|
if (quotaErr) { toast(quotaErr); return }
|
||||||
|
const el = fileToImageElement(file, dataUrl)
|
||||||
|
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style })
|
||||||
|
toast('已插入剪贴板图片')
|
||||||
|
} catch (err: any) {
|
||||||
|
toast('读取剪贴板图片失败:' + (err?.message || String(err)))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 文本:图片 URL → 图片元素;其他文本 → 文本元素(内部元素剪贴板优先级更高,keydown 已处理)
|
||||||
|
if (store.hasClipboard()) return
|
||||||
|
const text = cd.getData('text/plain')
|
||||||
|
if (!text || !text.trim()) return
|
||||||
|
e.preventDefault()
|
||||||
|
const trimmed = text.trim()
|
||||||
|
// 图片直链(常见图床/截图床格式)
|
||||||
|
if (/^https?:\/\/\S+\.(png|jpe?g|gif|webp|svg|bmp|ico)(\?\S*)?$/i.test(trimmed)) {
|
||||||
|
store.addElement('image', { content: trimmed, x: 30, y: 20, w: 40, h: 45, style: { fit: 'contain', anim: 'fade' } })
|
||||||
|
toast('已插入图片链接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 普通文本 → 文本元素(多行按 list 更合适则用 list)
|
||||||
|
const lines = trimmed.split(/\n+/).filter(Boolean)
|
||||||
|
if (lines.length >= 3) {
|
||||||
|
store.addElement('list', { content: lines.join('\n'), x: 10, y: 25, w: 55, h: 45, style: { fontSize: 24, align: 'left', anim: 'fade-up' } })
|
||||||
|
toast(`已插入列表(${lines.length} 行)`)
|
||||||
|
} else {
|
||||||
|
const isTitle = trimmed.length <= 30 && lines.length === 1
|
||||||
|
store.addElement(isTitle ? 'title' : 'text', {
|
||||||
|
content: trimmed,
|
||||||
|
x: 10, y: isTitle ? 8 : 25, w: 70, h: isTitle ? 12 : 15,
|
||||||
|
style: isTitle ? { fontSize: 44, align: 'left', anim: 'fade-up' } : { fontSize: 24, align: 'left', anim: 'fade-up' }
|
||||||
|
})
|
||||||
|
toast(isTitle ? '已插入标题' : '已插入文本')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 全局拖放:JSON 直接导入,其他文件进资料导入弹窗 ---------- */
|
||||||
|
const globalDragOver = ref(false)
|
||||||
|
|
||||||
|
function onGlobalDragOver(e: DragEvent) {
|
||||||
|
if (!e.dataTransfer?.types.includes('Files')) return
|
||||||
|
e.preventDefault() // 阻止浏览器默认打开文件
|
||||||
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
if (!globalDragOver.value) globalDragOver.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalDragLeave(e: DragEvent) {
|
||||||
|
if (e.relatedTarget) return // 目标还在窗口内
|
||||||
|
globalDragOver.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onGlobalDrop(e: DragEvent) {
|
||||||
|
const fl = e.dataTransfer?.files
|
||||||
|
if (!fl || fl.length === 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
globalDragOver.value = false
|
||||||
|
|
||||||
|
// 单个 .json 文件 → 直接导入 deck(与工具栏 📥 导入同路径)
|
||||||
|
if (fl.length === 1 && /\.json$/i.test(fl[0].name)) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
store.importJSON(reader.result as string)
|
||||||
|
toast('已导入')
|
||||||
|
if (mode.value === 'home') mode.value = 'editor'
|
||||||
|
} catch (err: any) {
|
||||||
|
toast('导入失败:' + (err?.message || String(err)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsText(fl[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他文件 → 打开资料导入弹窗并预填
|
||||||
|
importVisible.value = true
|
||||||
|
await nextTick()
|
||||||
|
dropFilesIntoImport(fl)
|
||||||
|
}
|
||||||
|
|
||||||
/* 持久化失败告警 → toast(防止静默丢数据) */
|
/* 持久化失败告警 → toast(防止静默丢数据) */
|
||||||
watch(() => store.saveWarning.text, (txt) => {
|
watch(() => store.saveWarning.text, (txt) => {
|
||||||
if (txt) toast(txt)
|
if (txt) toast(txt)
|
||||||
@@ -182,6 +326,10 @@ watch(libraryVisible, (now, prev) => {
|
|||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', onKey)
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.removeEventListener('paste', onPaste)
|
||||||
|
window.removeEventListener('dragover', onGlobalDragOver)
|
||||||
|
window.removeEventListener('drop', onGlobalDrop)
|
||||||
|
window.removeEventListener('dragleave', onGlobalDragLeave)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -205,7 +353,6 @@ onUnmounted(() => {
|
|||||||
@present="onPresent"
|
@present="onPresent"
|
||||||
@open-library="libraryVisible = true"
|
@open-library="libraryVisible = true"
|
||||||
@open-settings="settingsVisible = true"
|
@open-settings="settingsVisible = true"
|
||||||
@open-ai="openAi"
|
|
||||||
@save="onSave"
|
@save="onSave"
|
||||||
@open-templates="templateVisible = true"
|
@open-templates="templateVisible = true"
|
||||||
@export-json="onExportJson"
|
@export-json="onExportJson"
|
||||||
@@ -267,6 +414,7 @@ onUnmounted(() => {
|
|||||||
@toast="toast"
|
@toast="toast"
|
||||||
/>
|
/>
|
||||||
<ImportModal
|
<ImportModal
|
||||||
|
ref="importModalRef"
|
||||||
:visible="importVisible"
|
:visible="importVisible"
|
||||||
@close="onImportClose"
|
@close="onImportClose"
|
||||||
@toast="toast"
|
@toast="toast"
|
||||||
@@ -279,4 +427,13 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- 轻提示 -->
|
<!-- 轻提示 -->
|
||||||
<div class="toast" :class="{ show: toastShow }">{{ toastText }}</div>
|
<div class="toast" :class="{ show: toastShow }">{{ toastText }}</div>
|
||||||
|
|
||||||
|
<!-- 全局拖放提示遮罩 -->
|
||||||
|
<div v-if="globalDragOver" class="global-drop-overlay">
|
||||||
|
<div class="global-drop-card">
|
||||||
|
<span class="global-drop-icon">📥</span>
|
||||||
|
<strong>松开导入文件</strong>
|
||||||
|
<span class="global-drop-hint">.json 直接导入 · 图片/文档进入资料导入</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
===================================================================== -->
|
===================================================================== -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
import { store } from '../core/store'
|
import { store } from '../core/store'
|
||||||
import type { LibItem } from '../core/types'
|
import type { LibItem } from '../core/types'
|
||||||
|
|
||||||
@@ -46,6 +47,16 @@ function firstSlideTitle(item: LibItem): string {
|
|||||||
const el = item?.deck?.slides?.[0]?.elements?.[0]
|
const el = item?.deck?.slides?.[0]?.elements?.[0]
|
||||||
return el?.type === 'title' ? (el.content || '无标题') : '无标题'
|
return el?.type === 'title' ? (el.content || '无标题') : '无标题'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 上次未入库的工作区草稿(改了没保存就关/刷新),提示继续编辑 */
|
||||||
|
const workDraft = computed(() => {
|
||||||
|
if (!store.getActiveLibId() && store.hasWorkDeck()) {
|
||||||
|
const d = store.getDeck()
|
||||||
|
const t = d.slides[0]?.elements.find(e => e.type === 'title')
|
||||||
|
return { title: t?.content || '未命名草稿', pages: d.slides.length }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -64,6 +75,15 @@ function firstSlideTitle(item: LibItem): string {
|
|||||||
<p class="home-desc">轻量在线演示工具 · 支持 AI 创作与本地资料导入</p>
|
<p class="home-desc">轻量在线演示工具 · 支持 AI 创作与本地资料导入</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 未保存草稿恢复 -->
|
||||||
|
<button v-if="workDraft" class="draft-resume" @click="emit('open-deck')">
|
||||||
|
<span class="draft-icon">⏵</span>
|
||||||
|
<span class="draft-info">
|
||||||
|
<strong>继续编辑「{{ workDraft.title }}」</strong>
|
||||||
|
<span>{{ workDraft.pages }} 页 · 上次未保存的草稿已自动暂存</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- 快捷入口 -->
|
<!-- 快捷入口 -->
|
||||||
<div class="home-actions">
|
<div class="home-actions">
|
||||||
<button class="action-card" @click="emit('new-blank')">
|
<button class="action-card" @click="emit('new-blank')">
|
||||||
@@ -171,6 +191,27 @@ function firstSlideTitle(item: LibItem): string {
|
|||||||
color: var(--ui-muted, #64748b);
|
color: var(--ui-muted, #64748b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 未保存草稿恢复卡片 */
|
||||||
|
.draft-resume {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
width: 100%; padding: 14px 18px;
|
||||||
|
border: 1px solid var(--ui-primary, #4f46e5);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--ui-primary, #4f46e5) 6%, var(--ui-panel, #fff));
|
||||||
|
cursor: pointer; text-align: left;
|
||||||
|
transition: box-shadow .15s, transform .15s;
|
||||||
|
}
|
||||||
|
.draft-resume:hover { box-shadow: var(--shadow-md, 0 4px 12px rgba(0,0,0,.08)); transform: translateY(-1px); }
|
||||||
|
.draft-icon {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
background: var(--ui-primary, #4f46e5); color: #fff;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 16px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.draft-info { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.draft-info strong { font-size: 14px; color: var(--ui-text, #1e293b); }
|
||||||
|
.draft-info span { font-size: 12px; color: var(--ui-muted, #64748b); }
|
||||||
|
|
||||||
/* 快捷入口网格 */
|
/* 快捷入口网格 */
|
||||||
.home-actions {
|
.home-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { SlideElement, BgKey } from '../../core/types'
|
import type { SlideElement, BgKey } from '../../core/types'
|
||||||
import { store, resolveColor } from '../../core/store'
|
import { store, resolveColor, isDarkBg } from '../../core/store'
|
||||||
import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext'
|
import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext'
|
||||||
import ChartView from './ChartView.vue'
|
import ChartView from './ChartView.vue'
|
||||||
|
|
||||||
@@ -59,32 +59,12 @@ const emit = defineEmits<{
|
|||||||
(e: 'blur', id: string, field: string, value: string): void
|
(e: 'blur', id: string, field: string, value: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/** 当前页背景是否深色 → 文字是否需要反相 */
|
/** 当前页背景是否深色 → 文字是否需要反相(isDarkBg 复用 store 导出的统一实现) */
|
||||||
const dark = computed(() => {
|
const dark = computed(() => {
|
||||||
const d = store.state.deck ? (props.el.type === 'card' ? false : isDarkBg(props.bg)) : false
|
const d = store.state.deck ? (props.el.type === 'card' ? false : isDarkBg(props.bg)) : false
|
||||||
return d
|
return d
|
||||||
})
|
})
|
||||||
|
|
||||||
function isDarkBg(bg: string): boolean {
|
|
||||||
if (!bg) return false
|
|
||||||
if (bg.charAt(0) === '#') return isDarkHex(bg)
|
|
||||||
if (bg === 'primary' || bg === 'accent') return true
|
|
||||||
if (bg.indexOf('g-') === 0) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
function hexToRgb(hex: string) {
|
|
||||||
const c = String(hex).replace('#', '')
|
|
||||||
const full = c.length === 3 ? c[0] + c[0] + c[1] + c[1] + c[2] + c[2] : c
|
|
||||||
const r = parseInt(full.substr(0, 2), 16)
|
|
||||||
const g = parseInt(full.substr(2, 2), 16)
|
|
||||||
const b = parseInt(full.substr(4, 2), 16)
|
|
||||||
return (isNaN(r) || isNaN(g) || isNaN(b)) ? null : { r, g, b }
|
|
||||||
}
|
|
||||||
function isDarkHex(hex: string): boolean {
|
|
||||||
const rgb = hexToRgb(hex); if (!rgb) return false
|
|
||||||
return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) < 145
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = computed(() => props.el.style || {})
|
const s = computed(() => props.el.style || {})
|
||||||
|
|
||||||
const boxStyle = computed(() => {
|
const boxStyle = computed(() => {
|
||||||
@@ -245,9 +225,13 @@ function onBlur(e: Event, field: string) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 图片 -->
|
<!-- 图片(无内容时显示占位提示,不渲染空 src 的裂图) -->
|
||||||
<template v-else-if="el.type === 'image'">
|
<template v-else-if="el.type === 'image'">
|
||||||
<img class="el-image" :src="el.content" draggable="false" />
|
<div v-if="!el.content" class="el-image-empty">
|
||||||
|
<span class="el-image-empty-icon">🖼</span>
|
||||||
|
<span class="el-image-empty-text">拖入图片 · 属性面板「本地图片」或 AI 配图</span>
|
||||||
|
</div>
|
||||||
|
<img v-else class="el-image" :src="el.content" draggable="false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 形状 -->
|
<!-- 形状 -->
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
import { computed, watch, ref } from 'vue'
|
import { computed, watch, ref } from 'vue'
|
||||||
import { store } from '../../core/store'
|
import { store } from '../../core/store'
|
||||||
import { elementTypes } from '../../core/sample'
|
import { elementTypes } from '../../core/sample'
|
||||||
import { generateImage, isImageConfigured } from '../../core/ai'
|
import { generateImage, isImageConfigured, checkImageQuota } from '../../core/ai'
|
||||||
|
import { readAsDataURL } from '../../core/importer'
|
||||||
import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext'
|
import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext'
|
||||||
import AddGrid from './AddGrid.vue'
|
import AddGrid from './AddGrid.vue'
|
||||||
import type { ElementType, ChartType } from '../../core/types'
|
import type { ElementType, ChartType } from '../../core/types'
|
||||||
@@ -23,6 +24,7 @@ const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [
|
|||||||
|
|
||||||
const selected = computed(() => store.getSelected())
|
const selected = computed(() => store.getSelected())
|
||||||
const slide = computed(() => store.currentSlide.value)
|
const slide = computed(() => store.currentSlide.value)
|
||||||
|
const multiCount = computed(() => store.getSelection().length)
|
||||||
|
|
||||||
/** 临时输入态(v-model 绑定) */
|
/** 临时输入态(v-model 绑定) */
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
@@ -80,6 +82,16 @@ function onAdd(type: ElementType) {
|
|||||||
store.addElement(type)
|
store.addElement(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 多选批量操作 */
|
||||||
|
function onMultiCopy() {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
if (sel.length) store.copyElements(sel)
|
||||||
|
}
|
||||||
|
function onMultiDel() {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
if (sel.length) store.delElements(sel)
|
||||||
|
}
|
||||||
|
|
||||||
function onContentInput() {
|
function onContentInput() {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
store.updateElement(selected.value.id, { content: content.value })
|
store.updateElement(selected.value.id, { content: content.value })
|
||||||
@@ -161,7 +173,7 @@ function applyMarkdown() {
|
|||||||
if (!el) return
|
if (!el) return
|
||||||
const segs = markdownToSegments(content.value)
|
const segs = markdownToSegments(content.value)
|
||||||
if (segs.length && hasFormatting(segs)) {
|
if (segs.length && hasFormatting(segs)) {
|
||||||
store.updateElement(el.id, { segments: segs } as any)
|
store.updateElement(el.id, { segments: segs })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,10 +181,10 @@ function applyMarkdown() {
|
|||||||
function clearRich() {
|
function clearRich() {
|
||||||
const el = selected.value
|
const el = selected.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
store.updateElement(el.id, { segments: undefined } as any)
|
store.updateElement(el.id, { segments: undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- AI 配图 ---------- */
|
/* ---------- AI 配图 / 本地换图 ---------- */
|
||||||
async function onAiImage() {
|
async function onAiImage() {
|
||||||
if (imgBusy.value) return
|
if (imgBusy.value) return
|
||||||
const el = selected.value
|
const el = selected.value
|
||||||
@@ -184,6 +196,8 @@ async function onAiImage() {
|
|||||||
imgAbort = new AbortController()
|
imgAbort = new AbortController()
|
||||||
try {
|
try {
|
||||||
const r = await generateImage({ prompt: promptText, signal: imgAbort.signal })
|
const r = await generateImage({ prompt: promptText, signal: imgAbort.signal })
|
||||||
|
const quotaErr = checkImageQuota(r.url)
|
||||||
|
if (quotaErr) { alert(quotaErr); return }
|
||||||
store.updateElement(el.id, { content: r.url })
|
store.updateElement(el.id, { content: r.url })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name !== 'AbortError') alert('配图失败:' + (e?.message || String(e)))
|
if (e?.name !== 'AbortError') alert('配图失败:' + (e?.message || String(e)))
|
||||||
@@ -191,6 +205,28 @@ async function onAiImage() {
|
|||||||
imgBusy.value = false; imgAbort = null
|
imgBusy.value = false; imgAbort = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 本地上传图片替换当前图片元素内容 */
|
||||||
|
function onLocalImage() {
|
||||||
|
const el = selected.value
|
||||||
|
if (!el || el.type !== 'image') return
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = 'image/*'
|
||||||
|
input.onchange = async () => {
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
const dataUrl = await readAsDataURL(file)
|
||||||
|
const quotaErr = checkImageQuota(dataUrl)
|
||||||
|
if (quotaErr) { alert(quotaErr); return }
|
||||||
|
store.updateElement(el.id, { content: dataUrl })
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('读取图片失败:' + (e?.message || String(e)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -201,6 +237,24 @@ async function onAiImage() {
|
|||||||
<AddGrid @add="onAdd" />
|
<AddGrid @add="onAdd" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 多选态:批量操作 -->
|
||||||
|
<section v-else-if="multiCount > 1" class="prop-section" id="propMulti">
|
||||||
|
<h4 class="prop-title">多选 · <span>{{ multiCount }} 个元素</span></h4>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>批量操作</label>
|
||||||
|
<div class="seg">
|
||||||
|
<button @click="onMultiCopy" title="Ctrl+C">⧉ 复制</button>
|
||||||
|
<button class="danger" @click="onMultiDel" title="Delete">🗑 删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>提示</label>
|
||||||
|
<div style="font-size:12px;color:var(--ui-muted);line-height:1.6">
|
||||||
|
拖动整组移动 · Shift+点击加选/减选<br />空白处拖动框选 · Esc 取消多选
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 选中元素 -->
|
<!-- 选中元素 -->
|
||||||
<section v-else class="prop-section" id="propElement">
|
<section v-else class="prop-section" id="propElement">
|
||||||
<h4 class="prop-title">元素 · <span>{{ typeLabel }}</span></h4>
|
<h4 class="prop-title">元素 · <span>{{ typeLabel }}</span></h4>
|
||||||
@@ -321,10 +375,13 @@ async function onAiImage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AI 配图(仅图片元素) -->
|
<!-- 图片来源(仅图片元素) -->
|
||||||
<div v-if="selected.type === 'image'" class="prop-row">
|
<div v-if="selected.type === 'image'" class="prop-row">
|
||||||
<label>AI 配图</label>
|
<label>图片来源</label>
|
||||||
<button class="btn" :disabled="imgBusy" @click="onAiImage">{{ imgBusy ? '生成中…' : '🎨 AI 配图' }}</button>
|
<div class="seg">
|
||||||
|
<button @click="onLocalImage" title="从本地选择图片">📁 本地图片</button>
|
||||||
|
<button :disabled="imgBusy" @click="onAiImage">{{ imgBusy ? '生成中…' : '🎨 AI 配图' }}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 层级 -->
|
<!-- 层级 -->
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
scanFiles, readEntries,
|
scanFiles, readEntries,
|
||||||
aiAnalyzeDocument,
|
aiAnalyzeDocument,
|
||||||
fileToImageElement, imagesToSlideElements,
|
fileToImageElement, imagesToSlideElements,
|
||||||
|
ACCEPT_ATTR,
|
||||||
type FileEntry, type ImportReport,
|
type FileEntry, type ImportReport,
|
||||||
describeReport
|
describeReport
|
||||||
} from '../../core/importer'
|
} from '../../core/importer'
|
||||||
@@ -59,7 +60,7 @@ function openFilePicker() {
|
|||||||
fileInput = document.createElement('input')
|
fileInput = document.createElement('input')
|
||||||
fileInput.type = 'file'
|
fileInput.type = 'file'
|
||||||
fileInput.multiple = true
|
fileInput.multiple = true
|
||||||
fileInput.accept = '.png,.jpg,.jpeg,.gif,.webp,.svg,.md,.markdown,.txt,.text,.pdf,.docx,.doc'
|
fileInput.accept = ACCEPT_ATTR
|
||||||
fileInput.onchange = () => handleFiles(fileInput!.files)
|
fileInput.onchange = () => handleFiles(fileInput!.files)
|
||||||
}
|
}
|
||||||
fileInput.value = ''
|
fileInput.value = ''
|
||||||
@@ -77,6 +78,32 @@ function openDirPicker() {
|
|||||||
dirInput.click()
|
dirInput.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 拖放导入(两个 dropzone 通用) ---------- */
|
||||||
|
const dragOver = ref(false)
|
||||||
|
|
||||||
|
function onDropzoneDragOver(e: DragEvent) {
|
||||||
|
if (!e.dataTransfer?.types.includes('Files')) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
dragOver.value = true
|
||||||
|
}
|
||||||
|
function onDropzoneDragLeave() {
|
||||||
|
dragOver.value = false
|
||||||
|
}
|
||||||
|
function onDropzoneDrop(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
dragOver.value = false
|
||||||
|
const fl = e.dataTransfer?.files
|
||||||
|
if (fl && fl.length > 0) void handleFiles(fl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供父组件预填拖入的文件(全局拖放 → 打开弹窗并直接进入预览态) */
|
||||||
|
function acceptDroppedFiles(fl: FileList) {
|
||||||
|
void handleFiles(fl)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ acceptDroppedFiles })
|
||||||
|
|
||||||
async function handleFiles(fl: FileList | null) {
|
async function handleFiles(fl: FileList | null) {
|
||||||
if (!fl || fl.length === 0) return
|
if (!fl || fl.length === 0) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -152,6 +179,8 @@ function doImport() {
|
|||||||
|
|
||||||
let insertedImages = 0
|
let insertedImages = 0
|
||||||
let insertedSlides = 0
|
let insertedSlides = 0
|
||||||
|
// 整次导入合并为一条 undo 记录
|
||||||
|
const batch = store.beginBatch()
|
||||||
|
|
||||||
// 图片体积闸门:localStorage 约 5MB 字符上限,超限导入后刷新会丢图
|
// 图片体积闸门:localStorage 约 5MB 字符上限,超限导入后刷新会丢图
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
@@ -169,12 +198,12 @@ function doImport() {
|
|||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
if (images.length === 1) {
|
if (images.length === 1) {
|
||||||
const el = fileToImageElement(images[0].file, images[0].data!)
|
const el = fileToImageElement(images[0].file, images[0].data!)
|
||||||
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style })
|
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
||||||
insertedImages = 1
|
insertedImages = 1
|
||||||
} else {
|
} else {
|
||||||
const els = imagesToSlideElements(images.map(f => ({ file: f.file, dataUrl: f.data! })))
|
const els = imagesToSlideElements(images.map(f => ({ file: f.file, dataUrl: f.data! })))
|
||||||
for (const el of els) {
|
for (const el of els) {
|
||||||
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style })
|
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
||||||
insertedImages++
|
insertedImages++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +214,7 @@ function doImport() {
|
|||||||
for (const d of docs) {
|
for (const d of docs) {
|
||||||
if (d.slides) {
|
if (d.slides) {
|
||||||
for (const slide of d.slides) {
|
for (const slide of d.slides) {
|
||||||
store.appendSlide(slide)
|
store.appendSlide(slide, batch)
|
||||||
insertedSlides++
|
insertedSlides++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,15 +263,31 @@ function textPreview(data: string, maxLen = 80): string {
|
|||||||
<button class="tab" :class="{ active: activeTab === 'dir' }" @click="activeTab = 'dir'">📁 读取目录</button>
|
<button class="tab" :class="{ active: activeTab === 'dir' }" @click="activeTab = 'dir'">📁 读取目录</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="activeTab === 'files'" class="dropzone" @click="openFilePicker">
|
<div
|
||||||
|
v-if="activeTab === 'files'"
|
||||||
|
class="dropzone"
|
||||||
|
:class="{ 'drag-over': dragOver }"
|
||||||
|
@click="openFilePicker"
|
||||||
|
@dragover="onDropzoneDragOver"
|
||||||
|
@dragleave="onDropzoneDragLeave"
|
||||||
|
@drop="onDropzoneDrop"
|
||||||
|
>
|
||||||
<div class="dropzone-icon">📄</div>
|
<div class="dropzone-icon">📄</div>
|
||||||
<div class="dropzone-text">
|
<div class="dropzone-text">
|
||||||
<strong>点击选择文件</strong>
|
<strong>点击选择或拖入文件</strong>
|
||||||
<span class="hint">图片直接插入 · 文档(PDF/DOCX/MD/TXT)由 AI 分析生成幻灯片</span>
|
<span class="hint">图片直接插入 · 文档(PDF/DOCX/MD/TXT)由 AI 分析生成幻灯片</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="dropzone" @click="openDirPicker">
|
<div
|
||||||
|
v-else
|
||||||
|
class="dropzone"
|
||||||
|
:class="{ 'drag-over': dragOver }"
|
||||||
|
@click="openDirPicker"
|
||||||
|
@dragover="onDropzoneDragOver"
|
||||||
|
@dragleave="onDropzoneDragLeave"
|
||||||
|
@drop="onDropzoneDrop"
|
||||||
|
>
|
||||||
<div class="dropzone-icon">📁</div>
|
<div class="dropzone-icon">📁</div>
|
||||||
<div class="dropzone-text">
|
<div class="dropzone-text">
|
||||||
<strong>点击选择目录</strong>
|
<strong>点击选择目录</strong>
|
||||||
@@ -350,6 +395,7 @@ function textPreview(data: string, maxLen = 80): string {
|
|||||||
background: var(--panel, #f8fafc);
|
background: var(--panel, #f8fafc);
|
||||||
}
|
}
|
||||||
.dropzone:hover { border-color: var(--primary, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 5%, var(--panel, #f8fafc)); }
|
.dropzone:hover { border-color: var(--primary, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 5%, var(--panel, #f8fafc)); }
|
||||||
|
.dropzone.drag-over { border-color: var(--primary, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 10%, var(--panel, #f8fafc)); }
|
||||||
.dropzone-icon { font-size: 44px; }
|
.dropzone-icon { font-size: 44px; }
|
||||||
.dropzone-text { display: flex; flex-direction: column; gap: 4px; }
|
.dropzone-text { display: flex; flex-direction: column; gap: 4px; }
|
||||||
.dropzone-text strong { font-size: 16px; }
|
.dropzone-text strong { font-size: 16px; }
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
/* =====================================================================
|
/* =====================================================================
|
||||||
* useEditor.ts — 画布拖拽/缩放/选中逻辑(从 editor.js 提取)
|
* useEditor.ts — 画布拖拽/缩放/选中/框选逻辑
|
||||||
* 用 Vue ref 暴露拖拽会话状态,供 Canvas.vue 响应式绑定
|
* 用 Vue ref 暴露拖拽会话状态,供 Canvas.vue 响应式绑定
|
||||||
* ===================================================================== */
|
* ===================================================================== */
|
||||||
import { ref, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
import { store } from '../core/store'
|
import { store } from '../core/store'
|
||||||
|
|
||||||
interface DragSession {
|
interface DragSession {
|
||||||
mode: 'move' | 'resize'
|
mode: 'move' | 'resize' | 'multi-move'
|
||||||
id: string
|
id: string
|
||||||
|
ids?: string[] // multi-move 的元素集
|
||||||
ax?: number // resize 水平轴:-1/0/1
|
ax?: number // resize 水平轴:-1/0/1
|
||||||
ay?: number // resize 垂直轴:-1/0/1
|
ay?: number // resize 垂直轴:-1/0/1
|
||||||
startX: number
|
startX: number
|
||||||
@@ -19,21 +20,44 @@ interface DragSession {
|
|||||||
rectW: number
|
rectW: number
|
||||||
rectH: number
|
rectH: number
|
||||||
result?: { x: number; y: number; w: number; h: number }
|
result?: { x: number; y: number; w: number; h: number }
|
||||||
|
multiStart?: Array<{ id: string; x: number; y: number; w: number; h: number }> // multi-move 起点快照
|
||||||
|
multiResult?: Array<{ id: string; x: number; y: number }> // multi-move 各元素目标位
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 框选会话(空白处按下拖动 → marquee) */
|
||||||
|
interface MarqueeSession {
|
||||||
|
x0: number; y0: number; x1: number; y1: number // 百分比坐标
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEditor() {
|
export function useEditor() {
|
||||||
const drag: Ref<DragSession | null> = ref(null)
|
const drag: Ref<DragSession | null> = ref(null)
|
||||||
|
const marquee: Ref<MarqueeSession | null> = ref(null)
|
||||||
const editing = ref(false) // 是否在 contenteditable 编辑中(暂停拖拽)
|
const editing = ref(false) // 是否在 contenteditable 编辑中(暂停拖拽)
|
||||||
|
|
||||||
/** 鼠标按下:空白取消选中 / 手柄缩放 / 元素拖拽 */
|
/** 鼠标按下:空白→框选起点 / 手柄缩放 / 元素拖拽(Shift 加选) */
|
||||||
function onCanvasMouseDown(e: MouseEvent, canvasEl: HTMLElement) {
|
function onCanvasMouseDown(e: MouseEvent, canvasEl: HTMLElement) {
|
||||||
if (editing.value) return
|
if (editing.value) return
|
||||||
|
if (e.button !== 0) return // 仅左键
|
||||||
const target = (e.target as HTMLElement).closest('.el') as HTMLElement | null
|
const target = (e.target as HTMLElement).closest('.el') as HTMLElement | null
|
||||||
const handle = (e.target as HTMLElement).classList && (e.target as HTMLElement).classList.contains('handle')
|
const handle = (e.target as HTMLElement).classList && (e.target as HTMLElement).classList.contains('handle')
|
||||||
? (e.target as HTMLElement) : null
|
? (e.target as HTMLElement) : null
|
||||||
|
|
||||||
if (!target && !handle) {
|
if (!target && !handle) {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
if (!e.shiftKey && sel.length > 1) {
|
||||||
|
store.selectElement(null) // 多选态点空白:先只清多选
|
||||||
|
return
|
||||||
|
}
|
||||||
store.selectElement(null)
|
store.selectElement(null)
|
||||||
|
// 启动框选
|
||||||
|
const rect = canvasEl.getBoundingClientRect()
|
||||||
|
marquee.value = {
|
||||||
|
x0: (e.clientX - rect.left) / rect.width * 100,
|
||||||
|
y0: (e.clientY - rect.top) / rect.height * 100,
|
||||||
|
x1: (e.clientX - rect.left) / rect.width * 100,
|
||||||
|
y1: (e.clientY - rect.top) / rect.height * 100
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (handle) {
|
if (handle) {
|
||||||
@@ -43,7 +67,19 @@ export function useEditor() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const id = target!.dataset.id!
|
const id = target!.dataset.id!
|
||||||
|
// Shift+点击:加选/减选,不进入拖拽
|
||||||
|
if (e.shiftKey) {
|
||||||
|
store.toggleSelect(id)
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 点击已选多选集中的元素 → 拖动整组;否则正常单选拖动
|
||||||
|
const sel = store.getSelection()
|
||||||
|
if (sel.length > 1 && sel.includes(id)) {
|
||||||
|
startMultiDrag(sel, e, canvasEl)
|
||||||
|
} else {
|
||||||
startDrag(id, e, canvasEl)
|
startDrag(id, e, canvasEl)
|
||||||
|
}
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +94,23 @@ export function useEditor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 多选整组拖动:记录各元素起始位置(multiResult 始终 = 起点 + 累计位移,不从上次结果累加) */
|
||||||
|
function startMultiDrag(ids: string[], e: MouseEvent, canvasEl: HTMLElement) {
|
||||||
|
const starts = ids
|
||||||
|
.map(id => store.findElement(id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(el => ({ id: el!.id, x: el!.x, y: el!.y, w: el!.w, h: el!.h }))
|
||||||
|
if (!starts.length) return
|
||||||
|
const rect = canvasEl.getBoundingClientRect()
|
||||||
|
drag.value = {
|
||||||
|
mode: 'multi-move', id: ids[ids.length - 1], ids,
|
||||||
|
startX: 0, startY: 0, startW: 0, startH: 0,
|
||||||
|
px0: e.clientX, py0: e.clientY, rectW: rect.width, rectH: rect.height,
|
||||||
|
multiStart: starts.map(s => ({ id: s.id, x: s.x, y: s.y, w: s.w, h: s.h })),
|
||||||
|
multiResult: starts.map(s => ({ id: s.id, x: s.x, y: s.y }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const AXES: Record<string, [number, number]> = {
|
const AXES: Record<string, [number, number]> = {
|
||||||
tl: [-1, -1], tm: [0, -1], tr: [1, -1],
|
tl: [-1, -1], tm: [0, -1], tr: [1, -1],
|
||||||
lm: [-1, 0], rm: [1, 0],
|
lm: [-1, 0], rm: [1, 0],
|
||||||
@@ -76,10 +129,35 @@ export function useEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent) {
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
// 框选拖动:更新 marquee 矩形(Canvas 渲染选框)
|
||||||
|
const m = marquee.value
|
||||||
|
if (m) {
|
||||||
|
const canvas = document.querySelector('.canvas') as HTMLElement | null
|
||||||
|
if (canvas) {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
m.x1 = Math.max(0, Math.min(100, (e.clientX - rect.left) / rect.width * 100))
|
||||||
|
m.y1 = Math.max(0, Math.min(100, (e.clientY - rect.top) / rect.height * 100))
|
||||||
|
marquee.value = { ...m }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const d = drag.value
|
const d = drag.value
|
||||||
if (!d) return
|
if (!d) return
|
||||||
const dx = (e.clientX - d.px0) / d.rectW * 100
|
const dx = (e.clientX - d.px0) / d.rectW * 100
|
||||||
const dy = (e.clientY - d.py0) / d.rectH * 100
|
const dy = (e.clientY - d.py0) / d.rectH * 100
|
||||||
|
|
||||||
|
if (d.mode === 'multi-move' && d.multiStart) {
|
||||||
|
// 关键:基于「起点 + 累计位移」计算,而非在上次结果上累加(否则位移重复叠加)
|
||||||
|
d.multiResult = d.multiStart.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
x: Math.max(0, Math.min(100 - s.w, s.x + dx)),
|
||||||
|
y: Math.max(0, Math.min(100 - s.h, s.y + dy))
|
||||||
|
}))
|
||||||
|
drag.value = { ...d }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let nx = d.startX, ny = d.startY, nw = d.startW, nh = d.startH
|
let nx = d.startX, ny = d.startY, nw = d.startW, nh = d.startH
|
||||||
|
|
||||||
if (d.mode === 'move') {
|
if (d.mode === 'move') {
|
||||||
@@ -96,11 +174,45 @@ export function useEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onMouseUp() {
|
function onMouseUp() {
|
||||||
|
// 结束框选:计算与 marquee 相交的元素 → 多选
|
||||||
|
const m = marquee.value
|
||||||
|
if (m) {
|
||||||
|
marquee.value = null
|
||||||
|
const left = Math.min(m.x0, m.x1), right = Math.max(m.x0, m.x1)
|
||||||
|
const top = Math.min(m.y0, m.y1), bottom = Math.max(m.y0, m.y1)
|
||||||
|
// 过小的框(<1%)视为点击空白,仅清选
|
||||||
|
if (right - left > 1 || bottom - top > 1) {
|
||||||
|
const s = store.currentSlide.value
|
||||||
|
const hits = (s?.elements || [])
|
||||||
|
.filter(el => {
|
||||||
|
const ex = el.x, ey = el.y, ew = el.x + el.w, eh = el.y + el.h
|
||||||
|
return ex < right && ew > left && ey < bottom && eh > top // 矩形相交
|
||||||
|
})
|
||||||
|
.map(el => el.id)
|
||||||
|
if (hits.length) store.selectMany(hits)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const d = drag.value
|
const d = drag.value
|
||||||
if (!d) return
|
if (!d) return
|
||||||
const r = d.result
|
const r = d.result
|
||||||
|
const mr = d.multiResult
|
||||||
const id = d.id
|
const id = d.id
|
||||||
drag.value = null
|
drag.value = null
|
||||||
|
if (d.mode === 'multi-move' && mr) {
|
||||||
|
// 多选位移落盘(相对起点有变化的才提交)
|
||||||
|
const moved = mr.filter(r2 => {
|
||||||
|
const el = store.findElement(r2.id)
|
||||||
|
return el && (el.x !== r2.x || el.y !== r2.y)
|
||||||
|
})
|
||||||
|
if (moved.length) {
|
||||||
|
const group = store.beginBatch()
|
||||||
|
for (const t of moved) store.updateElement(t.id, { x: t.x, y: t.y })
|
||||||
|
void group
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (r) store.updateElement(id, r)
|
if (r) store.updateElement(id, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +220,7 @@ export function useEditor() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
drag,
|
drag,
|
||||||
|
marquee,
|
||||||
editing,
|
editing,
|
||||||
onCanvasMouseDown,
|
onCanvasMouseDown,
|
||||||
onMouseMove,
|
onMouseMove,
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ import { createElement } from './sample'
|
|||||||
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||||
|
|
||||||
/** 允许的图片扩展名 */
|
/** 允许的图片扩展名 */
|
||||||
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'])
|
export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'])
|
||||||
|
|
||||||
/** 允许的文档扩展名(由 LLM 解析) */
|
/** 允许的文档扩展名(由 LLM 解析) */
|
||||||
const DOC_EXTS = new Set(['.md', '.markdown', '.txt', '.text', '.pdf', '.docx', '.doc'])
|
export const DOC_EXTS = new Set(['.md', '.markdown', '.txt', '.text', '.pdf', '.docx', '.doc'])
|
||||||
|
|
||||||
|
/** 文件选择器 accept 属性(由支持清单派生,保持单一数据源) */
|
||||||
|
export const ACCEPT_ATTR = [...IMAGE_EXTS, ...DOC_EXTS].join(',')
|
||||||
|
|
||||||
/** 文件分类结果 */
|
/** 文件分类结果 */
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
@@ -58,7 +61,7 @@ function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 读取图片为 Data URL */
|
/** 读取图片为 Data URL */
|
||||||
function readAsDataURL(file: File): Promise<string> {
|
export function readAsDataURL(file: File): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => resolve(reader.result as string)
|
reader.onload = () => resolve(reader.result as string)
|
||||||
@@ -196,7 +199,8 @@ export async function scanFiles(fileList: FileList): Promise<FileEntry[]> {
|
|||||||
|
|
||||||
/** 读取文件内容(图片 → data URL,文档 → 原始文本),原位修改 entries */
|
/** 读取文件内容(图片 → data URL,文档 → 原始文本),原位修改 entries */
|
||||||
export async function readEntries(entries: FileEntry[]): Promise<void> {
|
export async function readEntries(entries: FileEntry[]): Promise<void> {
|
||||||
for (const entry of entries) {
|
// 并行读取:各文件互不依赖,错误就地记到 entry.error 不中断整体
|
||||||
|
await Promise.all(entries.map(async (entry) => {
|
||||||
try {
|
try {
|
||||||
if (entry.kind === 'image') {
|
if (entry.kind === 'image') {
|
||||||
entry.data = await readAsDataURL(entry.file)
|
entry.data = await readAsDataURL(entry.file)
|
||||||
@@ -215,7 +219,7 @@ export async function readEntries(entries: FileEntry[]): Promise<void> {
|
|||||||
entry.error = e?.message || String(e)
|
entry.error = e?.message || String(e)
|
||||||
console.warn(`读取失败 ${entry.name}:`, e)
|
console.warn(`读取失败 ${entry.name}:`, e)
|
||||||
}
|
}
|
||||||
}
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 使用 AI 将一个文档的文本内容解析为幻灯片(异步,调用 LLM) */
|
/** 使用 AI 将一个文档的文本内容解析为幻灯片(异步,调用 LLM) */
|
||||||
|
|||||||
+313
-38
@@ -15,21 +15,22 @@ const ACTIVE_KEY = 'u-ppt.activeLib.v1'
|
|||||||
const CHAT_PREFIX = 'u-ppt.chat.'
|
const CHAT_PREFIX = 'u-ppt.chat.'
|
||||||
const SAVE_DELAY = 400
|
const SAVE_DELAY = 400
|
||||||
|
|
||||||
/* ---------- 内置页面模板(版式预设) ---------- */
|
/* ---------- 内置页面模板(版式预设,按 group 分组展示) ---------- */
|
||||||
const BUILTIN_TEMPLATES: PageTemplate[] = [
|
const BUILTIN_TEMPLATES: PageTemplate[] = [
|
||||||
|
/* ===== 基础 ===== */
|
||||||
{
|
{
|
||||||
id: 'tpl-blank', name: '空白页', category: 'built-in', background: 'bg',
|
id: 'tpl-blank', name: '空白页', category: 'built-in', group: 'basic', background: 'bg',
|
||||||
elements: []
|
elements: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-title', name: '标题页', category: 'built-in', background: 'g-primary',
|
id: 'tpl-title', name: '标题页', category: 'built-in', group: 'basic', background: 'g-primary',
|
||||||
elements: [
|
elements: [
|
||||||
{ id: 'b1', type: 'title', x: 10, y: 38, w: 80, h: 20, content: '标题', style: { fontSize: 64, color: 'text', align: 'center', anim: 'pop' } },
|
{ id: 'b1', type: 'title', x: 10, y: 38, w: 80, h: 20, content: '标题', style: { fontSize: 64, color: 'text', align: 'center', anim: 'pop' } },
|
||||||
{ id: 'b2', type: 'text', x: 20, y: 60, w: 60, h: 8, content: '副标题', style: { fontSize: 24, color: 'muted', align: 'center', anim: 'fade-up' } }
|
{ id: 'b2', type: 'text', x: 20, y: 60, w: 60, h: 8, content: '副标题', style: { fontSize: 24, color: 'muted', align: 'center', anim: 'fade-up' } }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-section', name: '章节页', category: 'built-in', background: 'g-deep',
|
id: 'tpl-section', name: '章节页', category: 'built-in', group: 'basic', background: 'g-deep',
|
||||||
elements: [
|
elements: [
|
||||||
{ id: 's1', type: 'shape', x: 0, y: 0, w: 6, h: 100, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, anim: 'slide-l' } },
|
{ id: 's1', type: 'shape', x: 0, y: 0, w: 6, h: 100, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, anim: 'slide-l' } },
|
||||||
{ id: 's2', type: 'title', x: 12, y: 38, w: 70, h: 18, content: '章节标题', style: { fontSize: 56, color: 'text', align: 'left', anim: 'fade-up' } },
|
{ id: 's2', type: 'title', x: 12, y: 38, w: 70, h: 18, content: '章节标题', style: { fontSize: 56, color: 'text', align: 'left', anim: 'fade-up' } },
|
||||||
@@ -37,7 +38,19 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-cards3', name: '三卡片', category: 'built-in', background: 'bg',
|
id: 'tpl-toc', name: '目录页', category: 'built-in', group: 'basic', background: 'bg',
|
||||||
|
elements: [
|
||||||
|
{ id: 't1', type: 'title', x: 8, y: 8, w: 40, h: 12, content: '目录', style: { fontSize: 44, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 't2', type: 'shape', x: 8, y: 23, w: 10, h: 1.6, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' } },
|
||||||
|
{ id: 't3', type: 'list', x: 10, y: 32, w: 36, h: 50, content: '01 第一个主题\n02 第二个主题\n03 第三个主题\n04 第四个主题', style: { fontSize: 26, color: 'text', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 't4', type: 'shape', x: 55, y: 0, w: 45, h: 100, content: '', style: { shapeType: 'circle', fill: 'accent', gradient: true, opacity: 0.15, anim: 'scale' } },
|
||||||
|
{ id: 't5', type: 'stat', x: 58, y: 32, w: 36, h: 36, content: '04', style: { fontSize: 90, color: 'primary', label: '个章节', labelColor: 'muted', labelSize: 20, anim: 'scale' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ===== 内容 ===== */
|
||||||
|
{
|
||||||
|
id: 'tpl-cards3', name: '三卡片', category: 'built-in', group: 'content', background: 'bg',
|
||||||
elements: [
|
elements: [
|
||||||
{ id: 'c1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
{ id: 'c1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
{ id: 'c2', type: 'card', x: 6, y: 28, w: 28, h: 60, content: '卡片一\n要点描述', style: { accent: 'primary', icon: '', anim: 'fade-up' } },
|
{ id: 'c2', type: 'card', x: 6, y: 28, w: 28, h: 60, content: '卡片一\n要点描述', style: { accent: 'primary', icon: '', anim: 'fade-up' } },
|
||||||
@@ -46,7 +59,7 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-compare', name: '对比页', category: 'built-in', background: 'bg',
|
id: 'tpl-compare', name: '对比页', category: 'built-in', group: 'content', background: 'bg',
|
||||||
elements: [
|
elements: [
|
||||||
{ id: 'p1', type: 'title', x: 10, y: 7, w: 80, h: 11, content: '对比标题', style: { fontSize: 36, color: 'primary', align: 'center', anim: 'fade-up' } },
|
{ id: 'p1', type: 'title', x: 10, y: 7, w: 80, h: 11, content: '对比标题', style: { fontSize: 36, color: 'primary', align: 'center', anim: 'fade-up' } },
|
||||||
{ id: 'p2', type: 'card', x: 7, y: 24, w: 40, h: 65, content: '左\n要点一\n要点二', style: { accent: 'muted', icon: '', anim: 'slide-l' } },
|
{ id: 'p2', type: 'card', x: 7, y: 24, w: 40, h: 65, content: '左\n要点一\n要点二', style: { accent: 'muted', icon: '', anim: 'slide-l' } },
|
||||||
@@ -54,7 +67,78 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-quote', name: '金句页', category: 'built-in', background: 'g-deep',
|
id: 'tpl-bullets', name: '要点列表', category: 'built-in', group: 'content', background: 'bg',
|
||||||
|
elements: [
|
||||||
|
{ id: 'l1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '要点标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'l2', type: 'shape', x: 8, y: 23, w: 10, h: 1.6, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' } },
|
||||||
|
{ id: 'l3', type: 'list', x: 10, y: 30, w: 80, h: 55, content: '第一个要点:具体说明\n第二个要点:具体说明\n第三个要点:具体说明\n第四个要点:具体说明', style: { fontSize: 28, color: 'text', align: 'left', anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-image-text', name: '图文混排', category: 'built-in', group: 'content', background: 'bg',
|
||||||
|
elements: [
|
||||||
|
{ id: 'it1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '图文标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'it2', type: 'image', x: 8, y: 26, w: 42, h: 60, content: '', style: { fit: 'cover', anim: 'fade-up' } },
|
||||||
|
{ id: 'it3', type: 'list', x: 56, y: 28, w: 38, h: 55, content: '要点一:具体说明\n要点二:具体说明\n要点三:具体说明', style: { fontSize: 26, color: 'text', align: 'left', anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-timeline', name: '时间线', category: 'built-in', group: 'content', background: 'bg',
|
||||||
|
elements: [
|
||||||
|
{ id: 'tl1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '发展历程', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'tl2', type: 'shape', x: 10, y: 52, w: 80, h: 0.6, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, opacity: 0.6, anim: 'fade' } },
|
||||||
|
{ id: 'tl3', type: 'shape', x: 18, y: 48.5, w: 2.4, h: 2.4, content: '', style: { shapeType: 'circle', fill: 'primary', anim: 'pop' } },
|
||||||
|
{ id: 'tl4', type: 'shape', x: 48, y: 48.5, w: 2.4, h: 2.4, content: '', style: { shapeType: 'circle', fill: 'primary', anim: 'pop' } },
|
||||||
|
{ id: 'tl5', type: 'shape', x: 78, y: 48.5, w: 2.4, h: 2.4, content: '', style: { shapeType: 'circle', fill: 'primary', anim: 'pop' } },
|
||||||
|
{ id: 'tl6', type: 'stat', x: 8, y: 26, w: 22, h: 20, content: '2023', style: { fontSize: 40, color: 'primary', label: '里程碑说明', labelColor: 'muted', labelSize: 16, anim: 'fade-up' } },
|
||||||
|
{ id: 'tl7', type: 'stat', x: 38, y: 26, w: 22, h: 20, content: '2024', style: { fontSize: 40, color: 'primary', label: '里程碑说明', labelColor: 'muted', labelSize: 16, anim: 'fade-up' } },
|
||||||
|
{ id: 'tl8', type: 'stat', x: 68, y: 26, w: 22, h: 20, content: '2025', style: { fontSize: 40, color: 'accent', label: '里程碑说明', labelColor: 'muted', labelSize: 16, anim: 'fade-up' } },
|
||||||
|
{ id: 'tl9', type: 'text', x: 8, y: 60, w: 22, h: 25, content: '这一年的关键事件与成果描述', style: { fontSize: 20, color: 'muted', align: 'center', anim: 'fade-up' } },
|
||||||
|
{ id: 'tl10', type: 'text', x: 38, y: 60, w: 22, h: 25, content: '这一年的关键事件与成果描述', style: { fontSize: 20, color: 'muted', align: 'center', anim: 'fade-up' } },
|
||||||
|
{ id: 'tl11', type: 'text', x: 68, y: 60, w: 22, h: 25, content: '这一年的关键事件与成果描述', style: { fontSize: 20, color: 'muted', align: 'center', anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-steps', name: '步骤流程', category: 'built-in', group: 'content', background: 'bg',
|
||||||
|
elements: [
|
||||||
|
{ id: 'st1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '实施步骤', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'st2', type: 'stat', x: 8, y: 30, w: 18, h: 26, content: '1', style: { fontSize: 64, color: 'primary', label: '第一步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
|
||||||
|
{ id: 'st3', type: 'text', x: 8, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } },
|
||||||
|
{ id: 'st4', type: 'shape', x: 27.5, y: 40, w: 5, h: 0.8, content: '', style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' } },
|
||||||
|
{ id: 'st5', type: 'stat', x: 33, y: 30, w: 18, h: 26, content: '2', style: { fontSize: 64, color: 'primary', label: '第二步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
|
||||||
|
{ id: 'st6', type: 'text', x: 33, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } },
|
||||||
|
{ id: 'st7', type: 'shape', x: 52.5, y: 40, w: 5, h: 0.8, content: '', style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' } },
|
||||||
|
{ id: 'st8', type: 'stat', x: 58, y: 30, w: 18, h: 26, content: '3', style: { fontSize: 64, color: 'primary', label: '第三步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
|
||||||
|
{ id: 'st9', type: 'text', x: 58, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } },
|
||||||
|
{ id: 'st10', type: 'shape', x: 77.5, y: 40, w: 5, h: 0.8, content: '', style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' } },
|
||||||
|
{ id: 'st11', type: 'stat', x: 83, y: 30, w: 18, h: 26, content: '4', style: { fontSize: 64, color: 'accent', label: '第四步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
|
||||||
|
{ id: 'st12', type: 'text', x: 83, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ===== 数据 ===== */
|
||||||
|
{
|
||||||
|
id: 'tpl-data', name: '数据页', category: 'built-in', group: 'data', background: 'panel',
|
||||||
|
elements: [
|
||||||
|
{ id: 'd1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '数据标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'd2', type: 'stat', x: 8, y: 28, w: 28, h: 30, content: '65%', style: { fontSize: 76, color: 'primary', label: '说明', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
||||||
|
{ id: 'd3', type: 'chart', x: 42, y: 26, w: 52, h: 40, content: '[{"label":"A","value":65},{"label":"B","value":45},{"label":"C","value":30}]', style: { color: 'primary', max: 100, chartType: 'bar', anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-data3', name: '三数据页', category: 'built-in', group: 'data', background: 'bg',
|
||||||
|
elements: [
|
||||||
|
{ id: 'dd1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '关键数据', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'dd2', type: 'stat', x: 8, y: 30, w: 27, h: 30, content: '2.4x', style: { fontSize: 68, color: 'primary', label: '指标说明一', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
||||||
|
{ id: 'dd3', type: 'stat', x: 37, y: 30, w: 27, h: 30, content: '87%', style: { fontSize: 68, color: 'accent', label: '指标说明二', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
||||||
|
{ id: 'dd4', type: 'stat', x: 66, y: 30, w: 27, h: 30, content: '120k', style: { fontSize: 68, color: 'primary', label: '指标说明三', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
||||||
|
{ id: 'dd5', type: 'chart', x: 15, y: 62, w: 70, h: 30, content: '{"series":["本季","上季"],"items":[{"label":"华东","values":[120,95]},{"label":"华南","values":[100,88]},{"label":"华北","values":[86,70]}]}', style: { color: 'primary', chartType: 'bar', legend: true, grid: true, anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ===== 收尾 ===== */
|
||||||
|
{
|
||||||
|
id: 'tpl-quote', name: '金句页', category: 'built-in', group: 'ending', background: 'g-deep',
|
||||||
elements: [
|
elements: [
|
||||||
{ id: 'q1', type: 'shape', x: 8, y: 24, w: 4, h: 52, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'slide-l' } },
|
{ id: 'q1', type: 'shape', x: 8, y: 24, w: 4, h: 52, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'slide-l' } },
|
||||||
{ id: 'q2', type: 'quote', x: 16, y: 28, w: 76, h: 34, content: '金句内容', style: { fontSize: 48, color: 'text', italic: true, align: 'left', anim: 'scale' } },
|
{ id: 'q2', type: 'quote', x: 16, y: 28, w: 76, h: 34, content: '金句内容', style: { fontSize: 48, color: 'text', italic: true, align: 'left', anim: 'scale' } },
|
||||||
@@ -62,11 +146,22 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-data', name: '数据页', category: 'built-in', background: 'panel',
|
id: 'tpl-team', name: '团队页', category: 'built-in', group: 'ending', background: 'bg',
|
||||||
elements: [
|
elements: [
|
||||||
{ id: 'd1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '数据标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
{ id: 'tm1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '核心团队', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
{ id: 'd2', type: 'stat', x: 8, y: 28, w: 28, h: 30, content: '65%', style: { fontSize: 76, color: 'primary', label: '说明', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
{ id: 'tm2', type: 'card', x: 8, y: 28, w: 27, h: 55, content: '姓名\n职位\n一句话介绍', style: { accent: 'primary', icon: '', anim: 'fade-up' } },
|
||||||
{ id: 'd3', type: 'chart', x: 42, y: 26, w: 52, h: 40, content: '[{"label":"A","value":65},{"label":"B","value":45},{"label":"C","value":30}]', style: { color: 'primary', max: 100, chartType: 'bar', anim: 'fade-up' } }
|
{ id: 'tm3', type: 'card', x: 37, y: 28, w: 27, h: 55, content: '姓名\n职位\n一句话介绍', style: { accent: 'accent', icon: '', anim: 'fade-up' } },
|
||||||
|
{ id: 'tm4', type: 'card', x: 66, y: 28, w: 27, h: 55, content: '姓名\n职位\n一句话介绍', style: { accent: 'primary', icon: '', anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-thanks', name: '感谢页', category: 'built-in', group: 'ending', background: 'g-primary',
|
||||||
|
elements: [
|
||||||
|
{ id: 'th1', type: 'shape', x: 55, y: -25, w: 65, h: 100, content: '', style: { shapeType: 'circle', fill: 'accent', gradient: true, opacity: 0.3, anim: 'scale' } },
|
||||||
|
{ id: 'th2', type: 'shape', x: 0, y: 0, w: 6, h: 100, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, anim: 'slide-l' } },
|
||||||
|
{ id: 'th3', type: 'title', x: 10, y: 34, w: 70, h: 18, content: '感谢观看', style: { fontSize: 62, color: 'text', align: 'left', anim: 'pop' } },
|
||||||
|
{ id: 'th4', type: 'text', x: 10, y: 56, w: 60, h: 10, content: '欢迎交流与讨论', style: { fontSize: 26, color: 'muted', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'th5', type: 'text', x: 10, y: 82, w: 50, h: 7, content: '联系方式 / 二维码占位', style: { fontSize: 18, color: 'muted', align: 'left', anim: 'fade' } }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -87,11 +182,14 @@ const state = reactive<{
|
|||||||
deck: Deck
|
deck: Deck
|
||||||
currentIndex: number
|
currentIndex: number
|
||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
|
/** 多选集(框选/Shift加选时非空;selectedId 始终为锚点元素) */
|
||||||
|
selectedIds: string[]
|
||||||
activeLibId: string | null
|
activeLibId: string | null
|
||||||
}>({
|
}>({
|
||||||
deck: JSON.parse(JSON.stringify(SAMPLE_DECK)),
|
deck: JSON.parse(JSON.stringify(SAMPLE_DECK)),
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
|
selectedIds: [],
|
||||||
activeLibId: null
|
activeLibId: null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -119,9 +217,21 @@ function scheduleSave() {
|
|||||||
if (saveTimer) clearTimeout(saveTimer)
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
saveTimer = setTimeout(() => {
|
saveTimer = setTimeout(() => {
|
||||||
safeSet(LS_KEY, JSON.stringify(state.deck))
|
safeSet(LS_KEY, JSON.stringify(state.deck))
|
||||||
|
syncActiveLibItem()
|
||||||
}, SAVE_DELAY)
|
}, SAVE_DELAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 自动暂存:deck 有活跃文库条目时,改动自动回写文库(不点保存也不丢,刷新/首页重开均为最新) */
|
||||||
|
function syncActiveLibItem() {
|
||||||
|
if (!state.activeLibId) return
|
||||||
|
const lib = getLibrary()
|
||||||
|
const item = lib.find(x => x.id === state.activeLibId)
|
||||||
|
if (!item) return
|
||||||
|
item.deck = clone(state.deck)
|
||||||
|
item.updatedAt = Date.now()
|
||||||
|
setLibrary(lib)
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 颜色/背景解析(纯函数,供组件调用) ---------- */
|
/* ---------- 颜色/背景解析(纯函数,供组件调用) ---------- */
|
||||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||||
const c = String(hex).replace('#', '')
|
const c = String(hex).replace('#', '')
|
||||||
@@ -194,7 +304,7 @@ export function resolveBg(key: string): string {
|
|||||||
/* ---------- 内部:执行 Op 并推入历史 ---------- */
|
/* ---------- 内部:执行 Op 并推入历史 ---------- */
|
||||||
|
|
||||||
/** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */
|
/** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */
|
||||||
function execOp(op: Op, opts?: { coalesceKey?: string }): void {
|
function execOp(op: Op, opts?: { coalesceKey?: string; group?: string }): void {
|
||||||
if (suppressHistory) {
|
if (suppressHistory) {
|
||||||
state.deck = applyOp(state.deck, op)
|
state.deck = applyOp(state.deck, op)
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
@@ -214,7 +324,7 @@ function execOp(op: Op, opts?: { coalesceKey?: string }): void {
|
|||||||
// 执行正向
|
// 执行正向
|
||||||
state.deck = applyOp(state.deck, op)
|
state.deck = applyOp(state.deck, op)
|
||||||
// 推入历史
|
// 推入历史
|
||||||
const entry: HistoryEntry = { forward: op, backward }
|
const entry: HistoryEntry = { forward: op, backward, group: opts?.group }
|
||||||
hist.push(entry)
|
hist.push(entry)
|
||||||
if (hist.length > HIST_MAX) hist.shift()
|
if (hist.length > HIST_MAX) hist.shift()
|
||||||
future.length = 0
|
future.length = 0
|
||||||
@@ -244,6 +354,18 @@ function migrateDeck(d: any): Deck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 初始化 ---------- */
|
/* ---------- 初始化 ---------- */
|
||||||
|
|
||||||
|
/** 工作区 deck 是否有用户内容(区别于示例/空白):非示例且有元素,或页数>1 */
|
||||||
|
function hasWorkDeck(): boolean {
|
||||||
|
const d = state.deck
|
||||||
|
if (!d || !d.slides?.length) return false
|
||||||
|
const sampleTitle = SAMPLE_DECK.slides[0]?.elements.find(e => e.type === 'title')?.content
|
||||||
|
const firstTitle = d.slides[0]?.elements.find(e => e.type === 'title')?.content
|
||||||
|
const isSample = d.slides.length === SAMPLE_DECK.slides.length && sampleTitle && firstTitle === sampleTitle
|
||||||
|
if (isSample) return false
|
||||||
|
return d.slides.length > 1 || d.slides.some(s => s.elements.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
let loaded: Deck | null = null
|
let loaded: Deck | null = null
|
||||||
try { loaded = JSON.parse(localStorage.getItem(LS_KEY) || 'null') } catch (e) { /* ignore */ }
|
try { loaded = JSON.parse(localStorage.getItem(LS_KEY) || 'null') } catch (e) { /* ignore */ }
|
||||||
@@ -300,10 +422,63 @@ function setCurrentIndex(i: number) {
|
|||||||
i = Math.max(0, Math.min(state.deck.slides.length - 1, i))
|
i = Math.max(0, Math.min(state.deck.slides.length - 1, i))
|
||||||
if (i === state.currentIndex) return
|
if (i === state.currentIndex) return
|
||||||
state.currentIndex = i
|
state.currentIndex = i
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
}
|
}
|
||||||
function selectElement(id: string | null) { state.selectedId = id || null }
|
function selectElement(id: string | null) { state.selectedId = id || null; state.selectedIds = [] }
|
||||||
|
|
||||||
|
/* ---------- 多选(框选 / Shift 加选 / Ctrl+A) ---------- */
|
||||||
|
const selectedIds = computed(() => state.selectedIds)
|
||||||
|
|
||||||
|
/** 有效多选集:过滤掉已不存在的元素,非空(length>1)时表示多选态 */
|
||||||
|
function getSelection(): string[] {
|
||||||
|
const s = currentSlide.value
|
||||||
|
if (!s || !state.selectedIds.length) return []
|
||||||
|
return state.selectedIds.filter(id => s.elements.some(e => e.id === id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMany(ids: string[]) {
|
||||||
|
state.selectedIds = [...ids]
|
||||||
|
state.selectedId = ids.length ? ids[ids.length - 1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shift+点击:在多选集中加入/移除某元素 */
|
||||||
|
function toggleSelect(id: string) {
|
||||||
|
const cur = [...state.selectedIds]
|
||||||
|
const i = cur.indexOf(id)
|
||||||
|
if (i >= 0) cur.splice(i, 1)
|
||||||
|
else cur.push(id)
|
||||||
|
state.selectedIds = cur
|
||||||
|
state.selectedId = cur.length ? cur[cur.length - 1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllElements() {
|
||||||
|
const s = currentSlide.value
|
||||||
|
if (!s) return
|
||||||
|
state.selectedIds = s.elements.map(e => e.id)
|
||||||
|
state.selectedId = state.selectedIds.length ? state.selectedIds[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多元素整体位移(框选拖动);每个元素独立 op 但同批 undo */
|
||||||
|
function moveElementsBy(ids: string[], dx: number, dy: number) {
|
||||||
|
const group = beginBatch()
|
||||||
|
for (const id of ids) {
|
||||||
|
const el = findElement(id)
|
||||||
|
if (!el) continue
|
||||||
|
const x = Math.max(0, Math.min(100 - el.w, el.x + dx))
|
||||||
|
const y = Math.max(0, Math.min(100 - el.h, el.y + dy))
|
||||||
|
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: id, patch: { x, y }, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除(同批 undo) */
|
||||||
|
function delElements(ids: string[]) {
|
||||||
|
const group = beginBatch()
|
||||||
|
for (const id of ids) {
|
||||||
|
if (findElement(id)) delElement(id)
|
||||||
|
}
|
||||||
|
void group
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 主题 ---------- */
|
/* ---------- 主题 ---------- */
|
||||||
function setTheme(name: string) {
|
function setTheme(name: string) {
|
||||||
@@ -317,7 +492,7 @@ function addSlide(atIndex?: number) {
|
|||||||
const idx = atIndex != null ? atIndex + 1 : state.deck.slides.length
|
const idx = atIndex != null ? atIndex + 1 : state.deck.slides.length
|
||||||
execOp({ type: 'add_slide', atIndex: idx, slide: s, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'add_slide', atIndex: idx, slide: s, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.currentIndex = idx
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +503,7 @@ function dupSlide() {
|
|||||||
const idx = state.currentIndex + 1
|
const idx = state.currentIndex + 1
|
||||||
execOp({ type: 'add_slide', atIndex: idx, slide: copy, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'add_slide', atIndex: idx, slide: copy, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.currentIndex = idx
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function delSlide(i?: number): boolean {
|
function delSlide(i?: number): boolean {
|
||||||
@@ -337,7 +512,7 @@ function delSlide(i?: number): boolean {
|
|||||||
const deletedSlide = clone(state.deck.slides[idx])
|
const deletedSlide = clone(state.deck.slides[idx])
|
||||||
execOp({ type: 'del_slide', index: idx, deletedSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'del_slide', index: idx, deletedSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
if (state.currentIndex >= state.deck.slides.length) state.currentIndex = state.deck.slides.length - 1
|
if (state.currentIndex >= state.deck.slides.length) state.currentIndex = state.deck.slides.length - 1
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,9 +528,9 @@ function setSlideBackground(bg: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 元素 CRUD ---------- */
|
/* ---------- 元素 CRUD ---------- */
|
||||||
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1]) {
|
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1], group?: string) {
|
||||||
const el = createElement(type, over)
|
const el = createElement(type, over)
|
||||||
execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
|
||||||
state.selectedId = el.id
|
state.selectedId = el.id
|
||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
@@ -369,37 +544,124 @@ function delElement(id: string) {
|
|||||||
const el = findElement(id)
|
const el = findElement(id)
|
||||||
const deletedElement = el ? clone(el) : undefined
|
const deletedElement = el ? clone(el) : undefined
|
||||||
execOp({ type: 'del_element', slideIdx: state.currentIndex, elementId: id, deletedElement, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'del_element', slideIdx: state.currentIndex, elementId: id, deletedElement, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
if (state.selectedId === id) state.selectedId = null
|
if (state.selectedId === id) state.selectedId = null; state.selectedIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveElementZ(id: string, dir: number) {
|
function moveElementZ(id: string, dir: number) {
|
||||||
execOp({ type: 'move_element_z', slideIdx: state.currentIndex, elementId: id, dir, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'move_element_z', slideIdx: state.currentIndex, elementId: id, dir, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 元素剪贴板(内存级,跨页可用,不持久化;支持多元素) ---------- */
|
||||||
|
let clipboard: SlideElement[] = []
|
||||||
|
|
||||||
|
function copyElements(ids: string[]): boolean {
|
||||||
|
const els = ids.map(findElement).filter(Boolean) as SlideElement[]
|
||||||
|
if (!els.length) return false
|
||||||
|
clipboard = clone(els)
|
||||||
|
pasteCount = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyElement(id: string): boolean {
|
||||||
|
return copyElements([id])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 剪切 = 复制 + 删除(删除走 op,可撤销;撤销后剪贴板仍保留内容) */
|
||||||
|
function cutElements(ids: string[]): boolean {
|
||||||
|
if (!copyElements(ids)) return false
|
||||||
|
delElements(ids)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cutElement(id: string): boolean {
|
||||||
|
return cutElements([id])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 粘贴到当前页:新 id、位置阶梯偏移错开原位;连续粘贴合并为一条 undo */
|
||||||
|
let pasteCount = 0
|
||||||
|
let pasteTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let pasteGroup: string | null = null
|
||||||
|
|
||||||
|
function pasteElements(): boolean {
|
||||||
|
if (!clipboard.length) return false
|
||||||
|
pasteCount++
|
||||||
|
const off = (pasteCount - 1) * 3
|
||||||
|
// 连续粘贴(2s 内)共用同一 group → 一次 Ctrl+Z 整批撤销
|
||||||
|
if (!pasteTimer) pasteGroup = beginBatch()
|
||||||
|
else { clearTimeout(pasteTimer) }
|
||||||
|
pasteTimer = setTimeout(() => { pasteTimer = null; pasteGroup = null }, 2000)
|
||||||
|
|
||||||
|
const newIds: string[] = []
|
||||||
|
for (const src of clipboard) {
|
||||||
|
const el = createElement(src.type, {
|
||||||
|
...src,
|
||||||
|
id: uid('el'),
|
||||||
|
x: Math.min(97, Math.max(0, src.x + off)),
|
||||||
|
y: Math.min(97, Math.max(0, src.y + off)),
|
||||||
|
content: src.content,
|
||||||
|
segments: src.segments ? clone(src.segments) : undefined,
|
||||||
|
style: { ...src.style }
|
||||||
|
})
|
||||||
|
execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() }, { group: pasteGroup! })
|
||||||
|
newIds.push(el.id)
|
||||||
|
}
|
||||||
|
state.selectedIds = newIds
|
||||||
|
state.selectedId = newIds.length ? newIds[newIds.length - 1] : null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteElement(): boolean {
|
||||||
|
return pasteElements()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasClipboard(): boolean { return clipboard.length > 0 }
|
||||||
|
|
||||||
function findElement(id: string): SlideElement | null {
|
function findElement(id: string): SlideElement | null {
|
||||||
const s = currentSlide.value
|
const s = currentSlide.value
|
||||||
return s.elements.find(e => e.id === id) || null
|
return s.elements.find(e => e.id === id) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- undo / redo(基于 Op 回滚) ---------- */
|
/* ---------- undo / redo(基于 Op 回滚,同 group 批次整体回滚) ---------- */
|
||||||
|
let batchCounter = 0
|
||||||
|
|
||||||
|
/** 开始一个操作批次:期间所有 execOp 记录同 group 标记,undo 时整组回滚 */
|
||||||
|
function beginBatch(): string {
|
||||||
|
return 'batch-' + (++batchCounter) + '-' + Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
function undo(): boolean {
|
function undo(): boolean {
|
||||||
if (!hist.length) return false
|
if (!hist.length) return false
|
||||||
const entry = hist.pop()!
|
// 弹出栈顶;若带 group 标记,同组的全部一起弹出(整批回滚)
|
||||||
|
const popped: HistoryEntry[] = []
|
||||||
|
do {
|
||||||
|
popped.push(hist.pop()!)
|
||||||
|
} while (hist.length > 0 && hist[hist.length - 1].group != null && hist[hist.length - 1].group === popped[0].group)
|
||||||
suppressHistory = true
|
suppressHistory = true
|
||||||
state.deck = applyOp(state.deck, entry.backward)
|
// popped 按执行顺序倒排(栈顶=最后执行在最前),撤回时后执行的先撤
|
||||||
|
for (const e of popped) {
|
||||||
|
state.deck = applyOp(state.deck, e.backward)
|
||||||
|
}
|
||||||
suppressHistory = false
|
suppressHistory = false
|
||||||
future.push(entry)
|
for (let i = popped.length - 1; i >= 0; i--) future.push(popped[i])
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function redo(): boolean {
|
function redo(): boolean {
|
||||||
if (!future.length) return false
|
if (!future.length) return false
|
||||||
const entry = future.pop()!
|
// 同组的一起重做
|
||||||
|
const popped: HistoryEntry[] = []
|
||||||
|
const headGroup = future[future.length - 1].group
|
||||||
|
do {
|
||||||
|
popped.push(future.pop()!)
|
||||||
|
} while (future.length > 0 && headGroup != null && future[future.length - 1].group === headGroup)
|
||||||
suppressHistory = true
|
suppressHistory = true
|
||||||
state.deck = applyOp(state.deck, entry.forward)
|
// popped[0] 是最先执行的(undo 时最后压入 future),重做按原顺序:倒序遍历
|
||||||
|
for (let i = popped.length - 1; i >= 0; i--) {
|
||||||
|
state.deck = applyOp(state.deck, popped[i].forward)
|
||||||
|
}
|
||||||
suppressHistory = false
|
suppressHistory = false
|
||||||
hist.push(entry)
|
for (let i = popped.length - 1; i >= 0; i--) hist.push(popped[i])
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -430,7 +692,7 @@ function replaceDeck(newDeck: Deck | Record<string, unknown>, opts?: { keepHisto
|
|||||||
state.activeLibId = null
|
state.activeLibId = null
|
||||||
state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1)
|
state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1)
|
||||||
if (state.currentIndex < 0) state.currentIndex = 0
|
if (state.currentIndex < 0) state.currentIndex = 0
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceSlide(index: number, newSlide: Slide) {
|
function replaceSlide(index: number, newSlide: Slide) {
|
||||||
@@ -438,17 +700,17 @@ function replaceSlide(index: number, newSlide: Slide) {
|
|||||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||||
const oldSlide = state.deck.slides[index] ? clone(state.deck.slides[index]) : undefined
|
const oldSlide = state.deck.slides[index] ? clone(state.deck.slides[index]) : undefined
|
||||||
execOp({ type: 'replace_slide', index, slide: newSlide, oldSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'replace_slide', index, slide: newSlide, oldSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendSlide(newSlide: Slide) {
|
function appendSlide(newSlide: Slide, group?: string) {
|
||||||
newSlide.id = uid('s')
|
newSlide.id = uid('s')
|
||||||
newSlide.elements = newSlide.elements || []
|
newSlide.elements = newSlide.elements || []
|
||||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||||
const idx = state.deck.slides.length
|
const idx = state.deck.slides.length
|
||||||
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
|
||||||
state.currentIndex = idx
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertSlideAt(index: number, newSlide: Slide) {
|
function insertSlideAt(index: number, newSlide: Slide) {
|
||||||
@@ -457,7 +719,7 @@ function insertSlideAt(index: number, newSlide: Slide) {
|
|||||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||||
execOp({ type: 'add_slide', atIndex: index, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'add_slide', atIndex: index, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.currentIndex = index
|
state.currentIndex = index
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@@ -577,7 +839,7 @@ function addSlideFromTemplate(tplId: string): boolean {
|
|||||||
const idx = state.currentIndex + 1
|
const idx = state.currentIndex + 1
|
||||||
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.currentIndex = idx
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,9 +941,15 @@ function getChat(chatId: string): ChatMessage[] {
|
|||||||
try { return JSON.parse(localStorage.getItem(CHAT_PREFIX + chatId) || '[]') || [] } catch (e) { return [] }
|
try { return JSON.parse(localStorage.getItem(CHAT_PREFIX + chatId) || '[]') || [] } catch (e) { return [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 写入某个 chatId 的会话 */
|
/** 写入某个 chatId 的会话(超 1.5MB 时从最旧开始丢弃,防写满 localStorage) */
|
||||||
function setChat(chatId: string, msgs: ChatMessage[]) {
|
function setChat(chatId: string, msgs: ChatMessage[]) {
|
||||||
safeSet(CHAT_PREFIX + chatId, JSON.stringify(msgs))
|
let list = msgs
|
||||||
|
while (list.length > 2) {
|
||||||
|
const size = JSON.stringify(list).length
|
||||||
|
if (size <= 1_500_000) break
|
||||||
|
list = list.slice(2) // 成对丢弃(user+assistant)
|
||||||
|
}
|
||||||
|
safeSet(CHAT_PREFIX + chatId, JSON.stringify(list))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清除某个 chatId 的会话 */
|
/** 清除某个 chatId 的会话 */
|
||||||
@@ -723,8 +991,13 @@ export const store = {
|
|||||||
addSlide, dupSlide, delSlide, moveSlide, setSlideBackground,
|
addSlide, dupSlide, delSlide, moveSlide, setSlideBackground,
|
||||||
// 元素 CRUD
|
// 元素 CRUD
|
||||||
addElement, updateElement, delElement, moveElementZ, findElement,
|
addElement, updateElement, delElement, moveElementZ, findElement,
|
||||||
|
// 元素剪贴板
|
||||||
|
copyElement, copyElements, cutElement, cutElements, pasteElement, pasteElements, hasClipboard,
|
||||||
|
// 多选
|
||||||
|
selectedIds, getSelection, selectMany, toggleSelect, selectAllElements,
|
||||||
|
moveElementsBy, delElements,
|
||||||
// undo/redo
|
// undo/redo
|
||||||
undo, redo,
|
undo, redo, beginBatch,
|
||||||
// 整体替换
|
// 整体替换
|
||||||
replaceDeck, replaceSlide, appendSlide, insertSlideAt,
|
replaceDeck, replaceSlide, appendSlide, insertSlideAt,
|
||||||
reset, exportJSON, importJSON,
|
reset, exportJSON, importJSON,
|
||||||
@@ -739,6 +1012,8 @@ export const store = {
|
|||||||
getTemplates, getUserTemplates, saveCurrentAsTemplate, addSlideFromTemplate, deleteTemplate,
|
getTemplates, getUserTemplates, saveCurrentAsTemplate, addSlideFromTemplate, deleteTemplate,
|
||||||
// 会话管理
|
// 会话管理
|
||||||
getChatId, getChat, setChat, clearChat, newChatId,
|
getChatId, getChat, setChat, clearChat, newChatId,
|
||||||
|
// 暂存检测
|
||||||
|
hasWorkDeck,
|
||||||
// 备份恢复
|
// 备份恢复
|
||||||
recoverBackup,
|
recoverBackup,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ export interface PageTemplate {
|
|||||||
name: string
|
name: string
|
||||||
/** 模板分类:built-in / user */
|
/** 模板分类:built-in / user */
|
||||||
category: 'built-in' | 'user'
|
category: 'built-in' | 'user'
|
||||||
|
/** 展示分组:basic 基础 / content 内容 / data 数据 / ending 收尾(缺省按内容归组) */
|
||||||
|
group?: 'basic' | 'content' | 'data' | 'ending'
|
||||||
background: BgKey
|
background: BgKey
|
||||||
/** 元素结构(内容可为占位符如「标题」「正文」) */
|
/** 元素结构(内容可为占位符如「标题」「正文」) */
|
||||||
elements: SlideElement[]
|
elements: SlideElement[]
|
||||||
|
|||||||
@@ -108,6 +108,16 @@
|
|||||||
.el-stat .label { margin-top: .35em; text-align: center; }
|
.el-stat .label { margin-top: .35em; text-align: center; }
|
||||||
.el-shape { width: 100%; height: 100%; }
|
.el-shape { width: 100%; height: 100%; }
|
||||||
.el-image { width: 100%; height: 100%; object-fit: cover; }
|
.el-image { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
/* 空图片元素占位(未填 content 时不渲染裂图) */
|
||||||
|
.el-image-empty {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
border: 2px dashed rgba(100, 116, 139, .45); border-radius: 10px;
|
||||||
|
background: rgba(148, 163, 184, .08); color: var(--ui-muted, #64748b);
|
||||||
|
text-align: center; padding: 8px;
|
||||||
|
}
|
||||||
|
.el-image-empty-icon { font-size: 28px; opacity: .7; }
|
||||||
|
.el-image-empty-text { font-size: 12px; line-height: 1.5; }
|
||||||
.el-chart { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; gap: .5em; }
|
.el-chart { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; gap: .5em; }
|
||||||
.el-chart .bar-row { display: flex; align-items: center; gap: .6em; font-size: 14px; }
|
.el-chart .bar-row { display: flex; align-items: center; gap: .6em; font-size: 14px; }
|
||||||
.el-chart .bar-label { width: 26%; flex-shrink: 0; }
|
.el-chart .bar-label { width: 26%; flex-shrink: 0; }
|
||||||
@@ -182,6 +192,17 @@
|
|||||||
/* 选中态与手柄 */
|
/* 选中态与手柄 */
|
||||||
.el.selected { outline: 2px solid var(--ui-primary); outline-offset: 0; }
|
.el.selected { outline: 2px solid var(--ui-primary); outline-offset: 0; }
|
||||||
.el.dragging { opacity: .85; }
|
.el.dragging { opacity: .85; }
|
||||||
|
/* 多选态:次级高亮(锚点仍用 .selected 实线) */
|
||||||
|
.el.multi-selected { outline: 1.5px dashed color-mix(in srgb, var(--ui-primary) 70%, transparent); outline-offset: 1px; }
|
||||||
|
|
||||||
|
/* 框选矩形 */
|
||||||
|
.marquee-box {
|
||||||
|
position: absolute;
|
||||||
|
border: 1.5px dashed var(--ui-primary);
|
||||||
|
background: color-mix(in srgb, var(--ui-primary) 8%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
.handle {
|
.handle {
|
||||||
position: absolute; width: 10px; height: 10px;
|
position: absolute; width: 10px; height: 10px;
|
||||||
background: #fff; border: 1.5px solid var(--ui-primary);
|
background: #fff; border: 1.5px solid var(--ui-primary);
|
||||||
|
|||||||
Reference in New Issue
Block a user