Skip to content

Commit 18dc8ff

Browse files
authored
feat(css-editor): add batch operations with right-click context menu (doocs#1497)
- Add context menu for CSS scheme tabs with rename, export, delete, and multi-select options - Implement selection mode with checkboxes and bottom action bar - Support batch export (single as .css, multiple as .zip) and batch delete - Add isSelectMode and selectedIds state to CssContentConfig
1 parent ea240b4 commit 18dc8ff

2 files changed

Lines changed: 328 additions & 21 deletions

File tree

apps/web/src/components/editor/CssEditor.vue

Lines changed: 225 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
22
import { exportMergedTheme } from '@md/core'
33
import { themeMap, themeOptionsMap } from '@md/shared'
4-
import { Download, Edit3, Ellipsis, Eye, Plus, X } from 'lucide-vue-next'
4+
import { Check, CheckSquare, Download, Edit3, Ellipsis, Eye, Plus, X } from 'lucide-vue-next'
55
import { useCssEditorStore } from '@/stores/cssEditor'
66
import { useEditorStore } from '@/stores/editor'
77
import { useRenderStore } from '@/stores/render'
@@ -16,7 +16,7 @@ const editorStore = useEditorStore()
1616
const themeStore = useThemeStore()
1717
1818
const { isMobile } = storeToRefs(uiStore)
19-
const { cssContentConfig } = storeToRefs(cssEditorStore)
19+
const { cssContentConfig, isSelectMode, selectedIds } = storeToRefs(cssEditorStore)
2020
2121
// 控制是否启用动画
2222
const enableAnimation = ref(false)
@@ -176,11 +176,89 @@ function addHandler() {
176176
isOpenAddDialog.value = true
177177
}
178178
179-
// 查看内置主题功能
180179
const isOpenViewThemeDialog = ref(false)
181180
const selectedViewTheme = ref<'default' | 'grace' | 'simple'>('default')
182181
183-
// 打开查看内置主题对话框
182+
const contextMenuTargetId = ref<string | null>(null)
183+
const showContextMenu = ref(false)
184+
const contextMenuPos = ref({ x: 0, y: 0 })
185+
186+
function onContextMenu(e: MouseEvent, tabId: string) {
187+
e.preventDefault()
188+
e.stopPropagation()
189+
contextMenuTargetId.value = tabId
190+
contextMenuPos.value = { x: e.clientX, y: e.clientY }
191+
showContextMenu.value = true
192+
}
193+
194+
function closeContextMenu() {
195+
showContextMenu.value = false
196+
contextMenuTargetId.value = null
197+
}
198+
199+
function contextMenuRename() {
200+
const tab = cssContentConfig.value.tabs.find(t => t.id === contextMenuTargetId.value)
201+
if (tab) {
202+
rename(tab.name)
203+
}
204+
closeContextMenu()
205+
}
206+
207+
function contextMenuExport() {
208+
if (contextMenuTargetId.value) {
209+
cssEditorStore.exportSingleTab(contextMenuTargetId.value)
210+
}
211+
closeContextMenu()
212+
}
213+
214+
function contextMenuDelete() {
215+
if (contextMenuTargetId.value) {
216+
removeHandler(contextMenuTargetId.value)
217+
}
218+
closeContextMenu()
219+
}
220+
221+
function enterSelectModeFromContextMenu() {
222+
if (contextMenuTargetId.value) {
223+
cssEditorStore.toggleSelectMode()
224+
cssEditorStore.toggleSelectTab(contextMenuTargetId.value)
225+
}
226+
closeContextMenu()
227+
}
228+
229+
const allSelected = computed(
230+
() => cssContentConfig.value.tabs.length > 0 && selectedIds.value.length === cssContentConfig.value.tabs.length,
231+
)
232+
233+
const isOpenBatchDelConfirmDialog = ref(false)
234+
235+
const batchDelConfirmText = computed(() => {
236+
const n = selectedIds.value.length
237+
if (n === 1) {
238+
const tab = cssContentConfig.value.tabs.find(t => t.id === selectedIds.value[0])
239+
return `此操作将删除「${tab?.title ?? ``}」,是否继续?`
240+
}
241+
return `此操作将删除已选的 ${n} 个方案,是否继续?`
242+
})
243+
244+
function confirmBatchDelete() {
245+
cssEditorStore.batchDeleteTabs()
246+
isOpenBatchDelConfirmDialog.value = false
247+
}
248+
249+
function handleTabClick(tabId: string) {
250+
if (isSelectMode.value) {
251+
cssEditorStore.toggleSelectTab(tabId)
252+
}
253+
else {
254+
tabChanged(tabId)
255+
}
256+
}
257+
258+
function exitSelectMode() {
259+
cssEditorStore.toggleSelectMode()
260+
}
261+
184262
function openViewThemeDialog() {
185263
selectedViewTheme.value = 'default'
186264
isOpenViewThemeDialog.value = true
@@ -229,6 +307,13 @@ onMounted(() => {
229307
230308
// 初始化时滚动到活跃的 tab
231309
scrollToActiveTab()
310+
311+
// 点击外部关闭右键菜单
312+
document.addEventListener('click', closeContextMenu)
313+
})
314+
315+
onUnmounted(() => {
316+
document.removeEventListener('click', closeContextMenu)
232317
})
233318
234319
// 导出合并后的主题
@@ -279,17 +364,25 @@ function exportCurrentTheme() {
279364
<!-- Tab 栏 + 工具栏合并 -->
280365
<div class="flex items-center h-9 px-2 shrink-0 border-b border-border">
281366
<div class="flex-1 flex items-center gap-0 overflow-x-auto custom-scrollbar min-w-0 h-full">
282-
<button
367+
<div
283368
v-for="item in cssContentConfig.tabs"
284-
:key="item.name"
369+
:key="item.id"
285370
class="group/tab relative flex items-center gap-1.5 shrink-0 h-full px-3 text-xs transition-colors duration-150"
286-
:class="{
287-
'css-tab-active text-foreground font-medium': cssContentConfig.active === item.id,
288-
'text-muted-foreground hover:text-foreground': cssContentConfig.active !== item.id,
289-
}"
290-
@click="tabChanged(item.id)"
371+
:class="[
372+
cssContentConfig.active === item.id && !isSelectMode ? 'css-tab-active text-foreground font-medium' : 'text-muted-foreground hover:text-foreground',
373+
isSelectMode && selectedIds.includes(item.id) ? 'bg-accent text-accent-foreground' : '',
374+
]"
375+
@click="handleTabClick(item.id)"
291376
@dblclick.stop="startInlineRename(item)"
377+
@contextmenu="onContextMenu($event, item.id)"
292378
>
379+
<span
380+
v-if="isSelectMode"
381+
class="inline-flex items-center justify-center size-4 rounded border transition-colors mr-1"
382+
:class="selectedIds.includes(item.id) ? 'bg-primary border-primary text-primary-foreground' : 'border-border'"
383+
>
384+
<Check v-if="selectedIds.includes(item.id)" class="size-3" />
385+
</span>
293386
<input
294387
v-if="inlineEditId === item.id"
295388
:ref="setInlineInputRef"
@@ -302,14 +395,12 @@ function exportCurrentTheme() {
302395
>
303396
<span v-else class="truncate max-w-[100px]">{{ item.title }}</span>
304397

305-
<!-- 活跃 tab 下划线指示器 -->
306398
<span
307-
v-if="cssContentConfig.active === item.id"
399+
v-if="cssContentConfig.active === item.id && !isSelectMode"
308400
class="absolute bottom-0 left-2 right-2 h-[2px] rounded-full bg-primary"
309401
/>
310402

311-
<!-- 活跃 tab 操作: 更多菜单 -->
312-
<DropdownMenu v-if="cssContentConfig.active === item.id">
403+
<DropdownMenu v-if="cssContentConfig.active === item.id && !isSelectMode">
313404
<DropdownMenuTrigger as-child>
314405
<span
315406
class="inline-flex items-center justify-center size-4 rounded text-muted-foreground/60 hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors duration-100 cursor-pointer"
@@ -322,6 +413,9 @@ function exportCurrentTheme() {
322413
<DropdownMenuItem @click.stop="rename(item.name)">
323414
<Edit3 class="mr-2 size-4" /> 重命名
324415
</DropdownMenuItem>
416+
<DropdownMenuItem @click.stop="cssEditorStore.exportSingleTab(item.id)">
417+
<Download class="mr-2 size-4" /> 导出
418+
</DropdownMenuItem>
325419
<DropdownMenuSeparator />
326420
<DropdownMenuItem
327421
v-if="cssContentConfig.tabs.length > 1"
@@ -332,7 +426,7 @@ function exportCurrentTheme() {
332426
</DropdownMenuItem>
333427
</DropdownMenuContent>
334428
</DropdownMenu>
335-
</button>
429+
</div>
336430
</div>
337431

338432
<!-- 工具按钮组 -->
@@ -380,6 +474,90 @@ function exportCurrentTheme() {
380474
/>
381475
</div>
382476

477+
<!-- 选择模式底部操作栏 -->
478+
<Transition name="slide-up">
479+
<div
480+
v-if="isSelectMode"
481+
class="shrink-0 border-t border-border bg-background px-3 pt-2 pb-3 space-y-2"
482+
>
483+
<div class="flex items-center justify-between text-xs">
484+
<span class="text-muted-foreground">
485+
已选
486+
<strong class="text-foreground font-semibold">{{ selectedIds.length }}</strong>
487+
488+
</span>
489+
<div class="flex items-center gap-2 text-muted-foreground">
490+
<button
491+
class="hover:text-foreground transition-colors"
492+
@click="allSelected ? cssEditorStore.clearSelection() : cssEditorStore.selectAllTabs()"
493+
>
494+
{{ allSelected ? '取消全选' : '全选' }}
495+
</button>
496+
<span class="opacity-30">·</span>
497+
<button class="hover:text-foreground transition-colors" @click="exitSelectMode">
498+
完成
499+
</button>
500+
</div>
501+
</div>
502+
<div class="flex">
503+
<button
504+
class="flex flex-1 items-center justify-center rounded-md py-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-35"
505+
title="导出"
506+
:disabled="!selectedIds.length"
507+
@click="cssEditorStore.batchExportTabs"
508+
>
509+
<Download class="size-4" />
510+
</button>
511+
<div class="mx-1 self-center h-5 w-px bg-border/60 shrink-0" />
512+
<button
513+
class="flex flex-1 items-center justify-center rounded-md py-2 text-destructive/60 transition-colors hover:bg-destructive/8 hover:text-destructive disabled:pointer-events-none disabled:opacity-35"
514+
:title="selectedIds.length >= cssContentConfig.tabs.length ? '至少保留一个方案' : '删除'"
515+
:disabled="!selectedIds.length || selectedIds.length >= cssContentConfig.tabs.length"
516+
@click="isOpenBatchDelConfirmDialog = true"
517+
>
518+
<X class="size-4" />
519+
</button>
520+
</div>
521+
</div>
522+
</Transition>
523+
524+
<!-- 右键菜单 -->
525+
<Teleport to="body">
526+
<div
527+
v-if="showContextMenu"
528+
class="fixed z-50 min-w-[120px] rounded-md border border-border bg-background p-1 shadow-md animate-in fade-in-0 zoom-in-95"
529+
:style="{ left: `${contextMenuPos.x}px`, top: `${contextMenuPos.y}px` }"
530+
@click.stop
531+
>
532+
<button
533+
class="flex w-full items-center rounded-sm px-2 py-1.5 text-xs text-foreground hover:bg-accent transition-colors"
534+
@click="contextMenuRename"
535+
>
536+
<Edit3 class="mr-2 size-3.5" /> 重命名
537+
</button>
538+
<button
539+
class="flex w-full items-center rounded-sm px-2 py-1.5 text-xs text-foreground hover:bg-accent transition-colors"
540+
@click="contextMenuExport"
541+
>
542+
<Download class="mr-2 size-3.5" /> 导出
543+
</button>
544+
<div class="my-1 h-px bg-border" />
545+
<button
546+
v-if="cssContentConfig.tabs.length > 1"
547+
class="flex w-full items-center rounded-sm px-2 py-1.5 text-xs text-destructive hover:bg-destructive/8 transition-colors"
548+
@click="contextMenuDelete"
549+
>
550+
<X class="mr-2 size-3.5" /> 删除
551+
</button>
552+
<button
553+
class="flex w-full items-center rounded-sm px-2 py-1.5 text-xs text-foreground hover:bg-accent transition-colors"
554+
@click="enterSelectModeFromContextMenu"
555+
>
556+
<CheckSquare class="mr-2 size-3.5" /> 多选
557+
</button>
558+
</div>
559+
</Teleport>
560+
383561
<!-- 新增弹窗 -->
384562
<Dialog v-model:open="isOpenAddDialog">
385563
<DialogContent class="sm:max-w-[425px]">
@@ -468,6 +646,25 @@ function exportCurrentTheme() {
468646
</AlertDialogFooter>
469647
</AlertDialogContent>
470648
</AlertDialog>
649+
650+
<!-- 批量删除确认 -->
651+
<AlertDialog v-model:open="isOpenBatchDelConfirmDialog">
652+
<AlertDialogContent>
653+
<AlertDialogHeader>
654+
<AlertDialogTitle>提示</AlertDialogTitle>
655+
<AlertDialogDescription>{{ batchDelConfirmText }}</AlertDialogDescription>
656+
</AlertDialogHeader>
657+
<AlertDialogFooter>
658+
<AlertDialogCancel>取消</AlertDialogCancel>
659+
<AlertDialogAction
660+
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
661+
@click="confirmBatchDelete"
662+
>
663+
确定删除
664+
</AlertDialogAction>
665+
</AlertDialogFooter>
666+
</AlertDialogContent>
667+
</AlertDialog>
471668
</div>
472669

473670
<!-- 查看内置主题对话框 -->
@@ -539,4 +736,16 @@ function exportCurrentTheme() {
539736
.mobile-css-editor.animate {
540737
transition: transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
541738
}
739+
740+
/* 底部操作栏动画 */
741+
.slide-up-enter-active,
742+
.slide-up-leave-active {
743+
transition: transform 200ms ease, opacity 200ms ease;
744+
}
745+
746+
.slide-up-enter-from,
747+
.slide-up-leave-to {
748+
transform: translateY(100%);
749+
opacity: 0;
750+
}
542751
</style>

0 commit comments

Comments
 (0)