完善图片视频添加预览功能
This commit is contained in:
@@ -46,5 +46,11 @@
|
||||
"userTurns": 1,
|
||||
"basisHash": "53517398750be87f",
|
||||
"updatedAt": 1783656298406
|
||||
},
|
||||
"topic_20260710-054543_5e7c427932aecd7b": {
|
||||
"stage": 3,
|
||||
"userTurns": 3,
|
||||
"basisHash": "87073c4b14751c7b",
|
||||
"updatedAt": 1783663776394
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
26
components.json
Normal 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
1
src-tauri/Cargo.lock
generated
@@ -4930,6 +4930,7 @@ dependencies = [
|
||||
name = "yurou"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"notify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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(``);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
19
src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
19
src/components/ui/dropdown-menu/DropdownMenu.vue
Normal 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>
|
||||
43
src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
Normal file
43
src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
Normal 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>
|
||||
40
src/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
40
src/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal 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>
|
||||
15
src/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal file
15
src/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal 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>
|
||||
31
src/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
31
src/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal 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>
|
||||
23
src/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal file
23
src/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal 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>
|
||||
21
src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
Normal file
21
src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
Normal 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>
|
||||
44
src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
Normal file
44
src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
Normal 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>
|
||||
23
src/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal file
23
src/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal 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>
|
||||
17
src/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal file
17
src/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal 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>
|
||||
18
src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
18
src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal 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>
|
||||
27
src/components/ui/dropdown-menu/DropdownMenuSubContent.vue
Normal file
27
src/components/ui/dropdown-menu/DropdownMenuSubContent.vue
Normal 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>
|
||||
32
src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
Normal file
32
src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
Normal 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>
|
||||
17
src/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
17
src/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal 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>
|
||||
16
src/components/ui/dropdown-menu/index.ts
Normal file
16
src/components/ui/dropdown-menu/index.ts
Normal 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
6
src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user