264 lines
5.4 KiB
Vue
264 lines
5.4 KiB
Vue
<template>
|
|
<div
|
|
ref="root"
|
|
class="tui-markdown-editor diselect"
|
|
:class="{
|
|
'fold': isFold,
|
|
'code': code
|
|
}"
|
|
>
|
|
<div class="editor">
|
|
<div class="header">
|
|
<slot name="editorHeader">
|
|
<h4 class="title">
|
|
<span>源码</span>
|
|
<span class="light-gray word-space">Markdown</span>
|
|
</h4>
|
|
</slot>
|
|
</div>
|
|
<slot name="editor">
|
|
<textarea ref="textArea" class="text-area" v-model="_data" @keydown.tab.prevent="onTab"></textarea>
|
|
</slot>
|
|
</div>
|
|
<div class="preview" :class="{ 'showing': showingPreview }">
|
|
<div class="header">
|
|
<div
|
|
v-if="isFold"
|
|
class="icon cur-pointer"
|
|
v-text="showingPreview ? '>' : '<'"
|
|
@click="showingPreview = !showingPreview"
|
|
>
|
|
</div>
|
|
<slot name="previewHeader">
|
|
<h4 class="title">预览</h4>
|
|
</slot>
|
|
</div>
|
|
<div ref="previewContent" class="content">
|
|
<markdown-view v-if="code" :code="`${code}:${_data}`" />
|
|
<markdown-view v-else :content="_data" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { MarkdownView } from "../markdown-view";
|
|
import calcHeight from "./CalcTextareaHeight";
|
|
|
|
defineOptions({
|
|
name: "MarkdownEditor"
|
|
});
|
|
|
|
const props = withDefaults(defineProps<{
|
|
code?: string;
|
|
data?: string,
|
|
minRows?: number,
|
|
maxRows?: number
|
|
}>(), {
|
|
data: "",
|
|
minRows: 8,
|
|
maxRows: 32
|
|
});
|
|
const { data, minRows, maxRows } = toRefs(props);
|
|
|
|
const _data = ref(data.value);
|
|
const textArea = ref<HTMLTextAreaElement>();
|
|
const textAreaHeight = ref(30);
|
|
const previewContent = ref<HTMLDivElement>();
|
|
|
|
const emit = defineEmits(["update:data"]);
|
|
|
|
watch(_data, () => {
|
|
emit("update:data", _data.value);
|
|
calcTextAreaHeight();
|
|
});
|
|
watch(data, () => {
|
|
_data.value = data.value;
|
|
calcTextAreaHeight();
|
|
});
|
|
|
|
// 自适应折叠预览
|
|
const root = ref<HTMLDivElement>();
|
|
const isFold = ref(false);
|
|
const showingPreview = ref(false);
|
|
onMounted(() => {
|
|
if (root.value) {
|
|
const foldObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
|
if (entries && 0 < entries.length) {
|
|
isFold.value = entries[0].contentRect.width < 650;
|
|
}
|
|
});
|
|
foldObserver.observe(root.value);
|
|
}
|
|
});
|
|
|
|
// 自适应高度
|
|
const calcTextAreaHeight = () => {
|
|
if (textArea.value) {
|
|
textAreaHeight.value = calcHeight(textArea.value, minRows.value, maxRows.value).height;
|
|
}
|
|
};
|
|
onMounted(() => {
|
|
calcTextAreaHeight();
|
|
if (textArea.value) {
|
|
const textareaHeightObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
|
if (entries && 0 < entries.length) {
|
|
if (previewContent.value) {
|
|
previewContent.value.style.height = `calc(${entries[0].contentRect.height}px + 16px)`;
|
|
}
|
|
}
|
|
});
|
|
textareaHeightObserver.observe(textArea.value);
|
|
}
|
|
});
|
|
|
|
// ---------- Tab 缩进 ----------
|
|
|
|
const TAB = "\t";
|
|
|
|
/** 处理 Tab / Shift+Tab 缩进 */
|
|
const onTab = (e: KeyboardEvent) => {
|
|
const el = textArea.value!;
|
|
const { selectionStart: start, selectionEnd: end, value } = el;
|
|
const lines = value.split("\n");
|
|
|
|
// 计算选区覆盖的行范围
|
|
let charCount = 0;
|
|
let startLine = 0;
|
|
let endLine = 0;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const lineEnd = charCount + lines[i].length;
|
|
if (charCount <= start && start <= lineEnd) startLine = i;
|
|
if (charCount <= end && end <= lineEnd) endLine = i;
|
|
charCount += lines[i].length + 1;
|
|
}
|
|
|
|
// 单行且无选区:直接插入 Tab
|
|
if (!e.shiftKey && startLine === endLine && start === end) {
|
|
_data.value = value.slice(0, start) + TAB + value.slice(end);
|
|
nextTick(() => {
|
|
el.selectionStart = el.selectionEnd = start + 1;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 多行或有选区:整行缩进 / 反缩进
|
|
const newLines = lines.map((line, i) => {
|
|
if (i < startLine || i > endLine) return line;
|
|
if (e.shiftKey) return line.startsWith(TAB) ? line.slice(1) : line;
|
|
return TAB + line;
|
|
});
|
|
|
|
// 计算新选区位置
|
|
const delta = e.shiftKey ? -1 : 1;
|
|
const affectedCount = endLine - startLine + 1;
|
|
const newStart = Math.max(0, start + delta);
|
|
const newEnd = Math.max(newStart, end + delta * affectedCount);
|
|
|
|
_data.value = newLines.join("\n");
|
|
nextTick(() => {
|
|
el.selectionStart = newStart;
|
|
el.selectionEnd = newEnd;
|
|
});
|
|
};
|
|
|
|
defineExpose({
|
|
textArea
|
|
});
|
|
</script>
|
|
|
|
<style lang="less" scoped>
|
|
.tui-markdown-editor {
|
|
width: 100%;
|
|
border: var(--tui-border);
|
|
display: flex;
|
|
overflow: hidden;
|
|
position: relative;
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
border-bottom: 1px solid var(--tui-dark-white);
|
|
|
|
.icon {
|
|
margin-left: .5rem;
|
|
}
|
|
}
|
|
|
|
.title {
|
|
margin: 0;
|
|
height: 30px;
|
|
display: flex;
|
|
line-height: 30px;
|
|
padding-left: 10px;
|
|
}
|
|
|
|
.editor {
|
|
width: 50%;
|
|
display: flex;
|
|
border-right: var(--tui-border);
|
|
flex-direction: column;
|
|
|
|
.text-area {
|
|
width: calc(100% - .5rem * 2);
|
|
height: v-bind("textAreaHeight + 'px'");
|
|
resize: none;
|
|
border: none;
|
|
outline: none;
|
|
padding: .5rem;
|
|
font-size: 14px;
|
|
word-wrap: normal;
|
|
white-space: nowrap;
|
|
}
|
|
}
|
|
|
|
.preview {
|
|
width: 50%;
|
|
background: #FFF;
|
|
|
|
.content {
|
|
padding: .5rem 1rem;
|
|
overflow-y: auto;
|
|
}
|
|
}
|
|
|
|
&.fold {
|
|
|
|
.editor {
|
|
width: 100%;
|
|
}
|
|
|
|
.preview {
|
|
left: calc(100% - 5rem);
|
|
width: calc(100% - 2rem);
|
|
height: 100%;
|
|
z-index: 1;
|
|
position: absolute;
|
|
border-left: var(--tui-border);
|
|
transition: left .5s var(--tui-bezier);
|
|
box-shadow: -2px 0 0 var(--tui-shadow-color);
|
|
|
|
&.showing {
|
|
left: 2rem;
|
|
}
|
|
}
|
|
}
|
|
|
|
&.code {
|
|
|
|
.preview {
|
|
|
|
.content {
|
|
padding: 0;
|
|
|
|
:deep(pre[class*="language-"]) {
|
|
margin: 0;
|
|
border: none;
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|