first commit
This commit is contained in:
132
src/App.vue
Normal file
132
src/App.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import MarkdownEditor from "./components/MarkdownEditor.vue";
|
||||
import DirectorySidebar from "./components/DirectorySidebar.vue";
|
||||
import OutlinePanel from "./components/OutlinePanel.vue";
|
||||
|
||||
const content = ref("");
|
||||
const wordCount = ref(0);
|
||||
const charCount = ref(0);
|
||||
|
||||
// Panel state
|
||||
const showOutline = ref(true);
|
||||
|
||||
function onUpdate(html: string) {
|
||||
content.value = html;
|
||||
// Strip HTML tags for counting
|
||||
const text = html.replace(/<[^>]*>/g, "");
|
||||
charCount.value = text.length;
|
||||
wordCount.value = text.trim() ? text.trim().split(/\s+/).length : 0;
|
||||
}
|
||||
|
||||
function onHeadingClick(heading: { text: string; level: number }) {
|
||||
// Navigate to heading in the editor — find the heading element and scroll it into view
|
||||
const editorEl = document.querySelector(".tiptap-editor .ProseMirror");
|
||||
if (!editorEl) return;
|
||||
|
||||
const headingTags = editorEl.querySelectorAll("h1, h2, h3");
|
||||
for (const el of headingTags) {
|
||||
if (el.textContent?.trim() === heading.text && el.tagName === `H${heading.level}`) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-950 text-gray-100 flex flex-col">
|
||||
<!-- Header -->
|
||||
<header
|
||||
class="flex items-center justify-between px-6 py-3 border-b border-gray-800 bg-gray-900/70 backdrop-blur-md sticky top-0 z-10"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-sm font-bold"
|
||||
>
|
||||
M
|
||||
</div>
|
||||
<h1 class="text-lg font-semibold tracking-tight">Markdown Editor</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 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" />
|
||||
|
||||
<span class="text-xs text-gray-400">{{ wordCount.toLocaleString() }} 词</span>
|
||||
<span class="w-px h-3 bg-gray-600" />
|
||||
<span class="text-xs text-gray-400">{{ charCount.toLocaleString() }} 字</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Three-column body -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Left: Directory sidebar -->
|
||||
<DirectorySidebar />
|
||||
|
||||
<!-- Middle: Outline panel -->
|
||||
<transition name="outline-slide">
|
||||
<OutlinePanel
|
||||
v-if="showOutline"
|
||||
:html-content="content"
|
||||
@heading-click="onHeadingClick"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
<!-- Right: Editor area -->
|
||||
<main class="flex-1 flex justify-center overflow-y-auto px-4 py-8">
|
||||
<div class="w-full max-w-4xl">
|
||||
<MarkdownEditor
|
||||
v-model="content"
|
||||
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
||||
@update:model-value="onUpdate"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer
|
||||
class="text-center text-xs text-gray-600 py-4 border-t border-gray-800/50"
|
||||
>
|
||||
基于 TipTap · 支持 Markdown 快捷输入
|
||||
</footer>
|
||||
</div>
|
||||
</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
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
308
src/components/DirectorySidebar.vue
Normal file
308
src/components/DirectorySidebar.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
const collapsed = ref(false);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
collapsed.value = !collapsed.value;
|
||||
};
|
||||
|
||||
// ---- file tree state ----
|
||||
interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "file" | "folder";
|
||||
children?: FileItem[];
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
const files = ref<FileItem[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: "文档",
|
||||
type: "folder",
|
||||
expanded: true,
|
||||
children: [
|
||||
{ id: "2", name: "README.md", type: "file" },
|
||||
{ id: "3", name: "笔记.md", type: "file" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "工作",
|
||||
type: "folder",
|
||||
expanded: false,
|
||||
children: [{ id: "5", name: "项目计划.md", type: "file" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const toggleFolder = (item: FileItem) => {
|
||||
if (item.type === "folder") {
|
||||
item.expanded = !item.expanded;
|
||||
}
|
||||
};
|
||||
|
||||
const newFile = () => {
|
||||
const name = prompt("文件名称(含扩展名):");
|
||||
if (name) {
|
||||
files.value.push({ id: crypto.randomUUID(), name, type: "file" });
|
||||
}
|
||||
};
|
||||
|
||||
const newFolder = () => {
|
||||
const name = prompt("文件夹名称:");
|
||||
if (name) {
|
||||
files.value.push({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
type: "folder",
|
||||
expanded: true,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ---- tag state ----
|
||||
const tags = ref(["技术", "随笔", "待办", "灵感"]);
|
||||
const newTagInput = ref("");
|
||||
const addingTag = ref(false);
|
||||
|
||||
const addTag = () => {
|
||||
const t = newTagInput.value.trim();
|
||||
if (t && !tags.value.includes(t)) {
|
||||
tags.value.push(t);
|
||||
}
|
||||
newTagInput.value = "";
|
||||
addingTag.value = false;
|
||||
};
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
tags.value = tags.value.filter((t) => t !== tag);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
:class="[
|
||||
'flex flex-col border-r border-gray-800 bg-gray-900/60 transition-all duration-300 ease-in-out overflow-hidden shrink-0',
|
||||
collapsed ? 'w-12' : 'w-56',
|
||||
]"
|
||||
>
|
||||
<!-- Header / toggle -->
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-3 border-b border-gray-800"
|
||||
:class="collapsed ? 'justify-center' : ''"
|
||||
>
|
||||
<span v-if="!collapsed" class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
目录
|
||||
</span>
|
||||
<button
|
||||
class="p-1 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
|
||||
@click="toggleCollapsed"
|
||||
>
|
||||
<!-- hamburger / panel-close icon -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Collapsed icon-only view -->
|
||||
<div v-if="collapsed" class="flex flex-col items-center gap-1 py-3">
|
||||
<!-- 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 -->
|
||||
<div class="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
|
||||
<div
|
||||
v-for="item in files"
|
||||
:key="item.id"
|
||||
>
|
||||
<!-- Folder -->
|
||||
<div
|
||||
v-if="item.type === 'folder'"
|
||||
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(item)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-3.5 h-3.5 text-gray-500 transition-transform"
|
||||
:class="item.expanded ? '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">{{ item.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Folder children -->
|
||||
<div
|
||||
v-if="item.type === 'folder' && item.expanded"
|
||||
class="ml-4 space-y-0.5"
|
||||
>
|
||||
<div
|
||||
v-for="child in item.children"
|
||||
:key="child.id"
|
||||
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">{{ child.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top-level file -->
|
||||
<div
|
||||
v-else-if="item.type === 'file'"
|
||||
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">{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-1 px-3 py-2 border-t border-gray-800/60">
|
||||
<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="newFile"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Tags section -->
|
||||
<div class="border-t border-gray-800/60 px-3 py-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider">标签</span>
|
||||
<button
|
||||
class="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
@click="addingTag = !addingTag"
|
||||
>
|
||||
<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
|
||||
class="px-2 py-1 text-xs bg-indigo-600 text-white rounded-md hover:bg-indigo-500 transition-colors"
|
||||
@click="addTag"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tag list -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
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"
|
||||
>
|
||||
{{ tag }}
|
||||
<button
|
||||
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>
|
||||
</aside>
|
||||
</template>
|
||||
336
src/components/MarkdownEditor.vue
Normal file
336
src/components/MarkdownEditor.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from "vue";
|
||||
import { useEditor, EditorContent } from "@tiptap/vue-3";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{
|
||||
modelValue: "",
|
||||
placeholder: "开始写作...",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
}>();
|
||||
|
||||
const editor = useEditor({
|
||||
content: props.modelValue,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: props.placeholder,
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"min-h-[320px] px-6 py-4 outline-none focus:outline-none",
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
emit("update:modelValue", editor.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
// Sync external modelValue changes back into the editor
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (editor.value && val !== editor.value.getHTML()) {
|
||||
editor.value.commands.setContent(val, { emitUpdate: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col rounded-xl border border-gray-700 bg-gray-900 shadow-2xl overflow-hidden"
|
||||
>
|
||||
<!-- Toolbar -->
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<!-- Headings -->
|
||||
<button
|
||||
v-for="level in ([1, 2, 3] as const)"
|
||||
:key="'h' + level"
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-semibold rounded-md transition-colors',
|
||||
editor.isActive('heading', { level })
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleHeading({ level }).run()"
|
||||
>
|
||||
H{{ level }}
|
||||
</button>
|
||||
|
||||
<span class="w-px h-6 bg-gray-600 mx-1" />
|
||||
|
||||
<!-- Inline formatting -->
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||
editor.isActive('bold')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors italic',
|
||||
editor.isActive('italic')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleItalic().run()"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors line-through',
|
||||
editor.isActive('strike')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleStrike().run()"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-mono font-medium rounded-md transition-colors',
|
||||
editor.isActive('code')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleCode().run()"
|
||||
>
|
||||
</>
|
||||
</button>
|
||||
|
||||
<span class="w-px h-6 bg-gray-600 mx-1" />
|
||||
|
||||
<!-- Block elements -->
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||
editor.isActive('blockquote')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||
>
|
||||
"Quote"
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-mono font-medium rounded-md transition-colors',
|
||||
editor.isActive('codeBlock')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleCodeBlock().run()"
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
|
||||
<span class="w-px h-6 bg-gray-600 mx-1" />
|
||||
|
||||
<!-- Lists -->
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||
editor.isActive('bulletList')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleBulletList().run()"
|
||||
>
|
||||
• List
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||
editor.isActive('orderedList')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||
>
|
||||
1. List
|
||||
</button>
|
||||
|
||||
<span class="w-px h-6 bg-gray-600 mx-1" />
|
||||
|
||||
<!-- Horizontal rule -->
|
||||
<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"
|
||||
@click="editor.chain().focus().setHorizontalRule().run()"
|
||||
>
|
||||
──
|
||||
</button>
|
||||
|
||||
<span class="flex-1" />
|
||||
|
||||
<!-- Undo / Redo -->
|
||||
<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"
|
||||
:disabled="!editor.can().chain().focus().undo().run()"
|
||||
@click="editor.chain().focus().undo().run()"
|
||||
>
|
||||
↶
|
||||
</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"
|
||||
:disabled="!editor.can().chain().focus().redo().run()"
|
||||
@click="editor.chain().focus().redo().run()"
|
||||
>
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Editor content -->
|
||||
<EditorContent :editor="editor" class="tiptap-editor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* TipTap / ProseMirror content styling */
|
||||
.tiptap-editor .ProseMirror {
|
||||
color: #f3f4f6;
|
||||
min-height: 320px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: #6b7280;
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.tiptap-editor .ProseMirror h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #fff;
|
||||
}
|
||||
.tiptap-editor .ProseMirror h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
.tiptap-editor .ProseMirror h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Blockquote */
|
||||
.tiptap-editor .ProseMirror blockquote {
|
||||
border-left: 4px solid #6366f1;
|
||||
padding-left: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.tiptap-editor .ProseMirror code {
|
||||
background-color: #1f2937;
|
||||
color: #f472b6;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Code block */
|
||||
.tiptap-editor .ProseMirror pre {
|
||||
background-color: #1f2937;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.tiptap-editor .ProseMirror pre code {
|
||||
background-color: transparent;
|
||||
color: #e5e7eb;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.tiptap-editor .ProseMirror ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.tiptap-editor .ProseMirror ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.tiptap-editor .ProseMirror li {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Horizontal rule */
|
||||
.tiptap-editor .ProseMirror hr {
|
||||
border-color: #374151;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.tiptap-editor .ProseMirror a {
|
||||
color: #818cf8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Bold / Italic / Strike */
|
||||
.tiptap-editor .ProseMirror strong {
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
.tiptap-editor .ProseMirror em {
|
||||
font-style: italic;
|
||||
}
|
||||
.tiptap-editor .ProseMirror s {
|
||||
text-decoration: line-through;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Paragraph spacing */
|
||||
.tiptap-editor .ProseMirror p {
|
||||
margin-top: 0.375rem;
|
||||
margin-bottom: 0.375rem;
|
||||
line-height: 1.625;
|
||||
}
|
||||
</style>
|
||||
92
src/components/OutlinePanel.vue
Normal file
92
src/components/OutlinePanel.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
htmlContent: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
headingClick: [heading: { text: string; level: number }];
|
||||
}>();
|
||||
|
||||
interface OutlineItem {
|
||||
text: string;
|
||||
level: number; // 1, 2, 3
|
||||
id: string;
|
||||
}
|
||||
|
||||
const headings = computed<OutlineItem[]>(() => {
|
||||
if (!props.htmlContent) return [];
|
||||
const result: OutlineItem[] = [];
|
||||
// Match h1, h2, h3 tags — case-insensitive, capture inner text
|
||||
const regex = /<h([1-3])[^>]*>(.*?)<\/h\1>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(props.htmlContent)) !== null) {
|
||||
const level = parseInt(match[1]);
|
||||
const text = match[2].replace(/<[^>]*>/g, "").trim();
|
||||
if (text) {
|
||||
result.push({
|
||||
text,
|
||||
level,
|
||||
id: `heading-${result.length}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const onHeadingClick = (item: OutlineItem) => {
|
||||
emit("headingClick", { text: item.text, level: item.level });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="flex flex-col border-r border-gray-800 bg-gray-900/60 w-48 shrink-0 overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center px-3 py-3 border-b border-gray-800">
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
大纲
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Outline content -->
|
||||
<div class="flex-1 overflow-y-auto px-2 py-2">
|
||||
<template v-if="headings.length === 0">
|
||||
<p class="text-xs text-gray-600 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-gray-800 transition-colors truncate',
|
||||
item.level === 1
|
||||
? 'text-gray-200 font-medium ml-0'
|
||||
: item.level === 2
|
||||
? 'text-gray-400 ml-3'
|
||||
: 'text-gray-500 ml-6 text-xs',
|
||||
]"
|
||||
:title="item.text"
|
||||
@click="onHeadingClick(item)"
|
||||
>
|
||||
<!-- level indicator dot -->
|
||||
<span
|
||||
:class="[
|
||||
'inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle',
|
||||
item.level === 1
|
||||
? 'bg-indigo-400'
|
||||
: item.level === 2
|
||||
? 'bg-emerald-400/60'
|
||||
: 'bg-gray-500',
|
||||
]"
|
||||
/>
|
||||
{{ item.text }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./tailwind.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
2
src/tailwind.css
Normal file
2
src/tailwind.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@source "../src/**/*.vue";
|
||||
7
src/vite-env.d.ts
vendored
Normal file
7
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
Reference in New Issue
Block a user