功能修复
This commit is contained in:
@@ -34,5 +34,6 @@
|
|||||||
"topic_20260707-084048_75f72710c486f3de": "auto",
|
"topic_20260707-084048_75f72710c486f3de": "auto",
|
||||||
"topic_20260707-091519_dc062b2bfdf4249c": "auto",
|
"topic_20260707-091519_dc062b2bfdf4249c": "auto",
|
||||||
"topic_20260707-092315_b8c3939bfc696802": "auto",
|
"topic_20260707-092315_b8c3939bfc696802": "auto",
|
||||||
"topic_20260707-092711_af706c3fcc7e435e": "auto"
|
"topic_20260707-092711_af706c3fcc7e435e": "auto",
|
||||||
|
"topic_20260707-095503_bf879b65e4666516": "auto"
|
||||||
}
|
}
|
||||||
@@ -34,5 +34,6 @@
|
|||||||
"topic_20260707-084048_75f72710c486f3de": "在这个文件的区域的头部添加一行内容…",
|
"topic_20260707-084048_75f72710c486f3de": "在这个文件的区域的头部添加一行内容…",
|
||||||
"topic_20260707-091519_dc062b2bfdf4249c": "关于App.vue里的toolabr…",
|
"topic_20260707-091519_dc062b2bfdf4249c": "关于App.vue里的toolabr…",
|
||||||
"topic_20260707-092315_b8c3939bfc696802": "当我点击目录树切换显示文件的时候,右…",
|
"topic_20260707-092315_b8c3939bfc696802": "当我点击目录树切换显示文件的时候,右…",
|
||||||
"topic_20260707-092711_af706c3fcc7e435e": "帮我检查一下app.vue里的too…"
|
"topic_20260707-092711_af706c3fcc7e435e": "帮我检查一下app.vue里的too…",
|
||||||
|
"topic_20260707-095503_bf879b65e4666516": "帮我移除markdownEditor…"
|
||||||
}
|
}
|
||||||
105
src/App.vue
105
src/App.vue
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, watch, onMounted, onUnmounted, computed} from "vue";
|
import {ref, watch, onMounted, onUnmounted} from "vue";
|
||||||
import {listen} from "@tauri-apps/api/event";
|
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";
|
||||||
@@ -13,11 +13,6 @@ const charCount = ref(0);
|
|||||||
const currentFilePath = ref<string | null>(null);
|
const currentFilePath = ref<string | null>(null);
|
||||||
const saveStatus = ref("");
|
const saveStatus = ref("");
|
||||||
|
|
||||||
// Check if current file is a Markdown file (for display-mode toggle)
|
|
||||||
const isMarkdownFile = computed(() => {
|
|
||||||
return currentFilePath.value?.toLowerCase().endsWith(".md") ?? false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Folder state
|
// Folder state
|
||||||
interface DirEntry {
|
interface DirEntry {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -78,68 +73,11 @@ function onEditorWheel(e: WheelEvent) {
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- full-width mode ----
|
|
||||||
function loadFullWidthPref(): boolean {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem("editor-full-width") === "true";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFullWidth = ref(loadFullWidthPref());
|
|
||||||
|
|
||||||
function onToggleFullWidth(val: boolean) {
|
|
||||||
isFullWidth.value = val;
|
|
||||||
try {
|
|
||||||
localStorage.setItem("editor-full-width", String(val));
|
|
||||||
} catch { /* noop */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- display mode (split / wysiwyg) ----
|
|
||||||
type DisplayMode = "split" | "wysiwyg";
|
|
||||||
|
|
||||||
function loadDisplayModePref(): DisplayMode {
|
|
||||||
try {
|
|
||||||
const v = localStorage.getItem("editor-display-mode");
|
|
||||||
if (v === "split" || v === "wysiwyg") return v;
|
|
||||||
} catch { /* noop */
|
|
||||||
}
|
|
||||||
return "split";
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayMode = ref<DisplayMode>(loadDisplayModePref());
|
|
||||||
|
|
||||||
function onToggleDisplayMode(val: DisplayMode) {
|
|
||||||
displayMode.value = val;
|
|
||||||
try {
|
|
||||||
localStorage.setItem("editor-display-mode", val);
|
|
||||||
} catch { /* noop */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- toolbar state ----
|
// ---- toolbar state ----
|
||||||
const showThumbnail = ref(true);
|
const showThumbnail = ref(true);
|
||||||
const previewOnly = ref(false);
|
const previewOnly = ref(false);
|
||||||
const showMoreMenu = ref(false);
|
const showMoreMenu = ref(false);
|
||||||
|
|
||||||
function toggleFullWidth() {
|
|
||||||
isFullWidth.value = !isFullWidth.value;
|
|
||||||
try {
|
|
||||||
localStorage.setItem("editor-full-width", String(isFullWidth.value));
|
|
||||||
} catch { /* noop */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDisplayMode() {
|
|
||||||
displayMode.value = displayMode.value === "split" ? "wysiwyg" : "split";
|
|
||||||
try {
|
|
||||||
localStorage.setItem("editor-display-mode", displayMode.value);
|
|
||||||
} catch { /* noop */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleExportPdf() {
|
function handleExportPdf() {
|
||||||
showMoreMenu.value = false;
|
showMoreMenu.value = false;
|
||||||
// TODO: implement PDF export
|
// TODO: implement PDF export
|
||||||
@@ -347,22 +285,17 @@ listen("menu-event", (event) => {
|
|||||||
:entries="dirEntries"
|
:entries="dirEntries"
|
||||||
:content="content"
|
:content="content"
|
||||||
:current-file-path="currentFilePath"
|
:current-file-path="currentFilePath"
|
||||||
:is-full-width="isFullWidth"
|
|
||||||
:display-mode="displayMode"
|
|
||||||
:is-markdown-file="isMarkdownFile"
|
|
||||||
@heading-click="onHeadingClick"
|
@heading-click="onHeadingClick"
|
||||||
@open-folder="onOpenFolder"
|
@open-folder="onOpenFolder"
|
||||||
@toggle-folder="onToggleFolder"
|
@toggle-folder="onToggleFolder"
|
||||||
@open-file="onOpenFile"
|
@open-file="onOpenFile"
|
||||||
@toggle-full-width="onToggleFullWidth"
|
|
||||||
@toggle-display-mode="onToggleDisplayMode"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Right: Editor area -->
|
<!-- Right: Editor area -->
|
||||||
<main class="flex-1 flex flex-col overflow-hidden relative">
|
<main class="flex-1 flex flex-col overflow-hidden relative">
|
||||||
<!-- ════════════════ Toolbar ════════════════ -->
|
<!-- ════════════════ Toolbar ════════════════ -->
|
||||||
<div
|
<div
|
||||||
class="flex w-full items-center h-10 px-3 bg-[#faf9f6] border border-[#e0dbcf] border-b-0 select-none shrink-0"
|
class="flex w-full items-center h-10 px-3 bg-[#faf9f6] border-b border-[#e0dbcf] select-none shrink-0"
|
||||||
>
|
>
|
||||||
<!-- Left: Thumbnail toggle -->
|
<!-- Left: Thumbnail toggle -->
|
||||||
<button
|
<button
|
||||||
@@ -386,26 +319,6 @@ listen("menu-event", (event) => {
|
|||||||
|
|
||||||
<!-- Right: Actions -->
|
<!-- Right: Actions -->
|
||||||
<div class="flex items-center gap-0.5">
|
<div class="flex items-center gap-0.5">
|
||||||
<!-- Display mode toggle (only for .md files) -->
|
|
||||||
<button
|
|
||||||
v-if="isMarkdownFile"
|
|
||||||
class="toolbar-btn"
|
|
||||||
:title="displayMode === 'wysiwyg' ? '切换到双栏' : '切换到单栏'"
|
|
||||||
@click="toggleDisplayMode"
|
|
||||||
>
|
|
||||||
<!-- Split icon (shown when single, meaning click to switch to split) -->
|
|
||||||
<svg v-if="displayMode === 'wysiwyg'" width="16" height="16" viewBox="0 0 16 16" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="1.5">
|
|
||||||
<rect x="1" y="2" width="6" height="12" rx="1"/>
|
|
||||||
<rect x="9" y="2" width="6" height="12" rx="1"/>
|
|
||||||
</svg>
|
|
||||||
<!-- Single icon -->
|
|
||||||
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
|
||||||
stroke-width="1.5">
|
|
||||||
<rect x="3" y="2" width="10" height="12" rx="1"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Preview/Edit toggle -->
|
<!-- Preview/Edit toggle -->
|
||||||
<button
|
<button
|
||||||
class="toolbar-btn"
|
class="toolbar-btn"
|
||||||
@@ -418,18 +331,6 @@ listen("menu-event", (event) => {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Full-width toggle -->
|
|
||||||
<button
|
|
||||||
class="toolbar-btn"
|
|
||||||
:class="{ 'toolbar-btn-active': isFullWidth }"
|
|
||||||
title="切换全宽"
|
|
||||||
@click="toggleFullWidth"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
||||||
<path d="M10 1h5v5M6 15H1v-5M15 1l-5 5M1 15l5-5"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- More menu -->
|
<!-- More menu -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
@@ -462,8 +363,6 @@ listen("menu-event", (event) => {
|
|||||||
</div>
|
</div>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
:model-value="content"
|
:model-value="content"
|
||||||
:is-full-width="isFullWidth"
|
|
||||||
:display-mode="displayMode"
|
|
||||||
:preview-only="previewOnly"
|
:preview-only="previewOnly"
|
||||||
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
||||||
@update:model-value="onUpdate"
|
@update:model-value="onUpdate"
|
||||||
|
|||||||
@@ -15,16 +15,10 @@ const props = withDefaults(
|
|||||||
entries: DirEntry[];
|
entries: DirEntry[];
|
||||||
content?: string;
|
content?: string;
|
||||||
currentFilePath?: string | null;
|
currentFilePath?: string | null;
|
||||||
isFullWidth?: boolean;
|
|
||||||
displayMode?: "split" | "wysiwyg";
|
|
||||||
isMarkdownFile?: boolean;
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
content: "",
|
content: "",
|
||||||
currentFilePath: null,
|
currentFilePath: null,
|
||||||
isFullWidth: false,
|
|
||||||
displayMode: "split",
|
|
||||||
isMarkdownFile: false,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -35,8 +29,6 @@ const emit = defineEmits<{
|
|||||||
openFile: [path: string];
|
openFile: [path: string];
|
||||||
createFile: [];
|
createFile: [];
|
||||||
createFolder: [];
|
createFolder: [];
|
||||||
toggleFullWidth: [value: boolean];
|
|
||||||
toggleDisplayMode: [value: "split" | "wysiwyg"];
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Wrap the workspace root as a top-level tree node
|
// Wrap the workspace root as a top-level tree node
|
||||||
@@ -254,57 +246,6 @@ const showSettings = ref(false);
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 py-6 space-y-5">
|
<div class="px-4 py-6 space-y-5">
|
||||||
<!-- Display mode toggle (only for .md files) -->
|
|
||||||
<div v-if="props.isMarkdownFile" class="flex items-center justify-between">
|
|
||||||
<div class="flex-1 mr-3">
|
|
||||||
<p class="text-sm text-[#38342e] font-medium">编辑模式</p>
|
|
||||||
<p class="text-xs text-[#8c877d] mt-0.5 leading-relaxed">
|
|
||||||
{{ props.displayMode === "wysiwyg" ? "单栏:所见即所得,预览区直接编辑" : "双栏:左侧编辑源码,右侧预览效果" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
:aria-checked="props.displayMode === 'wysiwyg'"
|
|
||||||
:class="[
|
|
||||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
|
||||||
props.displayMode === 'wysiwyg' ? 'bg-[#bf6a3b]' : 'bg-[#d4cfc4]',
|
|
||||||
]"
|
|
||||||
@click="emit('toggleDisplayMode', props.displayMode === 'wysiwyg' ? 'split' : 'wysiwyg')"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:class="[
|
|
||||||
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform duration-200 ease-in-out',
|
|
||||||
props.displayMode === 'wysiwyg' ? 'translate-x-4' : 'translate-x-0',
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Full-width mode toggle -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex-1 mr-3">
|
|
||||||
<p class="text-sm text-[#38342e] font-medium">全宽模式</p>
|
|
||||||
<p class="text-xs text-[#8c877d] mt-0.5 leading-relaxed">关闭后编辑器居中显示,适合专注书写</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
:aria-checked="props.isFullWidth"
|
|
||||||
:class="[
|
|
||||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
|
||||||
props.isFullWidth ? 'bg-[#bf6a3b]' : 'bg-[#d4cfc4]',
|
|
||||||
]"
|
|
||||||
@click="emit('toggleFullWidth', !props.isFullWidth)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:class="[
|
|
||||||
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform duration-200 ease-in-out',
|
|
||||||
props.isFullWidth ? 'translate-x-4' : 'translate-x-0',
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick, computed } from "vue";
|
import { ref, watch, nextTick } from "vue";
|
||||||
import { unified } from "unified";
|
import { unified } from "unified";
|
||||||
import remarkParse from "remark-parse";
|
import remarkParse from "remark-parse";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
@@ -11,15 +11,11 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue?: string;
|
modelValue?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
isFullWidth?: boolean;
|
|
||||||
displayMode?: "split" | "wysiwyg";
|
|
||||||
previewOnly?: boolean;
|
previewOnly?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
modelValue: "",
|
modelValue: "",
|
||||||
placeholder: "开始写作...",
|
placeholder: "开始写作...",
|
||||||
isFullWidth: false,
|
|
||||||
displayMode: "split",
|
|
||||||
previewOnly: false,
|
previewOnly: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -29,8 +25,6 @@ const emit = defineEmits<{
|
|||||||
"save-shortcut": [];
|
"save-shortcut": [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isSingle = computed(() => props.displayMode === "wysiwyg");
|
|
||||||
|
|
||||||
// ── Unified processors ─────────────────────────────────────────────
|
// ── Unified processors ─────────────────────────────────────────────
|
||||||
|
|
||||||
/** Full pipeline: markdown → HTML */
|
/** Full pipeline: markdown → HTML */
|
||||||
@@ -41,10 +35,7 @@ const md2html = unified()
|
|||||||
.use(rehypeSanitize)
|
.use(rehypeSanitize)
|
||||||
.use(rehypeStringify);
|
.use(rehypeStringify);
|
||||||
|
|
||||||
/** Parser only: markdown → mdast (for block extraction) */
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
const mdParser = unified().use(remarkParse).use(remarkGfm);
|
|
||||||
|
|
||||||
// ── Split-pane rendering ───────────────────────────────────────────
|
|
||||||
|
|
||||||
const renderedHtml = ref("");
|
const renderedHtml = ref("");
|
||||||
const renderError = ref(false);
|
const renderError = ref(false);
|
||||||
@@ -63,169 +54,21 @@ function renderFullHtml(md: string) {
|
|||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(val) => {
|
(val) => {
|
||||||
if (!isSingle.value) renderFullHtml(val);
|
renderFullHtml(val);
|
||||||
// 切换文件时重置滚动位置到顶部
|
// 切换文件时重置滚动位置到顶部
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (previewRef.value) previewRef.value.scrollTop = 0;
|
if (scrollRef.value) scrollRef.value.scrollTop = 0;
|
||||||
if (splitTextareaRef.value) splitTextareaRef.value.scrollTop = 0;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(isSingle, (single) => {
|
// ── Editor textarea handlers ───────────────────────────────────────
|
||||||
if (!single) renderFullHtml(props.modelValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Block-level editing (single-pane mode) ─────────────────────────
|
const editorTextareaRef = ref<HTMLTextAreaElement>();
|
||||||
|
const scrollRef = ref<HTMLDivElement>();
|
||||||
|
|
||||||
interface Block {
|
function onEditorInput(e: Event) {
|
||||||
id: string;
|
|
||||||
source: string;
|
|
||||||
html: string;
|
|
||||||
startOffset: number;
|
|
||||||
endOffset: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocks = ref<Block[]>([]);
|
|
||||||
const editingBlockId = ref<string | null>(null);
|
|
||||||
const editingSource = ref("");
|
|
||||||
|
|
||||||
/** Parse markdown into editable blocks */
|
|
||||||
function parseBlocks(md: string) {
|
|
||||||
if (!md.trim()) {
|
|
||||||
blocks.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const tree = mdParser.parse(md);
|
|
||||||
blocks.value = (tree.children as any[])
|
|
||||||
.filter((c: any) => c.position?.start?.offset != null)
|
|
||||||
.map((c: any, i: number) => {
|
|
||||||
const source = md.slice(
|
|
||||||
c.position.start.offset!,
|
|
||||||
c.position.end.offset!,
|
|
||||||
);
|
|
||||||
let html = "";
|
|
||||||
try {
|
|
||||||
const file = md2html.processSync(source);
|
|
||||||
html = String(file.value);
|
|
||||||
} catch {
|
|
||||||
html = `<p>${escapeHtml(source)}</p>`;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: `b${i}`,
|
|
||||||
source,
|
|
||||||
html,
|
|
||||||
startOffset: c.position.start.offset!,
|
|
||||||
endOffset: c.position.end.offset!,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
blocks.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
return s
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">");
|
|
||||||
}
|
|
||||||
|
|
||||||
const singlePaneRef = ref<HTMLDivElement>();
|
|
||||||
|
|
||||||
// Watch modelValue → re-parse blocks in single mode
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(val) => {
|
|
||||||
if (isSingle.value) parseBlocks(val);
|
|
||||||
// 切换文件时重置滚动位置到顶部
|
|
||||||
nextTick(() => {
|
|
||||||
if (singlePaneRef.value) singlePaneRef.value.scrollTop = 0;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(isSingle, (single) => {
|
|
||||||
if (single) parseBlocks(props.modelValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Block editing actions ──────────────────────────────────────────
|
|
||||||
|
|
||||||
function startEdit(blockId: string) {
|
|
||||||
const block = blocks.value.find((b) => b.id === blockId);
|
|
||||||
if (!block) return;
|
|
||||||
editingBlockId.value = blockId;
|
|
||||||
editingSource.value = block.source;
|
|
||||||
|
|
||||||
void nextTick(() => {
|
|
||||||
const ta = document.querySelector(
|
|
||||||
".block-editor-textarea",
|
|
||||||
) as HTMLTextAreaElement | null;
|
|
||||||
if (ta) {
|
|
||||||
autoResize(ta);
|
|
||||||
ta.focus();
|
|
||||||
// Place cursor at end
|
|
||||||
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function finalizeEdit() {
|
|
||||||
const blockId = editingBlockId.value;
|
|
||||||
if (!blockId) return;
|
|
||||||
const block = blocks.value.find((b) => b.id === blockId);
|
|
||||||
editingBlockId.value = null;
|
|
||||||
|
|
||||||
if (!block) return;
|
|
||||||
const newSource = editingSource.value;
|
|
||||||
|
|
||||||
if (newSource === block.source) return; // no change
|
|
||||||
|
|
||||||
// Replace the block's portion in the full markdown
|
|
||||||
const before = props.modelValue.slice(0, block.startOffset);
|
|
||||||
const after = props.modelValue.slice(block.endOffset);
|
|
||||||
emit("update:modelValue", before + newSource + after);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEdit() {
|
|
||||||
editingBlockId.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBlockKeydown(e: KeyboardEvent) {
|
|
||||||
// Ctrl+S save
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
||||||
e.preventDefault();
|
|
||||||
// Finalize current edit first, then save
|
|
||||||
if (editingBlockId.value) finalizeEdit();
|
|
||||||
emit("save-shortcut");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Escape to cancel
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
cancelEdit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Auto-resize on any key
|
|
||||||
void nextTick(() => {
|
|
||||||
const ta = e.target as HTMLTextAreaElement;
|
|
||||||
autoResize(ta);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function autoResize(ta: HTMLTextAreaElement) {
|
|
||||||
ta.style.height = "auto";
|
|
||||||
ta.style.height = ta.scrollHeight + 2 + "px";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Split-pane textarea handlers ───────────────────────────────────
|
|
||||||
|
|
||||||
const splitTextareaRef = ref<HTMLTextAreaElement>();
|
|
||||||
|
|
||||||
function onSplitInput(e: Event) {
|
|
||||||
const val = (e.target as HTMLTextAreaElement).value;
|
const val = (e.target as HTMLTextAreaElement).value;
|
||||||
emit("update:modelValue", val);
|
emit("update:modelValue", val);
|
||||||
renderFullHtml(val);
|
renderFullHtml(val);
|
||||||
@@ -267,7 +110,7 @@ function tryAutoPair(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSplitKeydown(e: KeyboardEvent) {
|
function onEditorKeydown(e: KeyboardEvent) {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
emit("save-shortcut");
|
emit("save-shortcut");
|
||||||
@@ -298,122 +141,44 @@ function onSplitKeydown(e: KeyboardEvent) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scroll sync (split mode) ───────────────────────────────────────
|
|
||||||
|
|
||||||
const previewRef = ref<HTMLDivElement>();
|
|
||||||
|
|
||||||
function onSplitScroll() {
|
|
||||||
if (!splitTextareaRef.value || !previewRef.value) return;
|
|
||||||
const ratio =
|
|
||||||
splitTextareaRef.value.scrollTop /
|
|
||||||
(splitTextareaRef.value.scrollHeight -
|
|
||||||
splitTextareaRef.value.clientHeight || 1);
|
|
||||||
previewRef.value.scrollTop =
|
|
||||||
ratio *
|
|
||||||
(previewRef.value.scrollHeight - previewRef.value.clientHeight);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="[
|
ref="scrollRef"
|
||||||
'flex flex-1 w-full border border-[#e0dbcf] bg-white shadow-2xl overflow-hidden',
|
class="flex-1 w-full overflow-auto"
|
||||||
!props.isFullWidth && 'mx-auto',
|
|
||||||
]"
|
|
||||||
:style="
|
|
||||||
!props.isFullWidth
|
|
||||||
? { maxWidth: `calc(1600px * var(--editor-zoom, 1))` }
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
@wheel.passive
|
@wheel.passive
|
||||||
>
|
>
|
||||||
<!-- ════════════════ Single-pane: block editing ════════════════ -->
|
|
||||||
<div
|
<div
|
||||||
v-if="isSingle"
|
class="mx-auto px-8 pb-40 pt-8 h-full"
|
||||||
ref="singlePaneRef"
|
:style="{
|
||||||
class="flex-1 overflow-auto p-8 pb-40"
|
maxWidth: `calc(700px * var(--editor-zoom, 1))`,
|
||||||
:style="{ fontSize: `calc(1rem * var(--editor-zoom, 1))` }"
|
fontSize: `calc(1rem * var(--editor-zoom, 1))`,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<!-- Empty state -->
|
<!-- Editor mode -->
|
||||||
<div
|
<textarea
|
||||||
v-if="blocks.length === 0"
|
v-if="!previewOnly"
|
||||||
class="text-[#b8b3a8] font-serif leading-relaxed select-none"
|
ref="editorTextareaRef"
|
||||||
@click="emit('update:modelValue', '\n'); parseBlocks('\n')"
|
:value="props.modelValue"
|
||||||
>
|
:placeholder="props.placeholder"
|
||||||
{{ props.placeholder }}
|
class="
|
||||||
</div>
|
w-full h-full min-h-[60vh] resize-none bg-transparent outline-none
|
||||||
|
font-serif text-[#38342e] leading-relaxed
|
||||||
|
placeholder:text-[#b8b3a8]
|
||||||
|
"
|
||||||
|
@input="onEditorInput"
|
||||||
|
@keydown="onEditorKeydown"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Blocks -->
|
<!-- Preview mode -->
|
||||||
<template v-for="block in blocks" :key="block.id">
|
<div v-else class="min-h-[60vh]">
|
||||||
<!-- Editing state -->
|
|
||||||
<textarea
|
|
||||||
v-if="editingBlockId === block.id"
|
|
||||||
v-model="editingSource"
|
|
||||||
class="
|
|
||||||
block-editor-textarea
|
|
||||||
w-full resize-none outline-none
|
|
||||||
font-mono text-sm text-[#38342e] leading-relaxed
|
|
||||||
bg-[#faf9f6] border border-[#bf6a3b] rounded-md
|
|
||||||
px-3 py-2 mb-1
|
|
||||||
"
|
|
||||||
rows="1"
|
|
||||||
@keydown="onBlockKeydown"
|
|
||||||
@blur="finalizeEdit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Rendered state (clickable) -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
:class="[
|
|
||||||
'markdown-preview block-render py-px',
|
|
||||||
previewOnly ? '' : 'cursor-text rounded -mx-1 px-1 hover:bg-[#faf9f6] transition-colors',
|
|
||||||
]"
|
|
||||||
v-html="block.html"
|
|
||||||
@click="!previewOnly && startEdit(block.id)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ════════════════ Split-pane mode ════════════════ -->
|
|
||||||
<template v-else>
|
|
||||||
<!-- Textarea column: smoothly collapses width on preview-only -->
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'flex flex-col min-h-0 transition-[width,opacity] duration-300 ease-in-out',
|
|
||||||
previewOnly
|
|
||||||
? 'w-0 overflow-hidden opacity-0 border-r-0'
|
|
||||||
: 'flex-1 min-w-0 border-r border-[#e0dbcf] opacity-100',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
ref="splitTextareaRef"
|
|
||||||
:value="props.modelValue"
|
|
||||||
:placeholder="props.placeholder"
|
|
||||||
class="
|
|
||||||
flex-1 min-h-0 w-full resize-none bg-white p-8 pb-40 outline-none
|
|
||||||
font-serif text-[#38342e] leading-relaxed
|
|
||||||
placeholder:text-[#b8b3a8]
|
|
||||||
"
|
|
||||||
:style="{ fontSize: `calc(1rem * var(--editor-zoom, 1))` }"
|
|
||||||
@input="onSplitInput"
|
|
||||||
@keydown="onSplitKeydown"
|
|
||||||
@scroll="onSplitScroll"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Preview column: smoothly expands to fill available space -->
|
|
||||||
<div
|
|
||||||
ref="previewRef"
|
|
||||||
class="flex-1 min-w-0 min-h-0 overflow-auto p-8 pb-40 transition-[width] duration-300 ease-in-out"
|
|
||||||
:style="{ fontSize: `calc(1rem * var(--editor-zoom, 1))` }"
|
|
||||||
>
|
|
||||||
<div v-if="renderError" class="text-red-500 text-sm">
|
<div v-if="renderError" class="text-red-500 text-sm">
|
||||||
⚠ Markdown 解析错误
|
⚠ Markdown 解析错误
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="markdown-preview" v-html="renderedHtml" />
|
<div v-else class="markdown-preview" v-html="renderedHtml" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -528,15 +293,4 @@ function onSplitScroll() {
|
|||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Block editing textarea ────────────────────────────────────── */
|
|
||||||
|
|
||||||
.block-editor-textarea {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
min-height: 2em;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
.block-editor-textarea::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user