更新
This commit is contained in:
@@ -5,5 +5,12 @@
|
|||||||
"topic_20260630-161655_edfbcdeb0d6b655b": "auto",
|
"topic_20260630-161655_edfbcdeb0d6b655b": "auto",
|
||||||
"topic_20260701-155310_c69d2b8b32c78f89": "auto",
|
"topic_20260701-155310_c69d2b8b32c78f89": "auto",
|
||||||
"topic_20260701-161459_aeaacf65cf653c96": "auto",
|
"topic_20260701-161459_aeaacf65cf653c96": "auto",
|
||||||
"topic_20260701-162849_a8bde34cd0ff69c1": "auto"
|
"topic_20260701-162849_a8bde34cd0ff69c1": "auto",
|
||||||
|
"topic_20260702-153333_1133e5609dc92d8d": "auto",
|
||||||
|
"topic_20260702-154849_2270d3ece753aba9": "auto",
|
||||||
|
"topic_20260702-160011_745ecb81ec319e5e": "auto",
|
||||||
|
"topic_20260702-161756_b6c7f476849c7ee1": "auto",
|
||||||
|
"topic_20260702-161832_f8157b81a77b9bfe": "auto",
|
||||||
|
"topic_20260702-163743_d97b7189a52644d4": "auto",
|
||||||
|
"topic_20260702-165156_738d9dd96d08e07c": "auto"
|
||||||
}
|
}
|
||||||
@@ -5,5 +5,12 @@
|
|||||||
"topic_20260630-161655_edfbcdeb0d6b655b": "我现在需要给这个首页做一个三栏布局…",
|
"topic_20260630-161655_edfbcdeb0d6b655b": "我现在需要给这个首页做一个三栏布局…",
|
||||||
"topic_20260701-155310_c69d2b8b32c78f89": "MarkdownEditor组件应该…",
|
"topic_20260701-155310_c69d2b8b32c78f89": "MarkdownEditor组件应该…",
|
||||||
"topic_20260701-161459_aeaacf65cf653c96": "添加一下原生标题栏",
|
"topic_20260701-161459_aeaacf65cf653c96": "添加一下原生标题栏",
|
||||||
"topic_20260701-162849_a8bde34cd0ff69c1": "帮我完善一个功能,当用户直接打开软件…"
|
"topic_20260701-162849_a8bde34cd0ff69c1": "帮我完善一个功能,当用户直接打开软件…",
|
||||||
|
"topic_20260702-153333_1133e5609dc92d8d": "侧边栏需要改动一下,第一个图标我们默…",
|
||||||
|
"topic_20260702-154849_2270d3ece753aba9": "帮我安装npm install re…",
|
||||||
|
"topic_20260702-160011_745ecb81ec319e5e": "166-17 @src/compon…",
|
||||||
|
"topic_20260702-161756_b6c7f476849c7ee1": "打开的应用设置一个最小的高度是600…",
|
||||||
|
"topic_20260702-161832_f8157b81a77b9bfe": "左侧的工作目录,我希望除了名称之外…",
|
||||||
|
"topic_20260702-163743_d97b7189a52644d4": "tiptap-editor 里的区域…",
|
||||||
|
"topic_20260702-165156_738d9dd96d08e07c": "帮我引入@tiptap/markdo…"
|
||||||
}
|
}
|
||||||
@@ -14,9 +14,11 @@
|
|||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tiptap/extension-placeholder": "^3.27.1",
|
"@tiptap/extension-placeholder": "^3.27.1",
|
||||||
|
"@tiptap/markdown": "^3.27.1",
|
||||||
"@tiptap/pm": "^3.27.1",
|
"@tiptap/pm": "^3.27.1",
|
||||||
"@tiptap/starter-kit": "^3.27.1",
|
"@tiptap/starter-kit": "^3.27.1",
|
||||||
"@tiptap/vue-3": "^3.27.1",
|
"@tiptap/vue-3": "^3.27.1",
|
||||||
|
"remixicon": "^4.9.1",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
|||||||
'@tiptap/extension-placeholder':
|
'@tiptap/extension-placeholder':
|
||||||
specifier: ^3.27.1
|
specifier: ^3.27.1
|
||||||
version: 3.27.1(@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
|
version: 3.27.1(@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
|
||||||
|
'@tiptap/markdown':
|
||||||
|
specifier: ^3.27.1
|
||||||
|
version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||||
'@tiptap/pm':
|
'@tiptap/pm':
|
||||||
specifier: ^3.27.1
|
specifier: ^3.27.1
|
||||||
version: 3.27.1
|
version: 3.27.1
|
||||||
@@ -29,6 +32,9 @@ importers:
|
|||||||
'@tiptap/vue-3':
|
'@tiptap/vue-3':
|
||||||
specifier: ^3.27.1
|
specifier: ^3.27.1
|
||||||
version: 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(vue@3.5.39(typescript@5.6.3))
|
version: 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(vue@3.5.39(typescript@5.6.3))
|
||||||
|
remixicon:
|
||||||
|
specifier: ^4.9.1
|
||||||
|
version: 4.9.1
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.13
|
specifier: ^3.5.13
|
||||||
version: 3.5.39(typescript@5.6.3)
|
version: 3.5.39(typescript@5.6.3)
|
||||||
@@ -710,6 +716,12 @@ packages:
|
|||||||
'@tiptap/core': 3.27.1
|
'@tiptap/core': 3.27.1
|
||||||
'@tiptap/pm': 3.27.1
|
'@tiptap/pm': 3.27.1
|
||||||
|
|
||||||
|
'@tiptap/markdown@3.27.1':
|
||||||
|
resolution: {integrity: sha512-4m2LZcj/uN0uLlGnXr3i7sfdxbQNNmeMn4wFyFrP2xpshcKPL5WujUAcqT2rgKfaDjKQ8gIK/GpgSQKuRENFwg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.27.1
|
||||||
|
'@tiptap/pm': 3.27.1
|
||||||
|
|
||||||
'@tiptap/pm@3.27.1':
|
'@tiptap/pm@3.27.1':
|
||||||
resolution: {integrity: sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw==}
|
resolution: {integrity: sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw==}
|
||||||
|
|
||||||
@@ -923,6 +935,11 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
marked@17.0.6:
|
||||||
|
resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
minimatch@9.0.9:
|
minimatch@9.0.9:
|
||||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
@@ -991,6 +1008,9 @@ packages:
|
|||||||
prosemirror-view@1.41.9:
|
prosemirror-view@1.41.9:
|
||||||
resolution: {integrity: sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==}
|
resolution: {integrity: sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==}
|
||||||
|
|
||||||
|
remixicon@4.9.1:
|
||||||
|
resolution: {integrity: sha512-36gLSoujkabnCFZFDyP17VNh9piuBA/rsXUb4auSJWLGsHVXtmxLj/EM5FjaEAGnk8oIAj1Azob/DZ2N+90lAQ==}
|
||||||
|
|
||||||
rollup@4.62.2:
|
rollup@4.62.2:
|
||||||
resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==}
|
resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
@@ -1518,6 +1538,12 @@ snapshots:
|
|||||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||||
'@tiptap/pm': 3.27.1
|
'@tiptap/pm': 3.27.1
|
||||||
|
|
||||||
|
'@tiptap/markdown@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||||
|
'@tiptap/pm': 3.27.1
|
||||||
|
marked: 17.0.6
|
||||||
|
|
||||||
'@tiptap/pm@3.27.1':
|
'@tiptap/pm@3.27.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
prosemirror-changeset: 2.4.1
|
prosemirror-changeset: 2.4.1
|
||||||
@@ -1782,6 +1808,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
marked@17.0.6: {}
|
||||||
|
|
||||||
minimatch@9.0.9:
|
minimatch@9.0.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 2.1.1
|
brace-expansion: 2.1.1
|
||||||
@@ -1878,6 +1906,8 @@ snapshots:
|
|||||||
prosemirror-state: 1.4.4
|
prosemirror-state: 1.4.4
|
||||||
prosemirror-transform: 1.12.0
|
prosemirror-transform: 1.12.0
|
||||||
|
|
||||||
|
remixicon@4.9.1: {}
|
||||||
|
|
||||||
rollup@4.62.2:
|
rollup@4.62.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
|
|||||||
@@ -22,10 +22,23 @@ fn pick_folder(app: tauri::AppHandle) -> Option<String> {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn read_dir(path: String) -> Result<Vec<DirEntry>, String> {
|
fn read_dir(path: String) -> Result<Vec<DirEntry>, String> {
|
||||||
let entries = fs::read_dir(&path).map_err(|e| format!("无法读取目录: {}", e))?;
|
let entries = fs::read_dir(&path).map_err(|e| format!("无法读取目录: {}", e))?;
|
||||||
|
let allowed_extensions = ["md", "txt", "png", "jpg", "jpeg", "gif", "svg", "webp", "bmp"];
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry = entry.map_err(|e| format!("读取条目失败: {}", e))?;
|
let entry = entry.map_err(|e| format!("读取条目失败: {}", e))?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
let is_dir = path.is_dir();
|
||||||
|
// Always include directories; filter files by extension
|
||||||
|
if !is_dir {
|
||||||
|
let ext = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
if !allowed_extensions.contains(&ext.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
let name = entry
|
let name = entry
|
||||||
.file_name()
|
.file_name()
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
@@ -33,7 +46,7 @@ fn read_dir(path: String) -> Result<Vec<DirEntry>, String> {
|
|||||||
result.push(DirEntry {
|
result.push(DirEntry {
|
||||||
name,
|
name,
|
||||||
path: path.to_string_lossy().to_string(),
|
path: path.to_string_lossy().to_string(),
|
||||||
is_dir: path.is_dir(),
|
is_dir,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Sort: directories first, then alphabetical
|
// Sort: directories first, then alphabetical
|
||||||
|
|||||||
@@ -13,8 +13,10 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "yurou",
|
"title": "yurou",
|
||||||
"width": 800,
|
"width": 900,
|
||||||
"height": 600,
|
"height": 600,
|
||||||
|
"minWidth": 900,
|
||||||
|
"minHeight": 600,
|
||||||
"decorations": true
|
"decorations": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
159
src/App.vue
159
src/App.vue
@@ -4,7 +4,6 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import MarkdownEditor from "./components/MarkdownEditor.vue";
|
import MarkdownEditor from "./components/MarkdownEditor.vue";
|
||||||
import DirectorySidebar from "./components/DirectorySidebar.vue";
|
import DirectorySidebar from "./components/DirectorySidebar.vue";
|
||||||
import OutlinePanel from "./components/OutlinePanel.vue";
|
|
||||||
|
|
||||||
const content = ref("");
|
const content = ref("");
|
||||||
const wordCount = ref(0);
|
const wordCount = ref(0);
|
||||||
@@ -14,22 +13,43 @@ const charCount = ref(0);
|
|||||||
const currentFilePath = ref<string | null>(null);
|
const currentFilePath = ref<string | null>(null);
|
||||||
const saveStatus = ref("");
|
const saveStatus = ref("");
|
||||||
|
|
||||||
// Panel state
|
|
||||||
const showOutline = ref(true);
|
|
||||||
|
|
||||||
// Folder state
|
// Folder state
|
||||||
interface DirEntry {
|
interface DirEntry {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
is_dir: boolean;
|
is_dir: boolean;
|
||||||
|
children?: DirEntry[];
|
||||||
}
|
}
|
||||||
const folderPath = ref("");
|
const folderPath = ref("");
|
||||||
const dirEntries = ref<DirEntry[]>([]);
|
const dirEntries = ref<DirEntry[]>([]);
|
||||||
|
|
||||||
function onUpdate(html: string) {
|
function stripMarkdown(md: string): string {
|
||||||
content.value = html;
|
return md
|
||||||
// Strip HTML tags for counting
|
// Remove images 
|
||||||
const text = html.replace(/<[^>]*>/g, "");
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
||||||
|
// Convert links [text](url) to just text
|
||||||
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||||
|
// Remove heading markers
|
||||||
|
.replace(/^#{1,6}\s+/gm, "")
|
||||||
|
// Remove bold/italic markers
|
||||||
|
.replace(/\*{1,3}|_{1,3}/g, "")
|
||||||
|
// Remove strikethrough
|
||||||
|
.replace(/~~/g, "")
|
||||||
|
// Remove inline code markers
|
||||||
|
.replace(/`+/g, "")
|
||||||
|
// Remove blockquote markers
|
||||||
|
.replace(/^>\s?/gm, "")
|
||||||
|
// Remove list markers (-, *, +, or 1.)
|
||||||
|
.replace(/^[\s]*[-*+]\s+/gm, "")
|
||||||
|
.replace(/^[\s]*\d+\.\s+/gm, "")
|
||||||
|
// Remove horizontal rules
|
||||||
|
.replace(/^[-*_]{3,}\s*$/gm, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdate(md: string) {
|
||||||
|
content.value = md;
|
||||||
|
const text = stripMarkdown(md);
|
||||||
charCount.value = text.length;
|
charCount.value = text.length;
|
||||||
wordCount.value = text.trim() ? text.trim().split(/\s+/).length : 0;
|
wordCount.value = text.trim() ? text.trim().split(/\s+/).length : 0;
|
||||||
}
|
}
|
||||||
@@ -58,10 +78,55 @@ async function loadDir(path: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onOpenFolder() {
|
||||||
|
try {
|
||||||
|
const chosen = await invoke<string | null>("pick_folder");
|
||||||
|
if (chosen) {
|
||||||
|
await loadDir(chosen);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to pick folder:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getFileName(path: string): string {
|
function getFileName(path: string): string {
|
||||||
return path.split(/[/\\]/).pop() || path;
|
return path.split(/[/\\]/).pop() || path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- file tree helpers ----
|
||||||
|
function findEntry(entries: DirEntry[], path: string): DirEntry | null {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.path === path) return entry;
|
||||||
|
if (entry.children) {
|
||||||
|
const found = findEntry(entry.children, path);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleFolder(path: string) {
|
||||||
|
const entry = findEntry(dirEntries.value, path);
|
||||||
|
if (!entry || entry.children !== undefined) return;
|
||||||
|
try {
|
||||||
|
const children = await invoke<DirEntry[]>("read_dir", { path });
|
||||||
|
entry.children = children;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to read subdirectory:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOpenFile(path: string) {
|
||||||
|
try {
|
||||||
|
const fileContent = await invoke<string>("read_file", { path });
|
||||||
|
content.value = fileContent;
|
||||||
|
currentFilePath.value = path;
|
||||||
|
saveStatus.value = "";
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to open file:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function doSave() {
|
async function doSave() {
|
||||||
if (!currentFilePath.value) {
|
if (!currentFilePath.value) {
|
||||||
// No file path yet — prompt user to pick a save location
|
// No file path yet — prompt user to pick a save location
|
||||||
@@ -110,10 +175,6 @@ listen<string>("folder-opened", (event) => {
|
|||||||
loadDir(event.payload);
|
loadDir(event.payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
listen("toggle-outline", () => {
|
|
||||||
showOutline.value = !showOutline.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
listen("menu-save", () => {
|
listen("menu-save", () => {
|
||||||
doSave();
|
doSave();
|
||||||
});
|
});
|
||||||
@@ -132,26 +193,23 @@ listen("menu-event", (event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-950 text-gray-100 flex flex-col">
|
<div class="h-screen bg-[#faf9f6] text-[#38342e] flex flex-col">
|
||||||
<!-- Three-column body -->
|
<!-- Three-column body -->
|
||||||
<div class="flex-1 flex overflow-hidden">
|
<div class="flex-1 flex overflow-hidden bg-[#faf9f6]">
|
||||||
<!-- Left: Directory sidebar -->
|
<!-- Left: Directory sidebar -->
|
||||||
<DirectorySidebar
|
<DirectorySidebar
|
||||||
:folder-path="folderPath"
|
:folder-path="folderPath"
|
||||||
:entries="dirEntries"
|
:entries="dirEntries"
|
||||||
/>
|
:content="content"
|
||||||
|
:current-file-path="currentFilePath"
|
||||||
<!-- Middle: Outline panel -->
|
|
||||||
<transition name="outline-slide">
|
|
||||||
<OutlinePanel
|
|
||||||
v-if="showOutline"
|
|
||||||
:html-content="content"
|
|
||||||
@heading-click="onHeadingClick"
|
@heading-click="onHeadingClick"
|
||||||
|
@open-folder="onOpenFolder"
|
||||||
|
@toggle-folder="onToggleFolder"
|
||||||
|
@open-file="onOpenFile"
|
||||||
/>
|
/>
|
||||||
</transition>
|
|
||||||
|
|
||||||
<!-- Right: Editor area -->
|
<!-- Right: Editor area -->
|
||||||
<main class="flex-1 flex flex-col overflow-y-auto p-2">
|
<main class="flex-1 flex flex-col overflow-y-auto p-3">
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
v-model="content"
|
v-model="content"
|
||||||
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
||||||
@@ -161,65 +219,28 @@ listen("menu-event", (event) => {
|
|||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer class="border-t border-[#e8e4da] bg-[#f4f1eb]/60 backdrop-blur-sm">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4 px-4 py-1.5">
|
||||||
<!-- Outline toggle -->
|
|
||||||
<button
|
|
||||||
class="flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-md transition-colors"
|
|
||||||
:class="
|
|
||||||
showOutline
|
|
||||||
? 'bg-indigo-600/30 text-indigo-300'
|
|
||||||
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
|
||||||
"
|
|
||||||
title="切换大纲面板"
|
|
||||||
@click="showOutline = !showOutline"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="w-3.5 h-3.5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7" />
|
|
||||||
</svg>
|
|
||||||
大纲
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span class="w-px h-3 bg-gray-600" />
|
|
||||||
|
|
||||||
<!-- Current file / save status -->
|
<!-- Current file / save status -->
|
||||||
<span
|
<span
|
||||||
v-if="saveStatus"
|
v-if="saveStatus"
|
||||||
class="text-xs"
|
class="text-xs"
|
||||||
:class="saveStatus.startsWith('保存失败') ? 'text-red-400' : 'text-green-400'"
|
:class="saveStatus.startsWith('保存失败') ? 'text-red-500' : 'text-emerald-600'"
|
||||||
>{{ saveStatus }}</span>
|
>{{ saveStatus }}</span>
|
||||||
<span v-else-if="currentFilePath" class="text-xs text-gray-400">
|
<span v-else-if="currentFilePath" class="text-xs text-[#8c877d]">
|
||||||
{{ currentFilePath.split(/[/\\]/).pop() }}
|
{{ currentFilePath.split(/[/\\]/).pop() }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-xs text-gray-500">未保存</span>
|
<span v-else class="text-xs text-[#b8b3a8]">未保存</span>
|
||||||
|
|
||||||
<span class="flex-1" />
|
<span class="flex-1" />
|
||||||
|
|
||||||
<span class="text-xs text-gray-400">{{ wordCount.toLocaleString() }} 词</span>
|
<span class="text-xs text-[#8c877d]">{{ wordCount.toLocaleString() }} 词</span>
|
||||||
<span class="w-px h-3 bg-gray-600" />
|
<span class="w-px h-3 bg-[#d4cfc4]" />
|
||||||
<span class="text-xs text-gray-400">{{ charCount.toLocaleString() }} 字</span>
|
<span class="text-xs text-[#8c877d]">{{ charCount.toLocaleString() }} 字</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Outline panel slide transition */
|
|
||||||
.outline-slide-enter-active,
|
|
||||||
.outline-slide-leave-active {
|
|
||||||
transition: width 0.25s ease, opacity 0.2s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.outline-slide-enter-from,
|
|
||||||
.outline-slide-leave-to {
|
|
||||||
width: 0 !important;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,157 +1,155 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref, computed } from "vue";
|
||||||
|
import TreeEntry from "./TreeEntry.vue";
|
||||||
|
|
||||||
interface DirEntry {
|
interface DirEntry {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
is_dir: boolean;
|
is_dir: boolean;
|
||||||
|
children?: DirEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
folderPath: string;
|
folderPath: string;
|
||||||
entries: DirEntry[];
|
entries: DirEntry[];
|
||||||
|
content?: string;
|
||||||
|
currentFilePath?: string | null;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
content: "",
|
||||||
|
currentFilePath: null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
headingClick: [heading: { text: string; level: number }];
|
||||||
|
openFolder: [];
|
||||||
|
toggleFolder: [path: string];
|
||||||
|
openFile: [path: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const collapsed = ref(false);
|
// ---- sidebar view mode ----
|
||||||
|
type ViewMode = "folder" | "search" | "outline";
|
||||||
|
const activeView = ref<ViewMode>("folder");
|
||||||
|
const collapsed = ref(true);
|
||||||
|
|
||||||
const toggleCollapsed = () => {
|
const viewLabels: Record<ViewMode, string> = {
|
||||||
collapsed.value = !collapsed.value;
|
folder: "文件",
|
||||||
|
search: "搜索",
|
||||||
|
outline: "大纲",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- file tree state (for opened folder) ----
|
function switchView(view: ViewMode) {
|
||||||
interface FileItem {
|
if (collapsed.value) {
|
||||||
id: string;
|
collapsed.value = false;
|
||||||
name: string;
|
activeView.value = view;
|
||||||
type: "file" | "folder";
|
} else if (activeView.value === view) {
|
||||||
path: string;
|
collapsed.value = true;
|
||||||
children?: FileItem[];
|
|
||||||
expanded?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expandedFolders = ref<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const toggleFolder = (item: FileItem) => {
|
|
||||||
if (item.type === "folder") {
|
|
||||||
if (expandedFolders.value.has(item.path)) {
|
|
||||||
expandedFolders.value.delete(item.path);
|
|
||||||
} else {
|
} else {
|
||||||
expandedFolders.value.add(item.path);
|
activeView.value = view;
|
||||||
}
|
}
|
||||||
// Trigger reactivity
|
|
||||||
expandedFolders.value = new Set(expandedFolders.value);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const newFile = () => {
|
function collapseSidebar() {
|
||||||
const name = prompt("文件名称(含扩展名):");
|
collapsed.value = true;
|
||||||
if (name) {
|
|
||||||
// files are managed externally
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const newFolder = () => {
|
// ---- outline state ----
|
||||||
const name = prompt("文件夹名称:");
|
interface OutlineItem {
|
||||||
if (name) {
|
text: string;
|
||||||
// folders are managed externally
|
level: number;
|
||||||
|
id: string;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// ---- tag state ----
|
const headings = computed<OutlineItem[]>(() => {
|
||||||
const tags = ref(["技术", "随笔", "待办", "灵感"]);
|
if (!props.content) return [];
|
||||||
const newTagInput = ref("");
|
const result: OutlineItem[] = [];
|
||||||
const addingTag = ref(false);
|
const regex = /^(#{1,3})\s+(.+)$/gm;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
const addTag = () => {
|
while ((match = regex.exec(props.content)) !== null) {
|
||||||
const t = newTagInput.value.trim();
|
const level = match[1].length;
|
||||||
if (t && !tags.value.includes(t)) {
|
// Strip any remaining markdown formatting from the heading text
|
||||||
tags.value.push(t);
|
const text = match[2].replace(/\*{1,3}|_{1,3}|`+|~~|\[([^\]]*)\]\([^)]*\)/g, (_, linkText) => linkText || "").trim();
|
||||||
|
if (text) {
|
||||||
|
result.push({ text, level, id: `heading-${result.length}` });
|
||||||
}
|
}
|
||||||
newTagInput.value = "";
|
}
|
||||||
addingTag.value = false;
|
return result;
|
||||||
};
|
});
|
||||||
|
|
||||||
const removeTag = (tag: string) => {
|
function onHeadingClick(item: OutlineItem) {
|
||||||
tags.value = tags.value.filter((t) => t !== tag);
|
emit("headingClick", { text: item.text, level: item.level });
|
||||||
};
|
}
|
||||||
|
|
||||||
|
// ---- settings modal ----
|
||||||
|
const showSettings = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
:class="[
|
:class="[
|
||||||
'flex flex-col border-r border-gray-800 bg-gray-900/60 transition-all duration-300 ease-in-out overflow-hidden shrink-0',
|
'flex flex-col border-r border-[#e8e4da] bg-[#f4f1eb]/80 transition-all duration-300 ease-in-out overflow-hidden shrink-0',
|
||||||
collapsed ? 'w-12' : 'w-56',
|
collapsed ? 'w-12' : 'w-56',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<!-- Header / toggle -->
|
<!-- ============ COLLAPSED: icon bar ============ -->
|
||||||
<div
|
<template v-if="collapsed">
|
||||||
class="flex items-center justify-between px-3 py-3 border-b border-gray-800"
|
<div class="flex flex-col items-center gap-1 py-3 flex-1">
|
||||||
:class="collapsed ? 'justify-center' : ''"
|
<!-- Folder -->
|
||||||
|
<button
|
||||||
|
class="p-2 rounded-md transition-colors"
|
||||||
|
:class="activeView === 'folder' ? 'text-[#bf6a3b] bg-[#ede8de]' : 'text-[#b8b3a8] hover:text-[#5c574e] hover:bg-[#ede8de]'"
|
||||||
|
title="文件"
|
||||||
|
@click="switchView('folder')"
|
||||||
>
|
>
|
||||||
<span v-if="!collapsed" class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
<i class="ri-folder-line text-base"></i>
|
||||||
语柔
|
</button>
|
||||||
|
<!-- Search -->
|
||||||
|
<button
|
||||||
|
class="p-2 rounded-md transition-colors"
|
||||||
|
:class="activeView === 'search' ? 'text-[#bf6a3b] bg-[#ede8de]' : 'text-[#b8b3a8] hover:text-[#5c574e] hover:bg-[#ede8de]'"
|
||||||
|
title="搜索"
|
||||||
|
@click="switchView('search')"
|
||||||
|
>
|
||||||
|
<i class="ri-search-line text-base"></i>
|
||||||
|
</button>
|
||||||
|
<!-- Outline -->
|
||||||
|
<button
|
||||||
|
class="p-2 rounded-md transition-colors"
|
||||||
|
:class="activeView === 'outline' ? 'text-[#bf6a3b] bg-[#ede8de]' : 'text-[#b8b3a8] hover:text-[#5c574e] hover:bg-[#ede8de]'"
|
||||||
|
title="大纲"
|
||||||
|
@click="switchView('outline')"
|
||||||
|
>
|
||||||
|
<i class="ri-menu-line text-base"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============ EXPANDED: full sidebar ============ -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-3 py-3 border-b border-[#e8e4da]">
|
||||||
|
<span class="text-xs font-semibold text-[#8c877d] uppercase tracking-wider">
|
||||||
|
{{ viewLabels[activeView] }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
class="p-1 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
|
class="p-1 rounded-md text-[#8c877d] hover:text-[#38342e] hover:bg-[#e0dbcf] transition-colors"
|
||||||
@click="toggleCollapsed"
|
title="折叠侧边栏"
|
||||||
|
@click="collapseSidebar"
|
||||||
>
|
>
|
||||||
<!-- hamburger / panel-close icon -->
|
<i class="ri-arrow-left-double-line text-base"></i>
|
||||||
<svg
|
|
||||||
v-if="!collapsed"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
<svg
|
|
||||||
v-else
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Collapsed icon-only view -->
|
<!-- ===== FOLDER VIEW ===== -->
|
||||||
<div v-if="collapsed" class="flex flex-col items-center gap-1 py-3">
|
<div v-if="activeView === 'folder'" class="flex flex-col flex-1 overflow-hidden">
|
||||||
<!-- new file -->
|
|
||||||
<button
|
|
||||||
class="p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
|
|
||||||
title="新建文件"
|
|
||||||
@click="newFile"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<!-- new folder -->
|
|
||||||
<button
|
|
||||||
class="p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
|
|
||||||
title="新建文件夹"
|
|
||||||
@click="newFolder"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Expanded content -->
|
|
||||||
<div v-if="!collapsed" class="flex flex-col flex-1 overflow-hidden">
|
|
||||||
<!-- File tree -->
|
<!-- File tree -->
|
||||||
<div class="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
|
<div class="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
|
||||||
<!-- Folder path indicator -->
|
<!-- Folder path indicator -->
|
||||||
<div
|
<div
|
||||||
v-if="folderPath"
|
v-if="folderPath"
|
||||||
class="px-2 py-1 text-xs text-gray-500 truncate mb-2 border-b border-gray-800/50 pb-2"
|
class="px-2 py-1 text-xs text-[#b8b3a8] truncate mb-2 border-b border-[#e8e4da] pb-2"
|
||||||
:title="folderPath"
|
:title="folderPath"
|
||||||
>
|
>
|
||||||
📁 {{ folderPath.split(/[/\\]/).pop() || folderPath }}
|
📁 {{ folderPath.split(/[/\\]/).pop() || folderPath }}
|
||||||
@@ -160,133 +158,126 @@ const removeTag = (tag: string) => {
|
|||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div
|
<div
|
||||||
v-if="!folderPath"
|
v-if="!folderPath"
|
||||||
class="px-2 py-4 text-xs text-gray-500 text-center"
|
class="px-2 py-4 text-xs text-[#b8b3a8] text-center"
|
||||||
>
|
>
|
||||||
点击 文件 → 打开文件夹 或按 Ctrl+O
|
<p class="mb-3">暂无打开的文件夹</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-for="entry in entries" :key="entry.path">
|
|
||||||
<!-- Folder -->
|
|
||||||
<div
|
|
||||||
v-if="entry.is_dir"
|
|
||||||
class="flex items-center gap-1 px-2 py-1 rounded-md text-sm text-gray-300 hover:bg-gray-800 cursor-pointer select-none"
|
|
||||||
@click="toggleFolder({ id: entry.path, name: entry.name, type: 'folder', path: entry.path })"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="w-3.5 h-3.5 text-gray-500 transition-transform"
|
|
||||||
:class="expandedFolders.has(entry.path) ? 'rotate-90' : ''"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="w-4 h-4 text-yellow-500/70"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
||||||
</svg>
|
|
||||||
<span class="truncate">{{ entry.name }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- File -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-sm text-gray-400 hover:bg-gray-800 hover:text-gray-200 cursor-pointer"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="w-3.5 h-3.5 text-indigo-400/60"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
<span class="truncate">{{ entry.name }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<div class="flex gap-1 px-3 py-2 border-t border-gray-800/60">
|
|
||||||
<button
|
<button
|
||||||
class="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs text-gray-400 hover:text-white hover:bg-gray-800 rounded-md transition-colors"
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-[#fdf0e5] text-[#bf6a3b] border border-[#d4a574]/40 hover:bg-[#fdf0e5]/80 hover:text-[#bf6a3b] transition-colors"
|
||||||
@click="newFile"
|
@click="$emit('openFolder')"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<i class="ri-folder-open-line"></i>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
打开文件夹
|
||||||
</svg>
|
|
||||||
新建文件
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs text-gray-400 hover:text-white hover:bg-gray-800 rounded-md transition-colors"
|
|
||||||
@click="newFolder"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
新建文件夹
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags section -->
|
<TreeEntry
|
||||||
<div class="border-t border-gray-800/60 px-3 py-3">
|
v-for="entry in entries"
|
||||||
<div class="flex items-center justify-between mb-2">
|
:key="entry.path"
|
||||||
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider">标签</span>
|
:entry="entry"
|
||||||
<button
|
:depth="0"
|
||||||
class="text-gray-500 hover:text-gray-300 transition-colors"
|
@toggle-folder="(path: string) => emit('toggleFolder', path)"
|
||||||
@click="addingTag = !addingTag"
|
@open-file="(path: string) => emit('openFile', path)"
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add tag input -->
|
|
||||||
<div v-if="addingTag" class="flex gap-1 mb-2">
|
|
||||||
<input
|
|
||||||
v-model="newTagInput"
|
|
||||||
class="flex-1 px-2 py-1 text-xs bg-gray-800 border border-gray-700 rounded-md text-gray-200 outline-none focus:border-indigo-500"
|
|
||||||
placeholder="标签名称"
|
|
||||||
@keyup.enter="addTag"
|
|
||||||
/>
|
/>
|
||||||
<button
|
</div>
|
||||||
class="px-2 py-1 text-xs bg-indigo-600 text-white rounded-md hover:bg-indigo-500 transition-colors"
|
|
||||||
@click="addTag"
|
|
||||||
>
|
|
||||||
确定
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tag list -->
|
<!-- ===== SEARCH VIEW ===== -->
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div v-if="activeView === 'search'" class="flex flex-col flex-1 overflow-hidden">
|
||||||
<span
|
<div class="px-3 py-3">
|
||||||
v-for="tag in tags"
|
<div class="relative">
|
||||||
:key="tag"
|
<i class="ri-search-line absolute left-2.5 top-1/2 -translate-y-1/2 text-sm text-[#b8b3a8]"></i>
|
||||||
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-gray-800 text-gray-300 border border-gray-700/50 group"
|
<input
|
||||||
>
|
class="w-full pl-8 pr-3 py-1.5 text-xs bg-[#ede8de] border border-[#e0dbcf] rounded-md text-[#38342e] outline-none focus:border-[#bf6a3b] placeholder-[#b8b3a8]"
|
||||||
{{ tag }}
|
placeholder="搜索文件内容..."
|
||||||
<button
|
disabled
|
||||||
class="text-gray-600 hover:text-red-400 transition-colors"
|
/>
|
||||||
@click="removeTag(tag)"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-1 flex items-center justify-center px-3 pb-8">
|
||||||
|
<p class="text-xs text-[#b8b3a8] text-center leading-relaxed">
|
||||||
|
搜索功能即将上线
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== OUTLINE VIEW ===== -->
|
||||||
|
<div v-if="activeView === 'outline'" class="flex-1 overflow-y-auto px-2 py-2">
|
||||||
|
<template v-if="!currentFilePath">
|
||||||
|
<p class="text-xs text-[#b8b3a8] px-2 py-4 text-center leading-relaxed">
|
||||||
|
暂无打开的文件
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="headings.length === 0">
|
||||||
|
<p class="text-xs text-[#b8b3a8] px-2 py-4 text-center leading-relaxed">
|
||||||
|
暂无标题<br />使用 H1-H3 标题<br />自动生成大纲
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="item in headings"
|
||||||
|
:key="item.id"
|
||||||
|
:class="[
|
||||||
|
'px-2 py-1 rounded-md text-sm cursor-pointer hover:bg-[#ede8de] transition-colors truncate',
|
||||||
|
item.level === 1
|
||||||
|
? 'text-[#38342e] font-medium ml-0'
|
||||||
|
: item.level === 2
|
||||||
|
? 'text-[#8c877d] ml-3'
|
||||||
|
: 'text-[#b8b3a8] ml-6 text-xs',
|
||||||
|
]"
|
||||||
|
:title="item.text"
|
||||||
|
@click="onHeadingClick(item)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle',
|
||||||
|
item.level === 1
|
||||||
|
? 'bg-[#bf6a3b]'
|
||||||
|
: item.level === 2
|
||||||
|
? 'bg-[#7ba587]/60'
|
||||||
|
: 'bg-gray-500',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
{{ item.text }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== BOTTOM: Settings ===== -->
|
||||||
|
<div class="border-t border-[#e8e4da] px-3 py-2">
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 px-2 py-1.5 text-xs text-[#8c877d] hover:text-[#38342e] hover:bg-[#ede8de] rounded-md transition-colors"
|
||||||
|
@click="showSettings = true"
|
||||||
|
>
|
||||||
|
<i class="ri-settings-line text-sm"></i>
|
||||||
|
设置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ===== SETTINGS MODAL ===== -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showSettings"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-[#38342e]/25 backdrop-blur-sm"
|
||||||
|
@click.self="showSettings = false"
|
||||||
|
>
|
||||||
|
<div class="bg-white border border-[#e0dbcf] rounded-xl shadow-2xl w-80 max-h-[70vh] overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-[#e8e4da]">
|
||||||
|
<span class="text-sm font-semibold text-[#38342e]">设置</span>
|
||||||
|
<button
|
||||||
|
class="p-1 rounded-md text-[#8c877d] hover:text-[#38342e] hover:bg-[#e0dbcf] transition-colors"
|
||||||
|
@click="showSettings = false"
|
||||||
|
>
|
||||||
|
<i class="ri-close-line text-base"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-6">
|
||||||
|
<p class="text-xs text-[#b8b3a8] text-center leading-relaxed">
|
||||||
|
设置功能即将上线
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { watch } from "vue";
|
|||||||
import { useEditor, EditorContent } from "@tiptap/vue-3";
|
import { useEditor, EditorContent } from "@tiptap/vue-3";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { Markdown } from "@tiptap/markdown";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -29,6 +30,7 @@ const editor = useEditor({
|
|||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: props.placeholder,
|
placeholder: props.placeholder,
|
||||||
}),
|
}),
|
||||||
|
Markdown,
|
||||||
],
|
],
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -45,7 +47,7 @@ const editor = useEditor({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
emit("update:modelValue", editor.getHTML());
|
emit("update:modelValue", editor.getMarkdown());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,8 +55,8 @@ const editor = useEditor({
|
|||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(val) => {
|
(val) => {
|
||||||
if (editor.value && val !== editor.value.getHTML()) {
|
if (editor.value && val !== editor.value.getMarkdown()) {
|
||||||
editor.value.commands.setContent(val, { emitUpdate: false });
|
editor.value.commands.setContent(val, { contentType: "markdown", emitUpdate: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -62,12 +64,12 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col flex-1 w-full border border-gray-700 bg-gray-900 shadow-2xl overflow-hidden"
|
class="flex flex-col flex-1 w-full border border-[#e0dbcf] bg-white shadow-2xl overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div
|
<div
|
||||||
v-if="editor"
|
v-if="editor"
|
||||||
class="flex flex-wrap items-center gap-0.5 px-3 py-2 border-b border-gray-700 bg-gray-800/80 backdrop-blur"
|
class="flex flex-wrap items-center gap-0.5 px-3 py-2 border-b border-[#e0dbcf] bg-[#f4f1eb]/80 backdrop-blur"
|
||||||
>
|
>
|
||||||
<!-- Headings -->
|
<!-- Headings -->
|
||||||
<button
|
<button
|
||||||
@@ -76,140 +78,140 @@ watch(
|
|||||||
:class="[
|
:class="[
|
||||||
'px-2.5 py-1.5 text-xs font-semibold rounded-md transition-colors',
|
'px-2.5 py-1.5 text-xs font-semibold rounded-md transition-colors',
|
||||||
editor.isActive('heading', { level })
|
editor.isActive('heading', { level })
|
||||||
? 'bg-indigo-600 text-white'
|
? 'bg-[#bf6a3b] text-white'
|
||||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
: 'text-[#5c574e] hover:bg-[#e0dbcf] hover:text-[#38342e]',
|
||||||
]"
|
]"
|
||||||
@click="editor.chain().focus().toggleHeading({ level }).run()"
|
@click="editor.chain().focus().toggleHeading({ level }).run()"
|
||||||
>
|
>
|
||||||
H{{ level }}
|
H{{ level }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span class="w-px h-6 bg-gray-600 mx-1" />
|
<span class="w-px h-6 bg-[#d4cfc4] mx-1" />
|
||||||
|
|
||||||
<!-- Inline formatting -->
|
<!-- Inline formatting -->
|
||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||||
editor.isActive('bold')
|
editor.isActive('bold')
|
||||||
? 'bg-indigo-600 text-white'
|
? 'bg-[#bf6a3b] text-white'
|
||||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
: 'text-[#5c574e] hover:bg-[#e0dbcf] hover:text-[#38342e]',
|
||||||
]"
|
]"
|
||||||
@click="editor.chain().focus().toggleBold().run()"
|
@click="editor.chain().focus().toggleBold().run()"
|
||||||
>
|
>
|
||||||
<strong>B</strong>
|
<i class="ri-bold"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors italic',
|
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||||
editor.isActive('italic')
|
editor.isActive('italic')
|
||||||
? 'bg-indigo-600 text-white'
|
? 'bg-[#bf6a3b] text-white'
|
||||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
: 'text-[#5c574e] hover:bg-[#e0dbcf] hover:text-[#38342e]',
|
||||||
]"
|
]"
|
||||||
@click="editor.chain().focus().toggleItalic().run()"
|
@click="editor.chain().focus().toggleItalic().run()"
|
||||||
>
|
>
|
||||||
I
|
<i class="ri-italic"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors line-through',
|
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||||
editor.isActive('strike')
|
editor.isActive('strike')
|
||||||
? 'bg-indigo-600 text-white'
|
? 'bg-[#bf6a3b] text-white'
|
||||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
: 'text-[#5c574e] hover:bg-[#e0dbcf] hover:text-[#38342e]',
|
||||||
]"
|
]"
|
||||||
@click="editor.chain().focus().toggleStrike().run()"
|
@click="editor.chain().focus().toggleStrike().run()"
|
||||||
>
|
>
|
||||||
S
|
<i class="ri-strikethrough"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'px-2.5 py-1.5 text-xs font-mono font-medium rounded-md transition-colors',
|
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||||
editor.isActive('code')
|
editor.isActive('code')
|
||||||
? 'bg-indigo-600 text-white'
|
? 'bg-[#bf6a3b] text-white'
|
||||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
: 'text-[#5c574e] hover:bg-[#e0dbcf] hover:text-[#38342e]',
|
||||||
]"
|
]"
|
||||||
@click="editor.chain().focus().toggleCode().run()"
|
@click="editor.chain().focus().toggleCode().run()"
|
||||||
>
|
>
|
||||||
</>
|
<i class="ri-code-line"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span class="w-px h-6 bg-gray-600 mx-1" />
|
<span class="w-px h-6 bg-[#d4cfc4] mx-1" />
|
||||||
|
|
||||||
<!-- Block elements -->
|
<!-- Block elements -->
|
||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||||
editor.isActive('blockquote')
|
editor.isActive('blockquote')
|
||||||
? 'bg-indigo-600 text-white'
|
? 'bg-[#bf6a3b] text-white'
|
||||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
: 'text-[#5c574e] hover:bg-[#e0dbcf] hover:text-[#38342e]',
|
||||||
]"
|
]"
|
||||||
@click="editor.chain().focus().toggleBlockquote().run()"
|
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||||
>
|
>
|
||||||
"Quote"
|
<i class="ri-double-quotes-l"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'px-2.5 py-1.5 text-xs font-mono font-medium rounded-md transition-colors',
|
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||||
editor.isActive('codeBlock')
|
editor.isActive('codeBlock')
|
||||||
? 'bg-indigo-600 text-white'
|
? 'bg-[#bf6a3b] text-white'
|
||||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
: 'text-[#5c574e] hover:bg-[#e0dbcf] hover:text-[#38342e]',
|
||||||
]"
|
]"
|
||||||
@click="editor.chain().focus().toggleCodeBlock().run()"
|
@click="editor.chain().focus().toggleCodeBlock().run()"
|
||||||
>
|
>
|
||||||
Code
|
<i class="ri-code-box-line"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span class="w-px h-6 bg-gray-600 mx-1" />
|
<span class="w-px h-6 bg-[#d4cfc4] mx-1" />
|
||||||
|
|
||||||
<!-- Lists -->
|
<!-- Lists -->
|
||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||||
editor.isActive('bulletList')
|
editor.isActive('bulletList')
|
||||||
? 'bg-indigo-600 text-white'
|
? 'bg-[#bf6a3b] text-white'
|
||||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
: 'text-[#5c574e] hover:bg-[#e0dbcf] hover:text-[#38342e]',
|
||||||
]"
|
]"
|
||||||
@click="editor.chain().focus().toggleBulletList().run()"
|
@click="editor.chain().focus().toggleBulletList().run()"
|
||||||
>
|
>
|
||||||
• List
|
<i class="ri-list-unordered"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||||
editor.isActive('orderedList')
|
editor.isActive('orderedList')
|
||||||
? 'bg-indigo-600 text-white'
|
? 'bg-[#bf6a3b] text-white'
|
||||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
: 'text-[#5c574e] hover:bg-[#e0dbcf] hover:text-[#38342e]',
|
||||||
]"
|
]"
|
||||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||||
>
|
>
|
||||||
1. List
|
<i class="ri-list-ordered"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span class="w-px h-6 bg-gray-600 mx-1" />
|
<span class="w-px h-6 bg-[#d4cfc4] mx-1" />
|
||||||
|
|
||||||
<!-- Horizontal rule -->
|
<!-- Horizontal rule -->
|
||||||
<button
|
<button
|
||||||
class="px-2.5 py-1.5 text-xs font-medium rounded-md text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
|
class="px-2.5 py-1.5 text-xs font-medium rounded-md text-[#5c574e] hover:bg-[#e0dbcf] hover:text-[#38342e] transition-colors"
|
||||||
@click="editor.chain().focus().setHorizontalRule().run()"
|
@click="editor.chain().focus().setHorizontalRule().run()"
|
||||||
>
|
>
|
||||||
──
|
<i class="ri-separator"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span class="flex-1" />
|
<span class="flex-1" />
|
||||||
|
|
||||||
<!-- Undo / Redo -->
|
<!-- Undo / Redo -->
|
||||||
<button
|
<button
|
||||||
class="px-2.5 py-1.5 text-xs font-medium rounded-md text-gray-300 hover:bg-gray-700 hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
class="px-2.5 py-1.5 text-xs font-medium rounded-md text-[#5c574e] hover:bg-[#e0dbcf] hover:text-[#38342e] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
:disabled="!editor.can().chain().focus().undo().run()"
|
:disabled="!editor.can().chain().focus().undo().run()"
|
||||||
@click="editor.chain().focus().undo().run()"
|
@click="editor.chain().focus().undo().run()"
|
||||||
>
|
>
|
||||||
↶
|
<i class="ri-arrow-go-back-line"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="px-2.5 py-1.5 text-xs font-medium rounded-md text-gray-300 hover:bg-gray-700 hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
class="px-2.5 py-1.5 text-xs font-medium rounded-md text-[#5c574e] hover:bg-[#e0dbcf] hover:text-[#38342e] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
:disabled="!editor.can().chain().focus().redo().run()"
|
:disabled="!editor.can().chain().focus().redo().run()"
|
||||||
@click="editor.chain().focus().redo().run()"
|
@click="editor.chain().focus().redo().run()"
|
||||||
>
|
>
|
||||||
↷
|
<i class="ri-arrow-go-forward-line"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -221,14 +223,15 @@ watch(
|
|||||||
<style>
|
<style>
|
||||||
/* TipTap / ProseMirror content styling */
|
/* TipTap / ProseMirror content styling */
|
||||||
.tiptap-editor .ProseMirror {
|
.tiptap-editor .ProseMirror {
|
||||||
color: #f3f4f6;
|
color: #38342e;
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||||
color: #6b7280;
|
color: #b8b3a8;
|
||||||
float: left;
|
float: left;
|
||||||
height: 0;
|
height: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -241,37 +244,37 @@ watch(
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
color: #fff;
|
color: #2d2a26;
|
||||||
}
|
}
|
||||||
.tiptap-editor .ProseMirror h2 {
|
.tiptap-editor .ProseMirror h2 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: #fff;
|
color: #2d2a26;
|
||||||
}
|
}
|
||||||
.tiptap-editor .ProseMirror h3 {
|
.tiptap-editor .ProseMirror h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: #fff;
|
color: #2d2a26;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blockquote */
|
/* Blockquote */
|
||||||
.tiptap-editor .ProseMirror blockquote {
|
.tiptap-editor .ProseMirror blockquote {
|
||||||
border-left: 4px solid #6366f1;
|
border-left: 4px solid #bf6a3b;
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
color: #9ca3af;
|
color: #8c877d;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline code */
|
/* Inline code */
|
||||||
.tiptap-editor .ProseMirror code {
|
.tiptap-editor .ProseMirror code {
|
||||||
background-color: #1f2937;
|
background-color: #f4f1ea;
|
||||||
color: #f472b6;
|
color: #bf6a3b;
|
||||||
padding: 0.125rem 0.375rem;
|
padding: 0.125rem 0.375rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -280,7 +283,7 @@ watch(
|
|||||||
|
|
||||||
/* Code block */
|
/* Code block */
|
||||||
.tiptap-editor .ProseMirror pre {
|
.tiptap-editor .ProseMirror pre {
|
||||||
background-color: #1f2937;
|
background-color: #f4f1ea;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
@@ -289,7 +292,7 @@ watch(
|
|||||||
}
|
}
|
||||||
.tiptap-editor .ProseMirror pre code {
|
.tiptap-editor .ProseMirror pre code {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: #e5e7eb;
|
color: #5c574e;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,28 +316,28 @@ watch(
|
|||||||
|
|
||||||
/* Horizontal rule */
|
/* Horizontal rule */
|
||||||
.tiptap-editor .ProseMirror hr {
|
.tiptap-editor .ProseMirror hr {
|
||||||
border-color: #374151;
|
border-color: #e8e4da;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Links */
|
/* Links */
|
||||||
.tiptap-editor .ProseMirror a {
|
.tiptap-editor .ProseMirror a {
|
||||||
color: #818cf8;
|
color: #bf6a3b;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bold / Italic / Strike */
|
/* Bold / Italic / Strike */
|
||||||
.tiptap-editor .ProseMirror strong {
|
.tiptap-editor .ProseMirror strong {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #2d2a26;
|
||||||
}
|
}
|
||||||
.tiptap-editor .ProseMirror em {
|
.tiptap-editor .ProseMirror em {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.tiptap-editor .ProseMirror s {
|
.tiptap-editor .ProseMirror s {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
color: #9ca3af;
|
color: #8c877d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Paragraph spacing */
|
/* Paragraph spacing */
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ const onHeadingClick = (item: OutlineItem) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
class="flex flex-col border-r border-gray-800 bg-gray-900/60 w-48 shrink-0 overflow-hidden"
|
class="flex flex-col border-r border-[#e8e4da] bg-[#f4f1eb]/80 w-48 shrink-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center px-3 py-3 border-b border-gray-800">
|
<div class="flex items-center px-3 py-3 border-b border-[#e8e4da]">
|
||||||
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
<span class="text-xs font-semibold text-[#8c877d] uppercase tracking-wider">
|
||||||
大纲
|
大纲
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +54,7 @@ const onHeadingClick = (item: OutlineItem) => {
|
|||||||
<!-- Outline content -->
|
<!-- Outline content -->
|
||||||
<div class="flex-1 overflow-y-auto px-2 py-2">
|
<div class="flex-1 overflow-y-auto px-2 py-2">
|
||||||
<template v-if="headings.length === 0">
|
<template v-if="headings.length === 0">
|
||||||
<p class="text-xs text-gray-600 px-2 py-4 text-center leading-relaxed">
|
<p class="text-xs text-[#b8b3a8] px-2 py-4 text-center leading-relaxed">
|
||||||
暂无标题<br />使用 H1-H3 标题<br />自动生成大纲
|
暂无标题<br />使用 H1-H3 标题<br />自动生成大纲
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
@@ -63,12 +63,12 @@ const onHeadingClick = (item: OutlineItem) => {
|
|||||||
v-for="item in headings"
|
v-for="item in headings"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:class="[
|
:class="[
|
||||||
'px-2 py-1 rounded-md text-sm cursor-pointer hover:bg-gray-800 transition-colors truncate',
|
'px-2 py-1 rounded-md text-sm cursor-pointer hover:bg-[#ede8de] transition-colors truncate',
|
||||||
item.level === 1
|
item.level === 1
|
||||||
? 'text-gray-200 font-medium ml-0'
|
? 'text-[#38342e] font-medium ml-0'
|
||||||
: item.level === 2
|
: item.level === 2
|
||||||
? 'text-gray-400 ml-3'
|
? 'text-[#8c877d] ml-3'
|
||||||
: 'text-gray-500 ml-6 text-xs',
|
: 'text-[#b8b3a8] ml-6 text-xs',
|
||||||
]"
|
]"
|
||||||
:title="item.text"
|
:title="item.text"
|
||||||
@click="onHeadingClick(item)"
|
@click="onHeadingClick(item)"
|
||||||
@@ -78,9 +78,9 @@ const onHeadingClick = (item: OutlineItem) => {
|
|||||||
:class="[
|
:class="[
|
||||||
'inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle',
|
'inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle',
|
||||||
item.level === 1
|
item.level === 1
|
||||||
? 'bg-indigo-400'
|
? 'bg-[#bf6a3b]'
|
||||||
: item.level === 2
|
: item.level === 2
|
||||||
? 'bg-emerald-400/60'
|
? 'bg-[#7ba587]/60'
|
||||||
: 'bg-gray-500',
|
: 'bg-gray-500',
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|||||||
93
src/components/TreeEntry.vue
Normal file
93
src/components/TreeEntry.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Named export for recursive self-reference
|
||||||
|
export default { name: "TreeEntry" };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
|
interface DirEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
is_dir: boolean;
|
||||||
|
children?: DirEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entry: DirEntry;
|
||||||
|
depth: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggleFolder: [path: string];
|
||||||
|
openFile: [path: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const expanded = ref(false);
|
||||||
|
|
||||||
|
// Auto-expand when children are loaded for the first time
|
||||||
|
watch(
|
||||||
|
() => props.entry.children,
|
||||||
|
(children, oldChildren) => {
|
||||||
|
if (children !== undefined && oldChildren === undefined) {
|
||||||
|
expanded.value = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
if (props.entry.is_dir) {
|
||||||
|
if (props.entry.children === undefined) {
|
||||||
|
// Not loaded yet — ask parent to load
|
||||||
|
emit("toggleFolder", props.entry.path);
|
||||||
|
} else {
|
||||||
|
// Already loaded — toggle visibility
|
||||||
|
expanded.value = !expanded.value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emit("openFile", props.entry.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Folder -->
|
||||||
|
<div
|
||||||
|
v-if="entry.is_dir"
|
||||||
|
class="flex items-center gap-1 px-2 py-1 rounded-md text-sm text-[#5c574e] hover:bg-[#ede8de] cursor-pointer select-none"
|
||||||
|
:style="{ paddingLeft: `${depth * 16 + 8}px` }"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="ri-arrow-right-s-line text-sm text-[#b8b3a8] transition-transform shrink-0"
|
||||||
|
:class="expanded && entry.children ? 'rotate-90' : ''"
|
||||||
|
></i>
|
||||||
|
<i class="ri-folder-line text-base text-[#bf6a3b]/70 shrink-0"></i>
|
||||||
|
<span class="truncate">{{ entry.name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Children (recursive) -->
|
||||||
|
<template v-if="entry.is_dir && expanded && entry.children">
|
||||||
|
<TreeEntry
|
||||||
|
v-for="child in entry.children"
|
||||||
|
:key="child.path"
|
||||||
|
:entry="child"
|
||||||
|
:depth="depth + 1"
|
||||||
|
@toggle-folder="(path: string) => emit('toggleFolder', path)"
|
||||||
|
@open-file="(path: string) => emit('openFile', path)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- File -->
|
||||||
|
<div
|
||||||
|
v-else-if="!entry.is_dir"
|
||||||
|
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-sm text-[#8c877d] hover:bg-[#ede8de] hover:text-[#38342e] cursor-pointer"
|
||||||
|
:style="{ paddingLeft: `${depth * 16 + 8}px` }"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<i class="ri-file-line text-sm text-[#bf6a3b]/60 shrink-0"></i>
|
||||||
|
<span class="truncate">{{ entry.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import "./tailwind.css";
|
import "./tailwind.css";
|
||||||
|
import "remixicon/fonts/remixicon.css";
|
||||||
|
|
||||||
createApp(App).mount("#app");
|
createApp(App).mount("#app");
|
||||||
|
|||||||
@@ -1,2 +1,65 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@source "../src/**/*.vue";
|
@source "../src/**/*.vue";
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Global base styles — "Soft Stone" light theme
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
html {
|
||||||
|
/* ── Font stack: refined system serif for Chinese + English ── */
|
||||||
|
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
||||||
|
"Noto Sans SC", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #faf9f6;
|
||||||
|
color: #38342e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Selection ── */
|
||||||
|
::selection {
|
||||||
|
background: #e8d5b0;
|
||||||
|
color: #38342e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Scrollbar — thin, elegant, matches light theme
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #d4cfc4 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WebKit (Chrome, Edge, Safari, Tauri WebView2) */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #d4cfc4;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #b0a99b;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:active {
|
||||||
|
background: #9a9282;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user