完善图片视频添加预览功能

This commit is contained in:
2026-07-10 14:41:54 +08:00
parent aefe72b864
commit 266a7ffacd
25 changed files with 713 additions and 12 deletions

View File

@@ -46,5 +46,11 @@
"userTurns": 1,
"basisHash": "53517398750be87f",
"updatedAt": 1783656298406
},
"topic_20260710-054543_5e7c427932aecd7b": {
"stage": 3,
"userTurns": 3,
"basisHash": "87073c4b14751c7b",
"updatedAt": 1783663776394
}
}

View File

@@ -7,5 +7,7 @@
"topic_20260710-030936_cedad2907c3954fe": 1783652976310,
"topic_20260710-032907_600adc6b00791607": 1783654147796,
"topic_20260710-034033_6432ee6566f7b432": 1783654833341,
"topic_20260710-034724_3c6fb29d8d0a4119": 1783655244536
"topic_20260710-034724_3c6fb29d8d0a4119": 1783655244536,
"topic_20260710-054543_5e7c427932aecd7b": 1783662343716,
"topic_20260710-063053_353d579222d3b70b": 1783665053391
}

View File

@@ -45,5 +45,7 @@
"topic_20260710-030936_cedad2907c3954fe": "auto",
"topic_20260710-032907_600adc6b00791607": "auto",
"topic_20260710-034033_6432ee6566f7b432": "auto",
"topic_20260710-034724_3c6fb29d8d0a4119": "auto"
"topic_20260710-034724_3c6fb29d8d0a4119": "auto",
"topic_20260710-054543_5e7c427932aecd7b": "auto",
"topic_20260710-063053_353d579222d3b70b": "auto"
}

View File

@@ -45,5 +45,7 @@
"topic_20260710-030936_cedad2907c3954fe": "鼠标放在tree上的时候只有hov…",
"topic_20260710-032907_600adc6b00791607": "目录树中显示的文件夹markdow…",
"topic_20260710-034033_6432ee6566f7b432": "我在点击目录树的时候新增文件夹和新建…",
"topic_20260710-034724_3c6fb29d8d0a4119": "目录树中鼠标悬浮弹框dropmenu…"
"topic_20260710-034724_3c6fb29d8d0a4119": "目录树中鼠标悬浮弹框dropmenu…",
"topic_20260710-054543_5e7c427932aecd7b": "现在在markdown的编辑区域中…",
"topic_20260710-063053_353d579222d3b70b": "新的会话"
}

26
components.json Normal file
View File

@@ -0,0 +1,26 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "reka-nova",
"font": "geist-sans",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/tailwind.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"pointer": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

1
src-tauri/Cargo.lock generated
View File

@@ -4930,6 +4930,7 @@ dependencies = [
name = "yurou"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"notify",
"serde",
"serde_json",

View File

@@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
time = ">=0.3.0, <0.3.47"

View File

@@ -6,6 +6,7 @@ use std::sync::Mutex;
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
use tauri::{Emitter, Manager};
use tauri_plugin_dialog::DialogExt;
use base64::Engine;
#[derive(Debug, Clone, serde::Serialize)]
struct DirEntry {
@@ -55,6 +56,59 @@ fn is_hidden_entry(entry: &fs::DirEntry) -> bool {
}
}
/// Returns the companion hidden resources directory path for a markdown file.
/// Example: `notes/doc.md` → `notes/.doc`
fn resources_dir_path(file_path: &str) -> Option<String> {
let path = std::path::Path::new(file_path);
if path.extension().map_or(true, |ext| ext != "md") {
return None;
}
let stem = path.file_stem()?.to_str()?;
let parent = path.parent()?;
let dir_name = format!(".{}", stem);
Some(parent.join(dir_name).to_string_lossy().to_string())
}
/// On Windows, set the hidden attribute on a directory so it doesn't clutter
/// the file manager.
#[cfg(windows)]
fn hide_windows_dir(dir_path: &str) {
let _ = std::process::Command::new("attrib")
.args(["+h", dir_path])
.output();
}
/// Ensure a directory exists, and hide it on Windows when the name starts with `.`.
fn ensure_resources_dir(dir_path: &str) -> std::io::Result<()> {
fs::create_dir_all(dir_path)?;
#[cfg(windows)]
{
let name = std::path::Path::new(dir_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if name.starts_with('.') {
hide_windows_dir(dir_path);
}
}
Ok(())
}
#[tauri::command]
fn save_media_file(path: String, data: String) -> Result<(), String> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(&data)
.map_err(|e| format!("解码失败: {}", e))?;
if let Some(parent) = std::path::Path::new(&path).parent() {
let parent_str = parent.to_string_lossy().to_string();
ensure_resources_dir(&parent_str)
.map_err(|e| format!("无法创建目录: {}", e))?;
}
fs::write(&path, &bytes).map_err(|e| format!("无法写入文件: {}", e))
}
#[tauri::command]
fn pick_folder(app: tauri::AppHandle) -> Option<String> {
app.dialog()
@@ -200,7 +254,14 @@ fn create_file(path: String) -> Result<(), String> {
if let Some(parent) = PathBuf::from(&path).parent() {
fs::create_dir_all(parent).map_err(|e| format!("无法创建父目录: {}", e))?;
}
fs::write(&path, "").map_err(|e| format!("无法创建文件: {}", e))
fs::write(&path, "").map_err(|e| format!("无法创建文件: {}", e))?;
// Create companion hidden resources directory for markdown files.
if let Some(res_dir) = resources_dir_path(&path) {
ensure_resources_dir(&res_dir).map_err(|e| format!("无法创建资源文件夹: {}", e))?;
}
Ok(())
}
#[tauri::command]
@@ -209,7 +270,17 @@ fn delete_entry(path: String) -> Result<(), String> {
if p.is_dir() {
fs::remove_dir_all(&p).map_err(|e| format!("无法删除文件夹: {}", e))
} else if p.is_file() {
fs::remove_file(&p).map_err(|e| format!("无法删除文件: {}", e))
fs::remove_file(&p).map_err(|e| format!("无法删除文件: {}", e))?;
// If this was a markdown file, also remove its companion resources directory.
if let Some(res_dir) = resources_dir_path(&path) {
let res = PathBuf::from(&res_dir);
if res.is_dir() {
let _ = fs::remove_dir_all(&res);
}
}
Ok(())
} else {
Err("路径不存在".to_string())
}
@@ -222,6 +293,21 @@ fn rename_entry(path: String, new_name: String) -> Result<String, String> {
.parent()
.ok_or_else(|| "无法获取父目录".to_string())?;
let new_path = parent.join(&new_name);
// If this is a markdown file, rename its companion resources directory first.
if let Some(old_res_dir) = resources_dir_path(&path) {
let old_res = PathBuf::from(&old_res_dir);
if old_res.is_dir() {
let new_res_dir = resources_dir_path(&new_path.to_string_lossy());
if let Some(new_res) = new_res_dir {
let new_res_path = PathBuf::from(&new_res);
if new_res_path != old_res {
let _ = fs::rename(&old_res, &new_res_path);
}
}
}
}
fs::rename(&p, &new_path).map_err(|e| format!("无法重命名: {}", e))?;
Ok(new_path.to_string_lossy().to_string())
}
@@ -249,7 +335,8 @@ pub fn run() {
create_folder,
create_file,
delete_entry,
rename_entry
rename_entry,
save_media_file
])
.setup(|app| {
// ---- File menu ----

View File

@@ -13,7 +13,7 @@ import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { convertFileSrc, isTauri } from "@tauri-apps/api/core";
import { convertFileSrc, invoke, isTauri } from "@tauri-apps/api/core";
type DocumentMode = "preview" | "edit" | "mixed";
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]);
@@ -97,7 +97,16 @@ function getCurrentFileDirectory() {
return props.filePath.slice(0, separatorIndex);
}
function isWindowsAbsolutePath(path: string) {
return /^[A-Za-z]:[\\/]/.test(path);
}
function isUncPath(path: string) {
return path.startsWith("\\\\");
}
function isExternalUrl(url: string) {
if (isWindowsAbsolutePath(url) || isUncPath(url)) return false;
return /^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("//") || url.startsWith("#");
}
@@ -105,7 +114,7 @@ function normalizeLocalMediaPath(url: string) {
const trimmed = url.trim();
if (!trimmed || isExternalUrl(trimmed)) return trimmed;
if (/^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.startsWith("\\\\")) {
if (isWindowsAbsolutePath(trimmed) || isUncPath(trimmed)) {
return trimmed;
}
@@ -120,8 +129,8 @@ function normalizeLocalMediaPath(url: string) {
function toAssetSrc(path: string) {
if (isExternalUrl(path)) return path;
if (isTauri()) return convertFileSrc(path);
if (/^[A-Za-z]:[\\/]/.test(path)) return `file:///${path.replace(/\\/g, "/")}`;
if (path.startsWith("\\\\")) return `file:${path.replace(/\\/g, "/")}`;
if (isWindowsAbsolutePath(path)) return `file:///${path.replace(/\\/g, "/")}`;
if (isUncPath(path)) return `file:${path.replace(/\\/g, "/")}`;
return path;
}
@@ -679,6 +688,176 @@ onBeforeUnmount(() => {
}
});
// ── Paste image / file handling ─────────────────────────────────
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// Strip the "data:xxx;base64," prefix
resolve(result.split(',')[1] || '');
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
function generateMediaFilename(ext: string): string {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const suffix = Math.random().toString(36).slice(2, 6);
return `${ts}_${suffix}.${ext}`;
}
function imageExtFromMime(mime: string): string {
const map: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'image/avif': 'avif',
'image/bmp': 'bmp',
'image/svg+xml': 'svg',
};
return map[mime] || 'png';
}
/**
* Core paste handler: detects images/files in clipboard, saves them to the
* hidden resources folder, and inserts markdown image syntax at the cursor.
*/
async function processPaste(
e: ClipboardEvent,
value: string,
selectionStart: number,
selectionEnd: number,
applyValue: (newValue: string, cursorPos: number) => void,
) {
const items = e.clipboardData?.items;
if (!items || items.length === 0) return;
// Collect media items (images from screenshots, or files from file-manager copy)
const mediaItems: { item: DataTransferItem }[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith('image/') || item.type.startsWith('video/') || item.kind === 'file') {
mediaItems.push({ item });
}
}
if (mediaItems.length === 0) return; // No media — let default text paste happen
e.preventDefault();
e.stopPropagation();
if (!props.filePath) return; // No file open, nowhere to save
const dir = getCurrentFileDirectory();
if (!dir) return;
// Derive the hidden resources folder name from the current markdown file
const mdFileName = props.filePath.split(/[/\\]/).pop() || 'document';
const stem = mdFileName.replace(/\.md$/i, '');
const resourcesDir = `${dir}/.${stem}`;
const resourcesRelDir = `.${stem}`;
// Process each media item and build a replacement string
const insertParts: string[] = [];
for (const { item } of mediaItems) {
const file = item.getAsFile();
if (!file) continue;
let ext: string;
let blob: Blob;
if (item.type.startsWith('image/')) {
ext = imageExtFromMime(item.type);
blob = file;
} else if (item.type.startsWith('video/')) {
ext = item.type.split('/')[1] || 'mp4';
blob = file;
} else {
// File from file manager — derive extension from file name
const dot = file.name.lastIndexOf('.');
ext = dot >= 0 ? file.name.slice(dot + 1).toLowerCase() : 'bin';
blob = file;
}
const filename = generateMediaFilename(ext);
const assetPath = `${resourcesDir}/${filename}`;
const relativePath = `${resourcesRelDir}/${filename}`;
try {
const base64 = await blobToBase64(blob);
await invoke("save_media_file", { path: assetPath, data: base64 });
} catch (err) {
console.error("Failed to save pasted media:", err);
continue;
}
const alt = file.name || 'image';
// Separate multiple pasted images with a line break
if (insertParts.length > 0) insertParts.push('\n');
insertParts.push(`![${alt}](${relativePath})`);
}
if (insertParts.length === 0) return;
const insertText = insertParts.join('');
const newValue =
value.slice(0, selectionStart) + insertText + value.slice(selectionEnd);
const cursorPos = selectionStart + insertText.length;
applyValue(newValue, cursorPos);
}
async function onEditorPaste(e: ClipboardEvent) {
const ta = editorTextareaRef.value;
if (!ta) return;
await processPaste(
e,
props.modelValue,
ta.selectionStart,
ta.selectionEnd,
(newValue, cursorPos) => {
applyFullEditorValue(newValue);
void nextTick(() => {
const ta = editorTextareaRef.value;
if (ta) {
ta.selectionStart = ta.selectionEnd = cursorPos;
ta.focus();
}
});
},
);
}
async function onBlockPaste(e: ClipboardEvent) {
const ta = blockTextareaRef.value;
if (!ta) return;
await processPaste(
e,
editingSource.value,
ta.selectionStart,
ta.selectionEnd,
(newValue, cursorPos) => {
editingSource.value = newValue;
void nextTick(() => {
const ta = blockTextareaRef.value;
if (ta) {
ta.selectionStart = ta.selectionEnd = cursorPos;
ta.focus();
scheduleTextareaResize(ta);
}
});
},
);
}
defineExpose({
flushPendingChanges,
});
@@ -692,7 +871,7 @@ defineExpose({
@keydown.capture="onRootKeydown"
>
<div
class="mx-auto px-8 py-8 h-full"
class="mx-auto px-8 py-4 h-full"
:style="{
maxWidth: `calc(700px * var(--editor-zoom, 1))`,
fontSize: `calc(1rem * var(--editor-zoom, 1))`,
@@ -711,6 +890,7 @@ defineExpose({
"
@input="onEditorInput"
@keydown="onEditorKeydown"
@paste="onEditorPaste"
/>
<!-- Preview mode -->
@@ -718,7 +898,7 @@ defineExpose({
<div v-if="renderError" class="text-red-500 text-sm">
Markdown 解析错误
</div>
<div v-else class="markdown-preview" v-html="renderedHtml" />
<div v-else class="markdown-preview py-2" v-html="renderedHtml" />
</div>
<!-- Mixed mode -->
@@ -736,6 +916,7 @@ defineExpose({
@input="onBlockInput"
@keydown="onBlockKeydown"
@blur="finalizeBlockEdit"
@paste="onBlockPaste"
/>
<div
v-else-if="!props.modelValue.trim()"
@@ -764,6 +945,7 @@ defineExpose({
@input="onBlockInput"
@keydown="onBlockKeydown"
@blur="finalizeBlockEdit"
@paste="onBlockPaste"
/>
<div
v-else

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from "reka-ui"
import { DropdownMenuRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DropdownMenuRootProps>()
const emits = defineEmits<DropdownMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRoot
v-slot="slotProps"
data-slot="dropdown-menu"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DropdownMenuRoot>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { CheckIcon } from '@lucide/vue';
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<CheckIcon />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuContent,
DropdownMenuPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
{
align: "start",
sideOffset: 4,
},
)
const emits = defineEmits<DropdownMenuContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-dropdown-menu-content-available-height) w-(--reka-dropdown-menu-trigger-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden', props.class)"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DropdownMenuGroupProps } from "reka-ui"
import { DropdownMenuGroup } from "reka-ui"
const props = defineProps<DropdownMenuGroupProps>()
</script>
<template>
<DropdownMenuGroup
data-slot="dropdown-menu-group"
v-bind="props"
>
<slot />
</DropdownMenuGroup>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { DropdownMenuItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DropdownMenuItem, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<DropdownMenuItemProps & {
class?: HTMLAttributes["class"]
inset?: boolean
variant?: "default" | "destructive"
}>(), {
variant: "default",
})
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', props.class)"
>
<slot />
</DropdownMenuItem>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuLabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DropdownMenuLabel, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, "class", "inset")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuLabel
data-slot="dropdown-menu-label"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', props.class)"
>
<slot />
</DropdownMenuLabel>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from "reka-ui"
import {
DropdownMenuRadioGroup,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<DropdownMenuRadioGroupProps>()
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRadioGroup
data-slot="dropdown-menu-radio-group"
v-bind="forwarded"
>
<slot />
</DropdownMenuRadioGroup>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { CheckIcon } from '@lucide/vue';
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuRadioItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<CheckIcon />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuSeparator,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSeparatorProps & {
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DropdownMenuSeparator
data-slot="dropdown-menu-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
data-slot="dropdown-menu-shortcut"
:class="cn('text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from "reka-ui"
import {
DropdownMenuSub,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<DropdownMenuSubProps>()
const emits = defineEmits<DropdownMenuSubEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
<slot v-bind="slotProps" />
</DropdownMenuSub>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuSubContent,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 cn-menu-translucent z-50 origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden', props.class)"
>
<slot />
</DropdownMenuSubContent>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@lucide/vue';
import type { DropdownMenuSubTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuSubTrigger,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, "class", "inset")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<slot />
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
</DropdownMenuSubTrigger>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { DropdownMenuTriggerProps } from "reka-ui"
import { DropdownMenuTrigger, useForwardProps } from "reka-ui"
const props = defineProps<DropdownMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuTrigger
data-slot="dropdown-menu-trigger"
v-bind="forwardedProps"
>
<slot />
</DropdownMenuTrigger>
</template>

View File

@@ -0,0 +1,16 @@
export { default as DropdownMenu } from "./DropdownMenu.vue"
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue"
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue"
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue"
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue"
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue"
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue"
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue"
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue"
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue"
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue"
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"
export { DropdownMenuPortal } from "reka-ui"

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}