refactor editor
This commit is contained in:
+85
-9
@@ -1,21 +1,97 @@
|
|||||||
<template>
|
<template>
|
||||||
<user-profile-popup />
|
<div class="demo-app">
|
||||||
|
<header class="hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">timi-tdesign-pc</p>
|
||||||
|
<h1>PC 端组件演示台</h1>
|
||||||
|
<p>用固定数据覆盖编辑器、文章和通知的完整交互链路。</p>
|
||||||
|
</div>
|
||||||
|
<t-tag theme="primary" variant="light">TDesign Vue Next</t-tag>
|
||||||
|
</header>
|
||||||
|
<nav class="tabs" aria-label="演示分类">
|
||||||
|
<t-button
|
||||||
|
v-for="item in tabs"
|
||||||
|
:key="item.value"
|
||||||
|
size="small"
|
||||||
|
:theme="activeTab === item.value ? 'primary' : 'default'"
|
||||||
|
variant="outline"
|
||||||
|
@click="activeTab = item.value"
|
||||||
|
v-text="item.label"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<editor-showcase v-if="activeTab === 'editor'" />
|
||||||
|
<article-showcase v-else-if="activeTab === 'article'" />
|
||||||
|
<notify-showcase v-else />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<popup />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import ArticleShowcase from "./components/ArticleShowcase.vue";
|
||||||
|
import EditorShowcase from "./components/EditorShowcase.vue";
|
||||||
|
import NotifyShowcase from "./components/NotifyShowcase.vue";
|
||||||
import { Popup } from "timi-web";
|
import { Popup } from "timi-web";
|
||||||
import { UserProfilePopup } from "~/components";
|
|
||||||
|
|
||||||
const isReady = ref(false);
|
type TabValue = "editor" | "article" | "notify";
|
||||||
|
|
||||||
onMounted(async () => {
|
const tabs: Array<{ label: string; value: TabValue }> = [
|
||||||
isReady.value = true;
|
{ label: "编辑器", value: "editor" },
|
||||||
});
|
{ label: "文章", value: "article" },
|
||||||
|
{ label: "通知", value: "notify" }
|
||||||
|
];
|
||||||
|
const activeTab = ref<TabValue>("editor");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.test {
|
.demo-app {
|
||||||
width: 20rem;
|
width: min(100% - 3rem, 96rem);
|
||||||
margin: 2rem auto;
|
min-height: 100vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 0 5rem;
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: .25rem 0;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-size: .75rem;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.demo-app {
|
||||||
|
width: min(100% - 1rem, 96rem);
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<template>
|
||||||
|
<section class="demo-section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">文章场景</p>
|
||||||
|
<h2>文章编辑与预览</h2>
|
||||||
|
<p>切换正文类型,验证统一的 Article 数据结构。</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<t-button
|
||||||
|
v-for="item in contentTypeOptions"
|
||||||
|
:key="item.value"
|
||||||
|
size="small"
|
||||||
|
:theme="articleValue.contentType === item.value ? 'primary' : 'default'"
|
||||||
|
variant="outline"
|
||||||
|
@click="selectContentType(item.value)"
|
||||||
|
v-text="item.label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-grid">
|
||||||
|
<article class="demo-card">
|
||||||
|
<div class="card-heading">
|
||||||
|
<h3>ArticleEditor</h3>
|
||||||
|
<t-button size="small" theme="primary" @click="submitArticle">提交文章</t-button>
|
||||||
|
</div>
|
||||||
|
<t-input v-model="articleValue.title" class="title-input" placeholder="文章标题" />
|
||||||
|
<article-editor
|
||||||
|
ref="articleEditorRef"
|
||||||
|
:model-value="articleValue"
|
||||||
|
:upload-image="uploadDemoMedia"
|
||||||
|
:upload-video="uploadDemoMedia"
|
||||||
|
@change="onArticleChange"
|
||||||
|
@update:model-value="onArticleChange"
|
||||||
|
/>
|
||||||
|
<p class="status" v-text="articleStatus" />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="demo-card">
|
||||||
|
<div class="card-heading">
|
||||||
|
<h3>ArticleView</h3>
|
||||||
|
<span v-text="articleValue.contentType" />
|
||||||
|
</div>
|
||||||
|
<article-view :article="articleValue" />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
ArticleEditor,
|
||||||
|
ArticleView
|
||||||
|
} from "timi-tdesign-pc";
|
||||||
|
import type { ArticleEditorExpose } from "timi-tdesign-pc";
|
||||||
|
import type { Article, ArticleContentType } from "timi-web/editor";
|
||||||
|
import { DEMO_ARTICLE_CONTENT, cloneArticle, uploadDemoMedia } from "../demo-data";
|
||||||
|
|
||||||
|
const contentTypeOptions: Array<{ label: string; value: ArticleContentType }> = [
|
||||||
|
{ label: "纯文本", value: "TEXT" },
|
||||||
|
{ label: "Markdown", value: "MARKDOWN" },
|
||||||
|
{ label: "Tiptap", value: "TIP_TAP" }
|
||||||
|
];
|
||||||
|
const articleValue = ref<Article>(cloneArticle());
|
||||||
|
const articleEditorRef = ref<ArticleEditorExpose>();
|
||||||
|
const articleStatus = ref("等待提交");
|
||||||
|
|
||||||
|
function selectContentType(contentType: ArticleContentType) {
|
||||||
|
const content = contentType === "TEXT"
|
||||||
|
? "这是一段纯文本示例。"
|
||||||
|
: contentType === "MARKDOWN"
|
||||||
|
? "## Markdown 示例\n\n这是一段 Markdown 正文。"
|
||||||
|
: DEMO_ARTICLE_CONTENT;
|
||||||
|
articleValue.value = {
|
||||||
|
...articleValue.value,
|
||||||
|
contentType,
|
||||||
|
content
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onArticleChange(value: Article) {
|
||||||
|
articleValue.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitArticle() {
|
||||||
|
const value = await articleEditorRef.value?.submit();
|
||||||
|
if (value) {
|
||||||
|
articleValue.value = value;
|
||||||
|
articleStatus.value = `已提交,${value.attachmentList?.length || 0} 个附件`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.demo-section {
|
||||||
|
.section-heading {
|
||||||
|
align-items: flex-end;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: .25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-size: .75rem;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions,
|
||||||
|
.card-heading {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-card {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
border-radius: .75rem;
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span,
|
||||||
|
.status {
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-input {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.section-heading,
|
||||||
|
.card-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<template>
|
||||||
|
<section class="demo-section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">编辑器基础能力</p>
|
||||||
|
<h2>文本、富文本和代码</h2>
|
||||||
|
<p>通过同一组公共数据规则验证 PC 端 TDesign 交互。</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<t-button size="small" theme="primary" @click="openSearch">打开搜索</t-button>
|
||||||
|
<t-button size="small" variant="outline" @click="submitEditor">提交富文本</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-grid">
|
||||||
|
<article class="demo-card wide">
|
||||||
|
<div class="card-heading">
|
||||||
|
<h3>Tiptap 编辑器</h3>
|
||||||
|
<span v-text="editorStatus" />
|
||||||
|
</div>
|
||||||
|
<div class="editor-box rich-editor">
|
||||||
|
<tiptap-editor
|
||||||
|
ref="tiptapEditorRef"
|
||||||
|
v-model="richContent"
|
||||||
|
:upload-image="uploadDemoMedia"
|
||||||
|
:upload-video="uploadDemoMedia"
|
||||||
|
@change="editorStatus = `内容已更新,${$event.length} 个字符`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="demo-card wide">
|
||||||
|
<div class="card-heading">
|
||||||
|
<h3>Tiptap 只读视图</h3>
|
||||||
|
<t-input v-model="maxHeight" class="height-input" placeholder="最大高度,如 20rem" />
|
||||||
|
</div>
|
||||||
|
<tiptap-view :content="richContent" :max-height="maxHeight" />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="demo-card wide">
|
||||||
|
<div class="card-heading">
|
||||||
|
<h3>代码编辑器</h3>
|
||||||
|
<t-button size="small" variant="text" @click="resetCode">恢复示例</t-button>
|
||||||
|
</div>
|
||||||
|
<div class="editor-box code-editor">
|
||||||
|
<code-editor
|
||||||
|
ref="codeEditorRef"
|
||||||
|
v-model="codeContent"
|
||||||
|
language="typescript"
|
||||||
|
placeholder="请输入代码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
CodeEditor,
|
||||||
|
TiptapEditor,
|
||||||
|
TiptapView
|
||||||
|
} from "timi-tdesign-pc";
|
||||||
|
import type {
|
||||||
|
CodeEditorExpose,
|
||||||
|
TiptapEditorExpose
|
||||||
|
} from "timi-tdesign-pc";
|
||||||
|
import { DEMO_ARTICLE_CONTENT, DEMO_CODE, uploadDemoMedia } from "../demo-data";
|
||||||
|
|
||||||
|
const richContent = ref(DEMO_ARTICLE_CONTENT);
|
||||||
|
const codeContent = ref(DEMO_CODE);
|
||||||
|
const maxHeight = ref("20rem");
|
||||||
|
const editorStatus = ref("等待编辑");
|
||||||
|
const tiptapEditorRef = ref<TiptapEditorExpose>();
|
||||||
|
const codeEditorRef = ref<CodeEditorExpose>();
|
||||||
|
|
||||||
|
function openSearch() {
|
||||||
|
tiptapEditorRef.value?.openSearch(true);
|
||||||
|
codeEditorRef.value?.openSearch(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEditor() {
|
||||||
|
const value = await tiptapEditorRef.value?.submit();
|
||||||
|
if (value !== undefined) {
|
||||||
|
editorStatus.value = `已提交,${value.length} 个字符`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCode() {
|
||||||
|
codeContent.value = DEMO_CODE;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.demo-section {
|
||||||
|
.section-heading {
|
||||||
|
align-items: flex-end;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: .25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-size: .75rem;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions,
|
||||||
|
.card-heading {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-card {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
border-radius: .75rem;
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
box-shadow: 0 .25rem 1rem rgba(0, 0, 0, .04);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-box {
|
||||||
|
height: 24rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor {
|
||||||
|
height: 28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.height-input {
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.section-heading {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.height-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<section class="demo-section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">通知场景</p>
|
||||||
|
<h2>通知编辑与预览</h2>
|
||||||
|
<p>Notify 组件复用文章编辑器,同时保留通知自己的元数据。</p>
|
||||||
|
</div>
|
||||||
|
<t-button size="small" theme="primary" @click="submitNotify">提交通知</t-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-grid">
|
||||||
|
<article class="demo-card">
|
||||||
|
<div class="card-heading">
|
||||||
|
<h3>NotifyEditor</h3>
|
||||||
|
<span v-text="notifyStatus" />
|
||||||
|
</div>
|
||||||
|
<t-input v-model="notifyValue.subject" class="subject-input" placeholder="通知标题" />
|
||||||
|
<notify-editor
|
||||||
|
ref="notifyEditorRef"
|
||||||
|
:model-value="notifyValue"
|
||||||
|
:upload-image="uploadDemoMedia"
|
||||||
|
:upload-video="uploadDemoMedia"
|
||||||
|
@change="onNotifyChange"
|
||||||
|
@update:model-value="onNotifyChange"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="demo-card">
|
||||||
|
<div class="card-heading">
|
||||||
|
<h3>NotifyView</h3>
|
||||||
|
<span v-text="notifyValue.status" />
|
||||||
|
</div>
|
||||||
|
<notify-view :detail="notifyValue" />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
NotifyEditor,
|
||||||
|
NotifyView
|
||||||
|
} from "timi-tdesign-pc";
|
||||||
|
import type { NotifyEditorExpose } from "timi-tdesign-pc";
|
||||||
|
import type { NotifyDetail } from "timi-web/editor";
|
||||||
|
import { cloneNotify, uploadDemoMedia } from "../demo-data";
|
||||||
|
|
||||||
|
const notifyValue = ref<NotifyDetail>(cloneNotify());
|
||||||
|
const notifyEditorRef = ref<NotifyEditorExpose>();
|
||||||
|
const notifyStatus = ref("等待提交");
|
||||||
|
|
||||||
|
function onNotifyChange(value: NotifyDetail) {
|
||||||
|
notifyValue.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitNotify() {
|
||||||
|
const value = await notifyEditorRef.value?.submit();
|
||||||
|
if (value) {
|
||||||
|
notifyValue.value = value;
|
||||||
|
notifyStatus.value = `已提交,${value.content?.length || 0} 个字符`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.demo-section {
|
||||||
|
.section-heading {
|
||||||
|
align-items: flex-end;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: .25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-size: .75rem;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-heading {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-card {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
border-radius: .75rem;
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-input {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.section-heading,
|
||||||
|
.card-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Article, NotifyDetail, UploadResult } from "timi-web/editor";
|
||||||
|
|
||||||
|
/** 编辑器演示使用的固定内容,避免每个页面各自维护一份 HTML。 */
|
||||||
|
export const DEMO_ARTICLE_CONTENT = `<h2>跨平台编辑器</h2>
|
||||||
|
<p>这段内容同时验证标题、段落、链接、表格和代码块的渲染。</p>
|
||||||
|
<blockquote><p>移动端和 PC 端只替换交互层,共享文档模型。</p></blockquote>
|
||||||
|
<pre><code class="language-typescript">const message: string = "timi editor";</code></pre>`;
|
||||||
|
|
||||||
|
export const DEMO_CODE = `type DemoUser = {
|
||||||
|
\tid: string;
|
||||||
|
\tname: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const user: DemoUser = {
|
||||||
|
\tid: "demo-001",
|
||||||
|
\tname: "Timi"
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(user.name);`;
|
||||||
|
|
||||||
|
export const DEMO_ARTICLE: Article = {
|
||||||
|
title: "编辑器组件演示文章",
|
||||||
|
description: "用于验证文章编辑、预览和附件处理。",
|
||||||
|
contentType: "TIP_TAP",
|
||||||
|
content: DEMO_ARTICLE_CONTENT,
|
||||||
|
attachmentList: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEMO_NOTIFY: NotifyDetail = {
|
||||||
|
subject: "编辑器通知演示",
|
||||||
|
msgType: "INTERNAL",
|
||||||
|
contentType: "TIP_TAP",
|
||||||
|
content: DEMO_ARTICLE_CONTENT,
|
||||||
|
status: "WAITING",
|
||||||
|
attachmentList: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export function cloneArticle(): Article {
|
||||||
|
return {
|
||||||
|
...DEMO_ARTICLE,
|
||||||
|
attachmentList: [...(DEMO_ARTICLE.attachmentList || [])]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneNotify(): NotifyDetail {
|
||||||
|
return {
|
||||||
|
...DEMO_NOTIFY,
|
||||||
|
attachmentList: [...(DEMO_NOTIFY.attachmentList || [])]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回本地预览地址,让示例无需依赖后端上传接口。 */
|
||||||
|
export async function uploadDemoMedia(file: File): Promise<UploadResult> {
|
||||||
|
return {
|
||||||
|
id: `demo-${Date.now()}-${file.name}`,
|
||||||
|
name: file.name,
|
||||||
|
url: URL.createObjectURL(file)
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-4
@@ -1,14 +1,11 @@
|
|||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import Root from "./Root.vue";
|
import Root from "./Root.vue";
|
||||||
import TimiWebUI, { axios, Network, VPopup } from "timi-web"; // 本地开发
|
import TimiWebUI, { VPopup } from "timi-web";
|
||||||
|
|
||||||
import "timi-web/style.css";
|
import "timi-web/style.css";
|
||||||
import "~/assets/style/tencent-cloud.less";
|
import "~/assets/style/tencent-cloud.less";
|
||||||
import "~/assets/style/tencent-cloud-custom.less";
|
import "~/assets/style/tencent-cloud-custom.less";
|
||||||
|
|
||||||
axios.defaults.baseURL = "http://localhost:8091";
|
|
||||||
axios.interceptors.request.use(Network.userTokenInterceptors);
|
|
||||||
|
|
||||||
const app = createApp(Root);
|
const app = createApp(Root);
|
||||||
app.use(TimiWebUI);
|
app.use(TimiWebUI);
|
||||||
app.directive("popup", VPopup);
|
app.directive("popup", VPopup);
|
||||||
|
|||||||
+3
-3
@@ -36,6 +36,8 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"tdesign-vue-next": "^1.18.6",
|
||||||
|
"timi-web": "0.0.24",
|
||||||
"vue": "^3.5.26"
|
"vue": "^3.5.26"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -62,8 +64,6 @@
|
|||||||
"vite-plugin-vue-setup-extend": "^0.4.0",
|
"vite-plugin-vue-setup-extend": "^0.4.0",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"vite": "7.3.1",
|
"vite": "7.3.1",
|
||||||
"vue-tsc": "^3.2.2",
|
"vue-tsc": "^3.2.2"
|
||||||
"tdesign-vue-next": "^1.18.6",
|
|
||||||
"timi-web": "0.0.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+143
-32
@@ -8,6 +8,12 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
tdesign-vue-next:
|
||||||
|
specifier: ^1.18.6
|
||||||
|
version: 1.18.6(vue@3.5.26(typescript@5.9.3))
|
||||||
|
timi-web:
|
||||||
|
specifier: 0.0.24
|
||||||
|
version: 0.0.24(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.26
|
specifier: ^3.5.26
|
||||||
version: 3.5.26(typescript@5.9.3)
|
version: 3.5.26(typescript@5.9.3)
|
||||||
@@ -63,12 +69,6 @@ importers:
|
|||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.8.0
|
specifier: ^3.8.0
|
||||||
version: 3.8.0
|
version: 3.8.0
|
||||||
tdesign-vue-next:
|
|
||||||
specifier: ^1.18.6
|
|
||||||
version: 1.18.6(vue@3.5.26(typescript@5.9.3))
|
|
||||||
timi-web:
|
|
||||||
specifier: 0.0.2
|
|
||||||
version: 0.0.2
|
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -1462,6 +1462,15 @@ packages:
|
|||||||
'@vue/component-compiler-utils@3.3.0':
|
'@vue/component-compiler-utils@3.3.0':
|
||||||
resolution: {integrity: sha512-97sfH2mYNU+2PzGrmK2haqffDpVASuib9/w2/noxiFi31Z54hW+q3izKQXXQZSNhtiUpAI36uSuYepeBe4wpHQ==}
|
resolution: {integrity: sha512-97sfH2mYNU+2PzGrmK2haqffDpVASuib9/w2/noxiFi31Z54hW+q3izKQXXQZSNhtiUpAI36uSuYepeBe4wpHQ==}
|
||||||
|
|
||||||
|
'@vue/devtools-api@7.7.10':
|
||||||
|
resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==}
|
||||||
|
|
||||||
|
'@vue/devtools-kit@7.7.10':
|
||||||
|
resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==}
|
||||||
|
|
||||||
|
'@vue/devtools-shared@7.7.10':
|
||||||
|
resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==}
|
||||||
|
|
||||||
'@vue/eslint-config-prettier@10.2.0':
|
'@vue/eslint-config-prettier@10.2.0':
|
||||||
resolution: {integrity: sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==}
|
resolution: {integrity: sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1602,6 +1611,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
|
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
|
|
||||||
|
agent-base@6.0.2:
|
||||||
|
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||||
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
ajv-draft-04@1.0.0:
|
ajv-draft-04@1.0.0:
|
||||||
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
|
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1719,8 +1732,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss: ^8.1.0
|
postcss: ^8.1.0
|
||||||
|
|
||||||
axios@1.13.5:
|
axios@1.18.0:
|
||||||
resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==}
|
resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==}
|
||||||
|
|
||||||
babel-loader@8.4.1:
|
babel-loader@8.4.1:
|
||||||
resolution: {integrity: sha512-nXzRChX+Z1GoE6yWavBQg6jDslyFF3SDjl2paADuoQtQW10JqShJt62R6eJQ5m/pjJFDT8xgKIWSP85OY8eXeA==}
|
resolution: {integrity: sha512-nXzRChX+Z1GoE6yWavBQg6jDslyFF3SDjl2paADuoQtQW10JqShJt62R6eJQ5m/pjJFDT8xgKIWSP85OY8eXeA==}
|
||||||
@@ -1768,6 +1781,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
birpc@2.9.0:
|
||||||
|
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
||||||
|
|
||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||||
|
|
||||||
@@ -2153,6 +2169,10 @@ packages:
|
|||||||
copy-anything@2.0.6:
|
copy-anything@2.0.6:
|
||||||
resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==}
|
resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==}
|
||||||
|
|
||||||
|
copy-anything@4.1.0:
|
||||||
|
resolution: {integrity: sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
copy-webpack-plugin@9.1.0:
|
copy-webpack-plugin@9.1.0:
|
||||||
resolution: {integrity: sha512-rxnR7PaGigJzhqETHGmAcxKnLZSR5u1Y3/bcIv/1FnqXedcL/E2ewK7ZCNrArJKCiSv8yVXhTqetJh8inDvfsA==}
|
resolution: {integrity: sha512-rxnR7PaGigJzhqETHGmAcxKnLZSR5u1Y3/bcIv/1FnqXedcL/E2ewK7ZCNrArJKCiSv8yVXhTqetJh8inDvfsA==}
|
||||||
engines: {node: '>= 12.13.0'}
|
engines: {node: '>= 12.13.0'}
|
||||||
@@ -2704,6 +2724,15 @@ packages:
|
|||||||
debug:
|
debug:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
follow-redirects@1.16.0:
|
||||||
|
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
peerDependencies:
|
||||||
|
debug: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
debug:
|
||||||
|
optional: true
|
||||||
|
|
||||||
form-data@4.0.5:
|
form-data@4.0.5:
|
||||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -2845,6 +2874,9 @@ packages:
|
|||||||
highlight.js@10.7.3:
|
highlight.js@10.7.3:
|
||||||
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
|
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
|
||||||
|
|
||||||
|
hookable@5.5.3:
|
||||||
|
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||||
|
|
||||||
hosted-git-info@2.8.9:
|
hosted-git-info@2.8.9:
|
||||||
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
|
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
|
||||||
|
|
||||||
@@ -2908,6 +2940,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
|
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
https-proxy-agent@5.0.1:
|
||||||
|
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
human-signals@2.1.0:
|
human-signals@2.1.0:
|
||||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||||
engines: {node: '>=10.17.0'}
|
engines: {node: '>=10.17.0'}
|
||||||
@@ -3611,6 +3647,9 @@ packages:
|
|||||||
pathe@2.0.3:
|
pathe@2.0.3:
|
||||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
|
perfect-debounce@1.0.0:
|
||||||
|
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||||
|
|
||||||
picocolors@0.2.1:
|
picocolors@0.2.1:
|
||||||
resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==}
|
resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==}
|
||||||
|
|
||||||
@@ -3629,6 +3668,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
|
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
pinia@3.0.4:
|
||||||
|
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=4.5.0'
|
||||||
|
vue: ^3.5.11
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
pkg-dir@4.2.0:
|
pkg-dir@4.2.0:
|
||||||
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
|
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3895,8 +3943,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
proxy-from-env@1.1.0:
|
proxy-from-env@2.1.0:
|
||||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
prr@1.0.1:
|
prr@1.0.1:
|
||||||
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
|
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
|
||||||
@@ -4025,6 +4074,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
||||||
|
rfdc@1.4.1:
|
||||||
|
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||||
|
|
||||||
rimraf@3.0.2:
|
rimraf@3.0.2:
|
||||||
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
|
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
|
||||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||||
@@ -4211,6 +4263,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==}
|
resolution: {integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
speakingurl@14.0.1:
|
||||||
|
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
sprintf-js@1.0.3:
|
sprintf-js@1.0.3:
|
||||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||||
|
|
||||||
@@ -4280,6 +4336,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss: ^8.2.15
|
postcss: ^8.2.15
|
||||||
|
|
||||||
|
superjson@2.2.6:
|
||||||
|
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
supports-color@5.5.0:
|
supports-color@5.5.0:
|
||||||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -4339,11 +4399,6 @@ packages:
|
|||||||
uglify-js:
|
uglify-js:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
terser@5.39.0:
|
|
||||||
resolution: {integrity: sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==}
|
|
||||||
engines: {node: '>=10'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
terser@5.46.1:
|
terser@5.46.1:
|
||||||
resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==}
|
resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4365,8 +4420,8 @@ packages:
|
|||||||
thunky@1.1.0:
|
thunky@1.1.0:
|
||||||
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
|
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
|
||||||
|
|
||||||
timi-web@0.0.2:
|
timi-web@0.0.24:
|
||||||
resolution: {integrity: sha512-tXznHwCjFy/AtmWBky35V0SkLTeHG4FayzUhBRoUNe6a7xxlSQ0p8XcQAuSFkugQuCz8/pJsZNHgplfpzs041A==}
|
resolution: {integrity: sha512-qzcm72W9EE3BrYiUzc8a20J8xqxH17moEm/ovdijhRbIJtJUr81sslXFe2+OjNtx14meEIPJm3IfRooFzRswLg==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
tinycolor2@1.6.0:
|
tinycolor2@1.6.0:
|
||||||
@@ -4521,6 +4576,7 @@ packages:
|
|||||||
|
|
||||||
uuid@8.3.2:
|
uuid@8.3.2:
|
||||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||||
|
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
validate-npm-package-license@3.0.4:
|
validate-npm-package-license@3.0.4:
|
||||||
@@ -4901,7 +4957,7 @@ snapshots:
|
|||||||
'@babel/core': 7.26.9
|
'@babel/core': 7.26.9
|
||||||
'@babel/helper-compilation-targets': 7.26.5
|
'@babel/helper-compilation-targets': 7.26.5
|
||||||
'@babel/helper-plugin-utils': 7.26.5
|
'@babel/helper-plugin-utils': 7.26.5
|
||||||
debug: 4.4.0
|
debug: 4.4.3
|
||||||
lodash.debounce: 4.0.8
|
lodash.debounce: 4.0.8
|
||||||
resolve: 1.22.10
|
resolve: 1.22.10
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -6554,6 +6610,24 @@ snapshots:
|
|||||||
- walrus
|
- walrus
|
||||||
- whiskers
|
- whiskers
|
||||||
|
|
||||||
|
'@vue/devtools-api@7.7.10':
|
||||||
|
dependencies:
|
||||||
|
'@vue/devtools-kit': 7.7.10
|
||||||
|
|
||||||
|
'@vue/devtools-kit@7.7.10':
|
||||||
|
dependencies:
|
||||||
|
'@vue/devtools-shared': 7.7.10
|
||||||
|
birpc: 2.9.0
|
||||||
|
hookable: 5.5.3
|
||||||
|
mitt: 3.0.1
|
||||||
|
perfect-debounce: 1.0.0
|
||||||
|
speakingurl: 14.0.1
|
||||||
|
superjson: 2.2.6
|
||||||
|
|
||||||
|
'@vue/devtools-shared@7.7.10':
|
||||||
|
dependencies:
|
||||||
|
rfdc: 1.4.1
|
||||||
|
|
||||||
'@vue/eslint-config-prettier@10.2.0(@types/eslint@9.6.1)(eslint@9.39.2)(prettier@3.8.0)':
|
'@vue/eslint-config-prettier@10.2.0(@types/eslint@9.6.1)(eslint@9.39.2)(prettier@3.8.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.39.2
|
eslint: 9.39.2
|
||||||
@@ -6735,6 +6809,12 @@ snapshots:
|
|||||||
|
|
||||||
address@1.2.2: {}
|
address@1.2.2: {}
|
||||||
|
|
||||||
|
agent-base@6.0.2:
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
ajv-draft-04@1.0.0(ajv@8.13.0):
|
ajv-draft-04@1.0.0(ajv@8.13.0):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ajv: 8.13.0
|
ajv: 8.13.0
|
||||||
@@ -6841,13 +6921,15 @@ snapshots:
|
|||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
postcss-value-parser: 4.2.0
|
postcss-value-parser: 4.2.0
|
||||||
|
|
||||||
axios@1.13.5:
|
axios@1.18.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.15.11(debug@4.4.3)
|
follow-redirects: 1.16.0
|
||||||
form-data: 4.0.5
|
form-data: 4.0.5
|
||||||
proxy-from-env: 1.1.0
|
https-proxy-agent: 5.0.1
|
||||||
|
proxy-from-env: 2.1.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
|
- supports-color
|
||||||
|
|
||||||
babel-loader@8.4.1(@babel/core@7.26.9)(webpack@5.98.0):
|
babel-loader@8.4.1(@babel/core@7.26.9)(webpack@5.98.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6904,6 +6986,8 @@ snapshots:
|
|||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
|
birpc@2.9.0: {}
|
||||||
|
|
||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
buffer: 5.7.1
|
buffer: 5.7.1
|
||||||
@@ -7157,6 +7241,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-what: 3.14.1
|
is-what: 3.14.1
|
||||||
|
|
||||||
|
copy-anything@4.1.0: {}
|
||||||
|
|
||||||
copy-webpack-plugin@9.1.0(webpack@5.98.0):
|
copy-webpack-plugin@9.1.0(webpack@5.98.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.3
|
fast-glob: 3.3.3
|
||||||
@@ -7769,6 +7855,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
|
|
||||||
|
follow-redirects@1.16.0: {}
|
||||||
|
|
||||||
form-data@4.0.5:
|
form-data@4.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
asynckit: 0.4.0
|
asynckit: 0.4.0
|
||||||
@@ -7903,6 +7991,8 @@ snapshots:
|
|||||||
|
|
||||||
highlight.js@10.7.3: {}
|
highlight.js@10.7.3: {}
|
||||||
|
|
||||||
|
hookable@5.5.3: {}
|
||||||
|
|
||||||
hosted-git-info@2.8.9: {}
|
hosted-git-info@2.8.9: {}
|
||||||
|
|
||||||
hpack.js@2.1.6:
|
hpack.js@2.1.6:
|
||||||
@@ -7984,6 +8074,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
|
|
||||||
|
https-proxy-agent@5.0.1:
|
||||||
|
dependencies:
|
||||||
|
agent-base: 6.0.2
|
||||||
|
debug: 4.4.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
human-signals@2.1.0: {}
|
human-signals@2.1.0: {}
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
@@ -8605,6 +8702,8 @@ snapshots:
|
|||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
|
perfect-debounce@1.0.0: {}
|
||||||
|
|
||||||
picocolors@0.2.1: {}
|
picocolors@0.2.1: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
@@ -8616,6 +8715,13 @@ snapshots:
|
|||||||
pify@4.0.1:
|
pify@4.0.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
pinia@3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
'@vue/devtools-api': 7.7.10
|
||||||
|
vue: 3.5.26(typescript@5.9.3)
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
pkg-dir@4.2.0:
|
pkg-dir@4.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
find-up: 4.1.0
|
find-up: 4.1.0
|
||||||
@@ -8878,7 +8984,7 @@ snapshots:
|
|||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
ipaddr.js: 1.9.1
|
ipaddr.js: 1.9.1
|
||||||
|
|
||||||
proxy-from-env@1.1.0: {}
|
proxy-from-env@2.1.0: {}
|
||||||
|
|
||||||
prr@1.0.1:
|
prr@1.0.1:
|
||||||
optional: true
|
optional: true
|
||||||
@@ -9015,6 +9121,8 @@ snapshots:
|
|||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
|
rfdc@1.4.1: {}
|
||||||
|
|
||||||
rimraf@3.0.2:
|
rimraf@3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
glob: 7.2.3
|
glob: 7.2.3
|
||||||
@@ -9266,6 +9374,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
speakingurl@14.0.1: {}
|
||||||
|
|
||||||
sprintf-js@1.0.3: {}
|
sprintf-js@1.0.3: {}
|
||||||
|
|
||||||
ssri@8.0.1:
|
ssri@8.0.1:
|
||||||
@@ -9325,6 +9435,10 @@ snapshots:
|
|||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
postcss-selector-parser: 6.1.2
|
postcss-selector-parser: 6.1.2
|
||||||
|
|
||||||
|
superjson@2.2.6:
|
||||||
|
dependencies:
|
||||||
|
copy-anything: 4.1.0
|
||||||
|
|
||||||
supports-color@5.5.0:
|
supports-color@5.5.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 3.0.0
|
has-flag: 3.0.0
|
||||||
@@ -9385,16 +9499,9 @@ snapshots:
|
|||||||
jest-worker: 27.5.1
|
jest-worker: 27.5.1
|
||||||
schema-utils: 4.3.0
|
schema-utils: 4.3.0
|
||||||
serialize-javascript: 6.0.2
|
serialize-javascript: 6.0.2
|
||||||
terser: 5.39.0
|
terser: 5.46.1
|
||||||
webpack: 5.98.0
|
webpack: 5.98.0
|
||||||
|
|
||||||
terser@5.39.0:
|
|
||||||
dependencies:
|
|
||||||
'@jridgewell/source-map': 0.3.6
|
|
||||||
acorn: 8.14.1
|
|
||||||
commander: 2.20.3
|
|
||||||
source-map-support: 0.5.21
|
|
||||||
|
|
||||||
terser@5.46.1:
|
terser@5.46.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/source-map': 0.3.6
|
'@jridgewell/source-map': 0.3.6
|
||||||
@@ -9421,18 +9528,22 @@ snapshots:
|
|||||||
|
|
||||||
thunky@1.1.0: {}
|
thunky@1.1.0: {}
|
||||||
|
|
||||||
timi-web@0.0.2:
|
timi-web@0.0.24(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
axios: 1.13.5
|
axios: 1.18.0
|
||||||
less: 4.5.1
|
less: 4.5.1
|
||||||
marked: 17.0.6
|
marked: 17.0.6
|
||||||
marked-gfm-heading-id: 4.1.4(marked@17.0.6)
|
marked-gfm-heading-id: 4.1.4(marked@17.0.6)
|
||||||
marked-highlight: 2.2.4(marked@17.0.6)
|
marked-highlight: 2.2.4(marked@17.0.6)
|
||||||
marked-mangle: 1.1.13(marked@17.0.6)
|
marked-mangle: 1.1.13(marked@17.0.6)
|
||||||
|
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
|
||||||
prismjs: 1.30.0
|
prismjs: 1.30.0
|
||||||
terser: 5.46.1
|
terser: 5.46.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
|
- supports-color
|
||||||
|
- typescript
|
||||||
|
- vue
|
||||||
|
|
||||||
tinycolor2@1.6.0: {}
|
tinycolor2@1.6.0: {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import { Toolkit } from "timi-web";
|
||||||
|
import type { Article } from "timi-web/editor";
|
||||||
|
|
||||||
|
export type ArticleEditorExpose = {
|
||||||
|
submit: () => Promise<Article>;
|
||||||
|
getPendingMediaCount: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ArticleEditor = Toolkit.withInstall(view);
|
||||||
|
export default ArticleEditor;
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<template>
|
||||||
|
<t-textarea
|
||||||
|
v-if="isTextContent"
|
||||||
|
:model-value="editorContent"
|
||||||
|
:autosize="textareaAutosize"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@update:model-value="onSimpleEditorCommit"
|
||||||
|
/>
|
||||||
|
<markdown-editor
|
||||||
|
v-else-if="isMarkdownContent"
|
||||||
|
:data="editorContent"
|
||||||
|
@update:data="onSimpleEditorCommit"
|
||||||
|
/>
|
||||||
|
<tiptap-editor
|
||||||
|
v-else
|
||||||
|
ref="tiptapEditorRef"
|
||||||
|
:model-value="editorContent"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:upload-image="handleUploadImage"
|
||||||
|
:upload-video="handleUploadVideo"
|
||||||
|
@change="onEditorChange"
|
||||||
|
@update:model-value="onEditorCommit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Article, ArticleContentType, Attachment, UploadResult } from "timi-web/editor";
|
||||||
|
import { AttachmentAPI } from "timi-web/editor";
|
||||||
|
import { MarkdownEditor } from "timi-web";
|
||||||
|
import { TiptapEditor } from "../tiptap-editor";
|
||||||
|
import type { TiptapEditorExpose } from "../tiptap-editor";
|
||||||
|
import {
|
||||||
|
ATTACHMENT_ID_ATTR,
|
||||||
|
TEMP_FILE_ID_ATTR,
|
||||||
|
buildAttachmentListFromContent,
|
||||||
|
getAttachmentIdFromSrc,
|
||||||
|
getTempFileIdFromSrc,
|
||||||
|
normalizeArticleContentType,
|
||||||
|
normalizeContent,
|
||||||
|
normalizeContentForStore,
|
||||||
|
normalizeRawArticleContent,
|
||||||
|
sameAttachmentList
|
||||||
|
} from "timi-web/editor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ArticleEditor"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue?: Article;
|
||||||
|
placeholder?: string;
|
||||||
|
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||||
|
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||||
|
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
|
||||||
|
resolveTempUrl?: (tempFileId: string) => string | Promise<string>;
|
||||||
|
}>(), {
|
||||||
|
modelValue: () => ({}),
|
||||||
|
placeholder: "请输入文章正文内容"
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"change": [value: Article];
|
||||||
|
"update:modelValue": [value: Article];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const textareaAutosize = {
|
||||||
|
minRows: 8,
|
||||||
|
maxRows: 32
|
||||||
|
};
|
||||||
|
|
||||||
|
const editorContent = ref("");
|
||||||
|
const tiptapEditorRef = ref<TiptapEditorExpose>();
|
||||||
|
const lastOutgoingContent = ref("");
|
||||||
|
const lastOutgoingAttachmentList = ref<Attachment[]>([]);
|
||||||
|
|
||||||
|
const contentType = computed(() => {
|
||||||
|
return normalizeArticleContentType(props.modelValue?.contentType);
|
||||||
|
});
|
||||||
|
const isTextContent = computed(() => contentType.value === "TEXT");
|
||||||
|
const isMarkdownContent = computed(() => contentType.value === "MARKDOWN");
|
||||||
|
|
||||||
|
const attachmentMap = computed(() => {
|
||||||
|
return new Map((props.modelValue?.attachmentList || []).map(item => [item.id, item]));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function defaultUploadImage(file: File) {
|
||||||
|
return defaultUploadMedia(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultUploadVideo(file: File) {
|
||||||
|
return defaultUploadMedia(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultUploadMedia(file: File) {
|
||||||
|
const tempFileList = await AttachmentAPI.upload([file]);
|
||||||
|
const tempFileId = tempFileList[0]?.id || "";
|
||||||
|
if (!tempFileId) {
|
||||||
|
throw new Error("上传临时文件失败");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: tempFileId,
|
||||||
|
url: AttachmentAPI.getTempReadURL(tempFileId),
|
||||||
|
name: file.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadImage(file: File) {
|
||||||
|
if (props.uploadImage) {
|
||||||
|
return props.uploadImage(file);
|
||||||
|
}
|
||||||
|
return defaultUploadImage(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadVideo(file: File) {
|
||||||
|
if (props.uploadVideo) {
|
||||||
|
return props.uploadVideo(file);
|
||||||
|
}
|
||||||
|
return defaultUploadVideo(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAttachmentPreviewUrl(attachmentId: string) {
|
||||||
|
const attachment = attachmentMap.value.get(attachmentId);
|
||||||
|
if (props.resolveAttachmentUrl) {
|
||||||
|
const customUrl = await props.resolveAttachmentUrl(attachmentId, attachment);
|
||||||
|
if (customUrl) {
|
||||||
|
return customUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const previewUrl = `${attachment?.metadata?.previewUrl || ""}`;
|
||||||
|
if (previewUrl) {
|
||||||
|
return previewUrl;
|
||||||
|
}
|
||||||
|
return attachmentId ? AttachmentAPI.getReadURL(attachmentId) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTempPreviewUrl(tempFileId: string) {
|
||||||
|
if (props.resolveTempUrl) {
|
||||||
|
return props.resolveTempUrl(tempFileId);
|
||||||
|
}
|
||||||
|
return AttachmentAPI.getTempReadURL(tempFileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertContentForEditor(value?: string) {
|
||||||
|
if (contentType.value !== "TIP_TAP") {
|
||||||
|
return normalizeRawArticleContent(value);
|
||||||
|
}
|
||||||
|
const rawContent = normalizeContent(value);
|
||||||
|
if (!rawContent) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(rawContent, "text/html");
|
||||||
|
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||||
|
for (const media of mediaList) {
|
||||||
|
const src = media.getAttribute("src") || "";
|
||||||
|
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
|
||||||
|
const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src);
|
||||||
|
if (tempFileId) {
|
||||||
|
media.setAttribute(TEMP_FILE_ID_ATTR, tempFileId);
|
||||||
|
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId || tempFileId);
|
||||||
|
media.setAttribute("src", await resolveTempPreviewUrl(tempFileId));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!attachmentId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId);
|
||||||
|
const previewUrl = await resolveAttachmentPreviewUrl(attachmentId);
|
||||||
|
if (previewUrl) {
|
||||||
|
media.setAttribute("src", previewUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeContent(doc.body.innerHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIncomingArticleContent(content: string | undefined, currentContentType: ArticleContentType) {
|
||||||
|
if (currentContentType === "TIP_TAP") {
|
||||||
|
return normalizeContent(content);
|
||||||
|
}
|
||||||
|
return normalizeRawArticleContent(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoredContent(content: string, currentContentType: ArticleContentType) {
|
||||||
|
if (currentContentType === "TIP_TAP") {
|
||||||
|
return normalizeContentForStore(content);
|
||||||
|
}
|
||||||
|
return normalizeRawArticleContent(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArticleAttachmentList(content: string, currentContentType: ArticleContentType) {
|
||||||
|
if (currentContentType === "TIP_TAP") {
|
||||||
|
return buildAttachmentListFromContent(content);
|
||||||
|
}
|
||||||
|
return props.modelValue?.attachmentList || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArticleValue(content: string) {
|
||||||
|
const nextContentType = contentType.value;
|
||||||
|
return {
|
||||||
|
...(props.modelValue || {}),
|
||||||
|
contentType: nextContentType,
|
||||||
|
content: buildStoredContent(content, nextContentType),
|
||||||
|
attachmentList: buildArticleAttachmentList(content, nextContentType)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLastOutgoingArticle(value: Article) {
|
||||||
|
const nextContentType = normalizeArticleContentType(value.contentType);
|
||||||
|
lastOutgoingContent.value = normalizeIncomingArticleContent(value.content, nextContentType);
|
||||||
|
lastOutgoingAttachmentList.value = value.attachmentList || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实时变更只给预览态使用,不能反向驱动编辑器提交态
|
||||||
|
function onEditorChange(value: string) {
|
||||||
|
const nextValue = buildArticleValue(value);
|
||||||
|
syncLastOutgoingArticle(nextValue);
|
||||||
|
emit("change", {
|
||||||
|
...nextValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交态只在离开代码块后的同步时机更新
|
||||||
|
function onEditorCommit(value: string) {
|
||||||
|
editorContent.value = value;
|
||||||
|
const nextValue = buildArticleValue(value);
|
||||||
|
syncLastOutgoingArticle(nextValue);
|
||||||
|
emit("update:modelValue", {
|
||||||
|
...nextValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSimpleEditorCommit(value: string) {
|
||||||
|
editorContent.value = value;
|
||||||
|
const nextValue = buildArticleValue(value);
|
||||||
|
syncLastOutgoingArticle(nextValue);
|
||||||
|
emit("change", {
|
||||||
|
...nextValue
|
||||||
|
});
|
||||||
|
emit("update:modelValue", {
|
||||||
|
...nextValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.modelValue?.content, contentType.value] as const, async ([value, nextContentType]) => {
|
||||||
|
const nextValue = normalizeIncomingArticleContent(value, nextContentType);
|
||||||
|
if (nextValue === lastOutgoingContent.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (buildStoredContent(editorContent.value, nextContentType) === nextValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextContent = await convertContentForEditor(nextValue);
|
||||||
|
if (nextContent === editorContent.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editorContent.value = nextContent;
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(() => props.modelValue?.attachmentList, async (value) => {
|
||||||
|
if (contentType.value !== "TIP_TAP") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sameAttachmentList(lastOutgoingAttachmentList.value, value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sameAttachmentList(buildAttachmentListFromContent(editorContent.value), value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextContent = await convertContentForEditor(props.modelValue?.content);
|
||||||
|
if (nextContent === editorContent.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editorContent.value = nextContent;
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const nextEditorContent = contentType.value === "TIP_TAP" && tiptapEditorRef.value ? await tiptapEditorRef.value.submit() : editorContent.value;
|
||||||
|
const nextValue = buildArticleValue(nextEditorContent);
|
||||||
|
editorContent.value = nextEditorContent;
|
||||||
|
syncLastOutgoingArticle(nextValue);
|
||||||
|
emit("update:modelValue", nextValue);
|
||||||
|
return nextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
submit,
|
||||||
|
getPendingMediaCount: () => tiptapEditorRef.value?.getPendingMediaCount() || 0
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import { Toolkit } from "timi-web";
|
||||||
|
|
||||||
|
export const ArticleView = Toolkit.withInstall(view);
|
||||||
|
export default ArticleView;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<t-textarea
|
||||||
|
v-if="isTextContent && hasRawContent"
|
||||||
|
class="text-view"
|
||||||
|
:model-value="rawContent"
|
||||||
|
:autosize="textareaAutosize"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<t-empty v-else-if="isTextContent" :description="emptyText" />
|
||||||
|
<markdown-view v-else-if="isMarkdownContent && hasRawContent" :content="rawContent" />
|
||||||
|
<t-empty v-else-if="isMarkdownContent" :description="emptyText" />
|
||||||
|
<tiptap-view v-else :content="renderContent" :empty-text="emptyText" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Article, Attachment } from "timi-web/editor";
|
||||||
|
import { AttachmentAPI } from "timi-web/editor";
|
||||||
|
import { MarkdownView } from "timi-web";
|
||||||
|
import { TiptapView } from "../tiptap-view";
|
||||||
|
import {
|
||||||
|
ATTACHMENT_ID_ATTR,
|
||||||
|
getAttachmentIdFromSrc,
|
||||||
|
normalizeArticleContentType,
|
||||||
|
normalizeContent,
|
||||||
|
normalizeRawArticleContent
|
||||||
|
} from "timi-web/editor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ArticleView"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
article?: Article;
|
||||||
|
emptyText?: string;
|
||||||
|
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
|
||||||
|
}>(), {
|
||||||
|
article: () => ({}),
|
||||||
|
emptyText: "暂无正文内容"
|
||||||
|
});
|
||||||
|
|
||||||
|
const textareaAutosize = {
|
||||||
|
minRows: 8,
|
||||||
|
maxRows: 32
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = ref("");
|
||||||
|
|
||||||
|
const contentType = computed(() => {
|
||||||
|
return normalizeArticleContentType(props.article?.contentType);
|
||||||
|
});
|
||||||
|
const isTextContent = computed(() => contentType.value === "TEXT");
|
||||||
|
const isMarkdownContent = computed(() => contentType.value === "MARKDOWN");
|
||||||
|
const rawContent = computed(() => {
|
||||||
|
return normalizeRawArticleContent(props.article?.content);
|
||||||
|
});
|
||||||
|
const hasRawContent = computed(() => {
|
||||||
|
return 0 < rawContent.value.trim().length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachmentMap = computed(() => {
|
||||||
|
return new Map((props.article?.attachmentList || []).map(item => [item.id, item]));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function buildAttachmentPreviewUrl(attachmentId: string) {
|
||||||
|
const attachment = attachmentMap.value.get(attachmentId);
|
||||||
|
if (props.resolveAttachmentUrl) {
|
||||||
|
const customUrl = await props.resolveAttachmentUrl(attachmentId, attachment);
|
||||||
|
if (customUrl) {
|
||||||
|
return customUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const previewUrl = `${attachment?.metadata?.previewUrl || ""}`;
|
||||||
|
if (previewUrl) {
|
||||||
|
return previewUrl;
|
||||||
|
}
|
||||||
|
return attachmentId ? AttachmentAPI.getReadURL(attachmentId) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildRenderContent(content?: string) {
|
||||||
|
const rawContent = normalizeContent(content);
|
||||||
|
if (!rawContent) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(rawContent, "text/html");
|
||||||
|
const nodes = Array.from(doc.querySelectorAll("img, video, source"));
|
||||||
|
for (const node of nodes) {
|
||||||
|
const src = node.getAttribute("src") || "";
|
||||||
|
const attachmentId = node.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
|
||||||
|
if (!attachmentId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const previewUrl = await buildAttachmentPreviewUrl(attachmentId);
|
||||||
|
if (previewUrl) {
|
||||||
|
node.setAttribute("src", previewUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeContent(doc.body.innerHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.article?.content, props.article?.attachmentList, contentType.value] as const,
|
||||||
|
async ([, , nextContentType]) => {
|
||||||
|
if (nextContentType !== "TIP_TAP") {
|
||||||
|
renderContent.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderContent.value = await buildRenderContent(props.article?.content);
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.text-view {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<t-form-item label="验证码" name="captcha">
|
<t-form-item label="验证码" name="captcha">
|
||||||
<div class="captcha-box">
|
<div class="captcha-box">
|
||||||
<t-input class="captcha" v-model="loginRequest.captcha" placeholder="" />
|
<t-input class="captcha" v-model="loginRequest.captcha" placeholder="" />
|
||||||
<captcha :api="CommonAPI.getCaptchaAPI()" :width="90" :height="28" :from="CaptchaFrom.LOGIN" />
|
<captcha :api="CommonAPI.getCaptchaAPI()" :width="90" :height="28" />
|
||||||
</div>
|
</div>
|
||||||
</t-form-item>
|
</t-form-item>
|
||||||
<div class="exec">
|
<div class="exec">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Captcha, CaptchaData, CaptchaFrom, CommonAPI, LoginRequest, UserAPI, userStore } from "timi-web";
|
import { Captcha, CaptchaData, CommonAPI, LoginRequest, UserAPI } from "timi-web";
|
||||||
import { Emits, useHandler } from "~/components/authorize-form/emits";
|
import { Emits, useHandler } from "~/components/authorize-form/emits";
|
||||||
import { MessagePlugin } from "tdesign-vue-next";
|
import { MessagePlugin } from "tdesign-vue-next";
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ const { onLoginSuccess } = useHandler(emits);
|
|||||||
const doSubmitting = ref<boolean>(false);
|
const doSubmitting = ref<boolean>(false);
|
||||||
|
|
||||||
const loginRequest = reactive<CaptchaData<LoginRequest>>({
|
const loginRequest = reactive<CaptchaData<LoginRequest>>({
|
||||||
from: CaptchaFrom.LOGIN,
|
captchaId: "",
|
||||||
captcha: "",
|
captcha: "",
|
||||||
data: {
|
data: {
|
||||||
user: "",
|
user: "",
|
||||||
@@ -66,7 +66,7 @@ const loginRequest = reactive<CaptchaData<LoginRequest>>({
|
|||||||
const doSubmit = async () => {
|
const doSubmit = async () => {
|
||||||
doSubmitting.value = true;
|
doSubmitting.value = true;
|
||||||
UserAPI.login(loginRequest).then(async (response) => {
|
UserAPI.login(loginRequest).then(async (response) => {
|
||||||
await userStore.updateToken(response);
|
// await userStore.updateToken(response);
|
||||||
await onLoginSuccess();
|
await onLoginSuccess();
|
||||||
doSubmitting.value = false;
|
doSubmitting.value = false;
|
||||||
}).catch(msg => {
|
}).catch(msg => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<t-form-item label="验证码" name="captcha">
|
<t-form-item label="验证码" name="captcha">
|
||||||
<div class="captcha-box">
|
<div class="captcha-box">
|
||||||
<t-input class="captcha" v-model="formData.captcha" placeholder="" />
|
<t-input class="captcha" v-model="formData.captcha" placeholder="" />
|
||||||
<captcha :api="CommonAPI.getCaptchaAPI()" :width="90" :height="28" :from="CaptchaFrom.REGISTER" />
|
<captcha :api="CommonAPI.getCaptchaAPI()" :width="90" :height="28" />
|
||||||
</div>
|
</div>
|
||||||
</t-form-item>
|
</t-form-item>
|
||||||
<div class="exec">
|
<div class="exec">
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Captcha, CaptchaData, CaptchaFrom, CommonAPI, RegisterRequest, UserAPI, userStore } from "timi-web";
|
import { Captcha, CaptchaData, CommonAPI, RegisterRequest, UserAPI } from "timi-web";
|
||||||
import { FormRules, MessagePlugin, SubmitContext } from "tdesign-vue-next";
|
import { FormRules, MessagePlugin, SubmitContext } from "tdesign-vue-next";
|
||||||
import { Emits, useHandler } from "~/components/authorize-form/emits";
|
import { Emits, useHandler } from "~/components/authorize-form/emits";
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ const { onRegisterSuccess } = useHandler(emits);
|
|||||||
const doSubmitting = ref<boolean>(false);
|
const doSubmitting = ref<boolean>(false);
|
||||||
|
|
||||||
const formData = reactive<CaptchaData<RegisterForm>>({
|
const formData = reactive<CaptchaData<RegisterForm>>({
|
||||||
from: CaptchaFrom.LOGIN,
|
captchaId: "",
|
||||||
captcha: "",
|
captcha: "",
|
||||||
data: {
|
data: {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -101,7 +101,7 @@ const doSubmit = ({validateResult, e}: SubmitContext) => {
|
|||||||
if (validateResult === true) {
|
if (validateResult === true) {
|
||||||
doSubmitting.value = true;
|
doSubmitting.value = true;
|
||||||
UserAPI.register({...formData}).then(async (response) => {
|
UserAPI.register({...formData}).then(async (response) => {
|
||||||
await userStore.updateToken(response);
|
// await userStore.updateToken(response);
|
||||||
await onRegisterSuccess();
|
await onRegisterSuccess();
|
||||||
doSubmitting.value = false;
|
doSubmitting.value = false;
|
||||||
}).catch(msg => {
|
}).catch(msg => {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import { Toolkit } from "timi-web";
|
||||||
|
import "./style.less";
|
||||||
|
|
||||||
|
export type CodeEditorScrollState = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CodeEditorExpose = {
|
||||||
|
focus: () => void;
|
||||||
|
blur: () => void;
|
||||||
|
getScrollState: () => CodeEditorScrollState;
|
||||||
|
setScrollState: (state: Partial<CodeEditorScrollState>) => void;
|
||||||
|
findNext: () => boolean;
|
||||||
|
findPrevious: () => boolean;
|
||||||
|
replaceCurrent: () => boolean;
|
||||||
|
replaceAll: () => number;
|
||||||
|
openSearch: (showReplace: boolean) => void;
|
||||||
|
closeSearch: () => void;
|
||||||
|
textareaRef?: HTMLTextAreaElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CodeEditor = Toolkit.withInstall(view);
|
||||||
|
export default CodeEditor;
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="rootRef"
|
||||||
|
class="tui-code-editor"
|
||||||
|
:class="{
|
||||||
|
readonly,
|
||||||
|
disabled,
|
||||||
|
'with-line-numbers': lineNumbers
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div v-if="lineNumbers" ref="gutterRef" class="gutter diselect" aria-hidden="true">
|
||||||
|
<span v-for="line in lineCount" :key="line" v-text="line" />
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<editor-search-bar
|
||||||
|
ref="searchBarRef"
|
||||||
|
:visible="searchVisible"
|
||||||
|
:expanded="replaceExpanded"
|
||||||
|
:keyword="searchKeyword"
|
||||||
|
:replace-value="replaceKeyword"
|
||||||
|
:status-error="searchError"
|
||||||
|
:match-index="searchMatchIndex"
|
||||||
|
:match-count="searchMatchList.length"
|
||||||
|
:case-sensitive="searchOptions.caseSensitive"
|
||||||
|
:use-regex="searchOptions.useRegex"
|
||||||
|
@update:expanded="replaceExpanded = $event"
|
||||||
|
@update:keyword="searchKeyword = $event"
|
||||||
|
@update:replace-value="replaceKeyword = $event"
|
||||||
|
@update:case-sensitive="searchOptions.caseSensitive = $event"
|
||||||
|
@update:use-regex="searchOptions.useRegex = $event"
|
||||||
|
@previous="findPrevious"
|
||||||
|
@next="onSearchNext"
|
||||||
|
@replace="replaceCurrent"
|
||||||
|
@replace-all="replaceAll"
|
||||||
|
@close="closeSearch"
|
||||||
|
/>
|
||||||
|
<pre
|
||||||
|
ref="preRef"
|
||||||
|
class="preview cur-text"
|
||||||
|
:class="languageClass"
|
||||||
|
@scroll="onPreviewScroll"
|
||||||
|
><code :class="languageClass" v-html="highlightedHTML"></code></pre>
|
||||||
|
<textarea
|
||||||
|
v-if="!readonly"
|
||||||
|
ref="textareaRef"
|
||||||
|
v-model="currentValue"
|
||||||
|
class="input"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
wrap="off"
|
||||||
|
:spellcheck="false"
|
||||||
|
@input="onInput"
|
||||||
|
@scroll="onScroll"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import EditorSearchBar from "../editor-search/index.vue";
|
||||||
|
import {
|
||||||
|
findTextMatches,
|
||||||
|
replaceAllText,
|
||||||
|
replaceMatchText,
|
||||||
|
type TextSearchMatch,
|
||||||
|
type TextSearchOptions
|
||||||
|
} from "timi-web/editor";
|
||||||
|
import { highlightCode, resolveCodeEditorKeydown, resolvePrismLanguage } from "timi-web/editor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "CodeEditor"
|
||||||
|
});
|
||||||
|
|
||||||
|
type ScrollState = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditorSearchBarExpose = {
|
||||||
|
focusKeyword: (selectAll?: boolean) => void;
|
||||||
|
focusReplace: (selectAll?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue?: string;
|
||||||
|
value?: string;
|
||||||
|
language?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
lineNumbers?: boolean;
|
||||||
|
}>(), {
|
||||||
|
modelValue: "",
|
||||||
|
language: "typescript",
|
||||||
|
placeholder: "",
|
||||||
|
readonly: false,
|
||||||
|
disabled: false,
|
||||||
|
lineNumbers: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "update:modelValue", value: string): void;
|
||||||
|
(event: "change", value: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const bindValue = computed(() => props.value ?? props.modelValue);
|
||||||
|
const currentValue = ref(bindValue.value);
|
||||||
|
const rootRef = ref<HTMLDivElement>();
|
||||||
|
const gutterRef = ref<HTMLDivElement>();
|
||||||
|
const preRef = ref<HTMLPreElement>();
|
||||||
|
const textareaRef = ref<HTMLTextAreaElement>();
|
||||||
|
const searchBarRef = ref<EditorSearchBarExpose>();
|
||||||
|
const searchVisible = ref(false);
|
||||||
|
const replaceExpanded = ref(false);
|
||||||
|
const searchKeyword = ref("");
|
||||||
|
const replaceKeyword = ref("");
|
||||||
|
const searchError = ref("");
|
||||||
|
const searchMatchIndex = ref(-1);
|
||||||
|
const searchMatchList = ref<TextSearchMatch[]>([]);
|
||||||
|
const scrollState = reactive<ScrollState>({
|
||||||
|
top: 0,
|
||||||
|
left: 0
|
||||||
|
});
|
||||||
|
const searchOptions = reactive<TextSearchOptions>({
|
||||||
|
caseSensitive: false,
|
||||||
|
useRegex: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedLanguage = computed(() => resolvePrismLanguage(props.language));
|
||||||
|
const languageClass = computed(() => resolvedLanguage.value ? `language-${resolvedLanguage.value}` : "");
|
||||||
|
const lineCount = computed(() => Math.max(currentValue.value.split("\n").length, 1));
|
||||||
|
const highlightedHTML = computed(() => {
|
||||||
|
return highlightCode(currentValue.value, resolvedLanguage.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(bindValue, (value) => {
|
||||||
|
if (value === currentValue.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentValue.value = value;
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(syncScrollPosition);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([searchKeyword, () => searchOptions.caseSensitive, () => searchOptions.useRegex], () => {
|
||||||
|
refreshSearchMatchState();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onInput = () => {
|
||||||
|
emit("update:modelValue", currentValue.value);
|
||||||
|
emit("change", currentValue.value);
|
||||||
|
refreshSearchMatchState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
syncScrollPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPreviewScroll = () => {
|
||||||
|
if (!props.readonly || !preRef.value || !gutterRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scrollState.top = preRef.value.scrollTop;
|
||||||
|
scrollState.left = preRef.value.scrollLeft;
|
||||||
|
gutterRef.value.scrollTop = preRef.value.scrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeydown = (event: KeyboardEvent) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if ("f" === key) {
|
||||||
|
event.preventDefault();
|
||||||
|
openSearch(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("h" === key && !props.readonly) {
|
||||||
|
event.preventDefault();
|
||||||
|
openSearch(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (props.readonly || props.disabled || !textareaRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = textareaRef.value;
|
||||||
|
const nextState = resolveCodeEditorKeydown(currentValue.value, {
|
||||||
|
start: textarea.selectionStart,
|
||||||
|
end: textarea.selectionEnd
|
||||||
|
}, event);
|
||||||
|
if (!nextState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
currentValue.value = nextState.value;
|
||||||
|
emit("update:modelValue", currentValue.value);
|
||||||
|
emit("change", currentValue.value);
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
textarea.selectionStart = nextState.start;
|
||||||
|
textarea.selectionEnd = nextState.end;
|
||||||
|
syncScrollPosition();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScrollState = (): ScrollState => ({
|
||||||
|
top: scrollState.top,
|
||||||
|
left: scrollState.left
|
||||||
|
});
|
||||||
|
|
||||||
|
const setScrollState = (nextState: Partial<ScrollState>) => {
|
||||||
|
scrollState.top = Math.max(0, nextState.top || 0);
|
||||||
|
scrollState.left = Math.max(0, nextState.left || 0);
|
||||||
|
applyScrollState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncScrollPosition = () => {
|
||||||
|
if (textareaRef.value) {
|
||||||
|
scrollState.top = textareaRef.value.scrollTop;
|
||||||
|
scrollState.left = textareaRef.value.scrollLeft;
|
||||||
|
applyScrollState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.readonly && preRef.value) {
|
||||||
|
scrollState.top = preRef.value.scrollTop;
|
||||||
|
scrollState.left = preRef.value.scrollLeft;
|
||||||
|
applyScrollState();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyScrollState = () => {
|
||||||
|
if (textareaRef.value) {
|
||||||
|
textareaRef.value.scrollTop = scrollState.top;
|
||||||
|
textareaRef.value.scrollLeft = scrollState.left;
|
||||||
|
}
|
||||||
|
if (preRef.value) {
|
||||||
|
preRef.value.scrollTop = scrollState.top;
|
||||||
|
preRef.value.scrollLeft = scrollState.left;
|
||||||
|
}
|
||||||
|
if (gutterRef.value) {
|
||||||
|
gutterRef.value.scrollTop = scrollState.top;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectionKeyword = () => {
|
||||||
|
if (!textareaRef.value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const { selectionStart, selectionEnd } = textareaRef.value;
|
||||||
|
if (selectionStart === selectionEnd) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return currentValue.value.slice(selectionStart, selectionEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshSearchMatchState = () => {
|
||||||
|
const result = findTextMatches(currentValue.value, searchKeyword.value, searchOptions);
|
||||||
|
searchError.value = result.error;
|
||||||
|
searchMatchList.value = result.matches;
|
||||||
|
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
|
||||||
|
searchMatchIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { selectionStart, selectionEnd } = textareaRef.value;
|
||||||
|
searchMatchIndex.value = searchMatchList.value.findIndex((match) => (
|
||||||
|
match.start === selectionStart && match.end === selectionEnd
|
||||||
|
));
|
||||||
|
if (0 > searchMatchIndex.value) {
|
||||||
|
searchMatchIndex.value = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSearch = (showReplace: boolean) => {
|
||||||
|
searchVisible.value = true;
|
||||||
|
replaceExpanded.value = showReplace;
|
||||||
|
const selectedText = getSelectionKeyword();
|
||||||
|
if (selectedText) {
|
||||||
|
searchKeyword.value = selectedText;
|
||||||
|
}
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
searchBarRef.value?.focusKeyword(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSearch = () => {
|
||||||
|
searchVisible.value = false;
|
||||||
|
replaceExpanded.value = false;
|
||||||
|
focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectMatch = (match: TextSearchMatch, matchIndex: number) => {
|
||||||
|
if (!textareaRef.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
textareaRef.value.focus();
|
||||||
|
textareaRef.value.selectionStart = match.start;
|
||||||
|
textareaRef.value.selectionEnd = match.end;
|
||||||
|
textareaRef.value.setSelectionRange(match.start, match.end);
|
||||||
|
textareaRef.value.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||||
|
scrollState.top = textareaRef.value.scrollTop;
|
||||||
|
scrollState.left = textareaRef.value.scrollLeft;
|
||||||
|
applyScrollState();
|
||||||
|
searchMatchIndex.value = matchIndex;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findNext = () => {
|
||||||
|
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { selectionEnd } = textareaRef.value;
|
||||||
|
const nextIndex = searchMatchList.value.findIndex((match) => match.start >= selectionEnd);
|
||||||
|
const targetIndex = 0 <= nextIndex ? nextIndex : 0;
|
||||||
|
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findPrevious = () => {
|
||||||
|
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { selectionStart } = textareaRef.value;
|
||||||
|
let targetIndex = searchMatchList.value.length - 1;
|
||||||
|
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
|
||||||
|
if (searchMatchList.value[index].start < selectionStart) {
|
||||||
|
targetIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearchNext = (shiftKey: boolean) => {
|
||||||
|
if (shiftKey) {
|
||||||
|
findPrevious();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
findNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceSelection = (match: TextSearchMatch) => {
|
||||||
|
const nextText = replaceMatchText(match.text, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||||
|
currentValue.value = currentValue.value.slice(0, match.start) + nextText + currentValue.value.slice(match.end);
|
||||||
|
emit("update:modelValue", currentValue.value);
|
||||||
|
emit("change", currentValue.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceCurrent = () => {
|
||||||
|
if (props.readonly || !textareaRef.value || !searchKeyword.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { selectionStart, selectionEnd } = textareaRef.value;
|
||||||
|
let currentMatchIndex = searchMatchList.value.findIndex((match) => (
|
||||||
|
match.start === selectionStart && match.end === selectionEnd
|
||||||
|
));
|
||||||
|
if (0 > currentMatchIndex) {
|
||||||
|
if (!findNext()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
currentMatchIndex = searchMatchIndex.value;
|
||||||
|
}
|
||||||
|
const currentMatch = searchMatchList.value[currentMatchIndex];
|
||||||
|
if (!currentMatch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
replaceSelection(currentMatch);
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
const nextMatch = searchMatchList.value[currentMatchIndex] || searchMatchList.value[currentMatchIndex - 1];
|
||||||
|
if (!nextMatch) {
|
||||||
|
focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectMatch(nextMatch, Math.min(currentMatchIndex, searchMatchList.value.length - 1));
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceAll = () => {
|
||||||
|
if (props.readonly || !searchKeyword.value) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const result = replaceAllText(currentValue.value, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||||
|
searchError.value = result.error;
|
||||||
|
if (result.error || 1 > result.count) {
|
||||||
|
refreshSearchMatchState();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
currentValue.value = result.value;
|
||||||
|
emit("update:modelValue", currentValue.value);
|
||||||
|
emit("change", currentValue.value);
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
if (searchMatchList.value[0]) {
|
||||||
|
selectMatch(searchMatchList.value[0], 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
focus();
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const focus = () => {
|
||||||
|
textareaRef.value?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const blur = () => {
|
||||||
|
textareaRef.value?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
applyScrollState();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
rootRef,
|
||||||
|
textareaRef,
|
||||||
|
focus,
|
||||||
|
blur,
|
||||||
|
getScrollState,
|
||||||
|
setScrollState,
|
||||||
|
findNext,
|
||||||
|
findPrevious,
|
||||||
|
replaceCurrent,
|
||||||
|
replaceAll,
|
||||||
|
openSearch,
|
||||||
|
closeSearch
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
.tui-code-editor {
|
||||||
|
width: calc(100% - 2px);
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--td-radius-medium, 0);
|
||||||
|
|
||||||
|
.gutter {
|
||||||
|
flex: none;
|
||||||
|
color: var(--td-text-color-placeholder, #999);
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: right;
|
||||||
|
background: #F2F2F2E6;
|
||||||
|
border-right: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
|
||||||
|
> span {
|
||||||
|
width: 3.25rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
padding-right: .25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
top: .5rem;
|
||||||
|
right: .5rem;
|
||||||
|
gap: .5rem;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
padding: .5rem;
|
||||||
|
position: absolute;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--td-radius-default, .375rem);
|
||||||
|
background: var(--td-bg-color-container, #fff);
|
||||||
|
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 10rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0 .5rem;
|
||||||
|
color: var(--td-text-color-primary, #111);
|
||||||
|
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
background: var(--td-bg-color-container, #fff);
|
||||||
|
border-radius: var(--td-radius-default, .375rem);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-actions {
|
||||||
|
gap: .25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button {
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0 .625rem;
|
||||||
|
color: var(--td-text-color-primary, #111);
|
||||||
|
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
background: var(--td-bg-color-container, #fff);
|
||||||
|
border-radius: var(--td-radius-default, .375rem);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--td-bg-color-container-hover, #f6f6f6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-status {
|
||||||
|
color: var(--td-text-color-secondary, #666);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview,
|
||||||
|
.input {
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
tab-size: 4;
|
||||||
|
overflow: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border: none;
|
||||||
|
position: absolute;
|
||||||
|
word-break: normal;
|
||||||
|
background: transparent;
|
||||||
|
transition: none;
|
||||||
|
white-space: pre;
|
||||||
|
border-radius: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 0;
|
||||||
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
text-shadow: none;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
color: transparent;
|
||||||
|
resize: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
position: absolute;
|
||||||
|
background: transparent;
|
||||||
|
white-space: pre;
|
||||||
|
word-break: normal;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
caret-color: var(--td-text-color-primary, #111);
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--td-text-color-placeholder, #999);
|
||||||
|
-webkit-text-fill-color: var(--td-text-color-placeholder, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::selection {
|
||||||
|
background: rgb(0 122 255 / 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: .65;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.readonly {
|
||||||
|
.preview {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.tui-code-editor {
|
||||||
|
border-color: var(--td-component-stroke, #434343);
|
||||||
|
|
||||||
|
.gutter {
|
||||||
|
border-right-color: var(--td-component-stroke, #434343);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
background: var(--td-bg-color-container, #1f1f1f);
|
||||||
|
border-color: var(--td-component-stroke, #434343);
|
||||||
|
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input,
|
||||||
|
.search-button {
|
||||||
|
color: var(--td-text-color-primary, #F5F5F5);
|
||||||
|
border-color: var(--td-component-stroke, #434343);
|
||||||
|
background: var(--td-bg-color-container, #1f1f1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button:hover {
|
||||||
|
background: var(--td-bg-color-container-hover, #2b2b2b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
caret-color: var(--td-text-color-primary, #F5F5F5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::selection {
|
||||||
|
background: rgb(64 153 255 / 28%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Comment } from "timi-web";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* emits
|
|
||||||
*/
|
|
||||||
export interface Emits {
|
|
||||||
(event: "submit"): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useHandler = (emit: Emits) => {
|
|
||||||
|
|
||||||
const onSubmit = async (comment: Comment) => await emit("submit");
|
|
||||||
|
|
||||||
return {
|
|
||||||
onSubmit,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import view from "./index.vue";
|
|
||||||
import { Toolkit } from "timi-web";
|
|
||||||
|
|
||||||
export const CommentForm = Toolkit.withInstall(view);
|
|
||||||
export default CommentForm;
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<template>
|
|
||||||
<t-form class="tui-comment-form">
|
|
||||||
<t-form-item label="昵称" name="nick">
|
|
||||||
<t-input
|
|
||||||
class="nick"
|
|
||||||
v-model="formData.data.nick"
|
|
||||||
:disabled="userStore.isLogged()"
|
|
||||||
placeholder="昵称"
|
|
||||||
/>
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="评论" name="content">
|
|
||||||
<markdown-editor v-model:data="formData.data.content" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="验证码" name="captcha">
|
|
||||||
<t-input class="captcha" v-model="formData.captcha" placeholder="" />
|
|
||||||
<captcha
|
|
||||||
ref="captchaRef"
|
|
||||||
class="captcha-img"
|
|
||||||
:api="CommonAPI.getCaptchaAPI()"
|
|
||||||
:width="90"
|
|
||||||
:height="28"
|
|
||||||
:from="CaptchaFrom.COMMENT"
|
|
||||||
/>
|
|
||||||
<t-button
|
|
||||||
type="submit"
|
|
||||||
:loading="doSubmitting"
|
|
||||||
:disabled="doSubmitting"
|
|
||||||
@click="doSubmit"
|
|
||||||
>提交</t-button>
|
|
||||||
</t-form-item>
|
|
||||||
</t-form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import {
|
|
||||||
Captcha,
|
|
||||||
CaptchaData,
|
|
||||||
CaptchaFrom,
|
|
||||||
Comment,
|
|
||||||
CommentAPI,
|
|
||||||
CommentBizType,
|
|
||||||
CommonAPI,
|
|
||||||
MarkdownEditor,
|
|
||||||
userStore
|
|
||||||
} from "timi-web";
|
|
||||||
import { Emits, useHandler } from "~/components/comment/form/emits";
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: "CommentForm"
|
|
||||||
});
|
|
||||||
|
|
||||||
const emits = defineEmits<Emits>();
|
|
||||||
const { onSubmit } = useHandler(emits);
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
bizType: CommentBizType,
|
|
||||||
bizId: number,
|
|
||||||
}>(), {});
|
|
||||||
const { bizType, bizId } = toRefs(props);
|
|
||||||
|
|
||||||
const captchaRef = ref<{ update: () => void}>();
|
|
||||||
const formData = reactive<CaptchaData<Comment>>({
|
|
||||||
from: CaptchaFrom.COMMENT,
|
|
||||||
captcha: "",
|
|
||||||
data: {
|
|
||||||
bizType: bizType.value,
|
|
||||||
bizId: bizId.value,
|
|
||||||
nick: "",
|
|
||||||
content: ""
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const doSubmitting = ref(false);
|
|
||||||
|
|
||||||
async function doSubmit() {
|
|
||||||
doSubmitting.value = true;
|
|
||||||
|
|
||||||
const loginUser = userStore.loginUser;
|
|
||||||
if (userStore.isLogged() && loginUser.user) {
|
|
||||||
formData.data.nick = loginUser.user.name;
|
|
||||||
formData.data.userId = loginUser.user.id;
|
|
||||||
}
|
|
||||||
await CommentAPI.create(formData);
|
|
||||||
formData.captcha = "";
|
|
||||||
formData.data.content = "";
|
|
||||||
captchaRef.value?.update();
|
|
||||||
|
|
||||||
doSubmitting.value = false;
|
|
||||||
|
|
||||||
await onSubmit(formData.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登录用户
|
|
||||||
const updateNick = () => formData.data.nick = userStore.loginUser?.user?.name || "";
|
|
||||||
watch(userStore.loginUser, updateNick);
|
|
||||||
onMounted(updateNick);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.tui-comment-form {
|
|
||||||
padding: 1rem;
|
|
||||||
|
|
||||||
.nick {
|
|
||||||
width: 12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.captcha {
|
|
||||||
width: 6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.captcha-img {
|
|
||||||
margin: 0 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { CommentReply } from "timi-web";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* emits
|
|
||||||
*/
|
|
||||||
export interface Emits {
|
|
||||||
(event: "cancel"): Promise<void>;
|
|
||||||
(event: "submit", reply: CommentReply): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useHandler = (emit: Emits) => {
|
|
||||||
|
|
||||||
const onCancel = async () => await emit("cancel");
|
|
||||||
const onSubmit = async (reply: CommentReply) => await emit("submit", reply);
|
|
||||||
|
|
||||||
return {
|
|
||||||
onCancel,
|
|
||||||
onSubmit
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import view from "./index.vue";
|
|
||||||
import { Toolkit } from "timi-web";
|
|
||||||
|
|
||||||
export const CommentReplyForm = Toolkit.withInstall(view);
|
|
||||||
export default CommentReplyForm;
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
<template>
|
|
||||||
<t-form class="tui-comment-reply-form">
|
|
||||||
<t-form-item label="昵称" name="nick">
|
|
||||||
<t-input
|
|
||||||
ref="nickRef"
|
|
||||||
class="nick"
|
|
||||||
v-model="formData.data.senderNick"
|
|
||||||
placeholder="昵称"
|
|
||||||
:disabled="userStore.isLogged()"
|
|
||||||
/>
|
|
||||||
<span v-if="replyToNick" class="gray selectable clip-text" v-text="` 回复 ${replyToNick}`"></span>
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="评论" name="content">
|
|
||||||
<markdown-editor ref="contentRef" v-model:data="formData.data.content" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="验证码" name="captcha">
|
|
||||||
<div class="captcha-box">
|
|
||||||
<div>
|
|
||||||
<t-input class="captcha" v-model="formData.captcha" placeholder="" />
|
|
||||||
<captcha
|
|
||||||
ref="captchaRef"
|
|
||||||
class="captcha-img"
|
|
||||||
:api="CommonAPI.getCaptchaAPI()"
|
|
||||||
:width="90"
|
|
||||||
:height="28"
|
|
||||||
:from="CaptchaFrom.COMMENT_REPLY"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<t-button
|
|
||||||
class="submit"
|
|
||||||
type="submit"
|
|
||||||
:loading="doSubmitting"
|
|
||||||
:disabled="doSubmitting"
|
|
||||||
@click="doSubmit"
|
|
||||||
>提交</t-button>
|
|
||||||
<t-button theme="default" @click="onCancel">取消</t-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</t-form-item>
|
|
||||||
</t-form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import {
|
|
||||||
Captcha,
|
|
||||||
CaptchaData,
|
|
||||||
CaptchaFrom,
|
|
||||||
CommentAPI,
|
|
||||||
CommentReply,
|
|
||||||
CommonAPI,
|
|
||||||
MarkdownEditor,
|
|
||||||
Scroller,
|
|
||||||
userStore
|
|
||||||
} from "timi-web";
|
|
||||||
import { Emits, useHandler } from "~/components/comment/form/reply/emits";
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: "CommentReplyForm"
|
|
||||||
});
|
|
||||||
|
|
||||||
const emits = defineEmits<Emits>();
|
|
||||||
const { onCancel, onSubmit } = useHandler(emits);
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
commentId: number,
|
|
||||||
replyId?: number,
|
|
||||||
replyToNick?: string,
|
|
||||||
}>(), {});
|
|
||||||
const { commentId, replyId, replyToNick } = toRefs(props);
|
|
||||||
|
|
||||||
const doSubmitting = ref(false);
|
|
||||||
const nickRef = ref();
|
|
||||||
const contentRef = ref();
|
|
||||||
|
|
||||||
const formData = reactive<CaptchaData<CommentReply>>({
|
|
||||||
from: CaptchaFrom.COMMENT_REPLY,
|
|
||||||
captcha: "",
|
|
||||||
data: {
|
|
||||||
commentId: commentId.value,
|
|
||||||
replyId: replyId.value,
|
|
||||||
senderNick: "",
|
|
||||||
content: ""
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 提交回复 */
|
|
||||||
async function doSubmit() {
|
|
||||||
doSubmitting.value = true;
|
|
||||||
|
|
||||||
formData.data.commentId = commentId.value;
|
|
||||||
formData.data.replyId = replyId.value;
|
|
||||||
const loginUser = userStore.loginUser;
|
|
||||||
if (userStore.isLogged() && loginUser.user) {
|
|
||||||
formData.data.senderNick = loginUser.user.name;
|
|
||||||
formData.data.senderId = loginUser.user.id;
|
|
||||||
}
|
|
||||||
await CommentAPI.createReply(formData);
|
|
||||||
formData.captcha = "";
|
|
||||||
await onSubmit(formData.data);
|
|
||||||
|
|
||||||
doSubmitting.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function focus() {
|
|
||||||
await nextTick();
|
|
||||||
let focusEl: HTMLElement;
|
|
||||||
if (userStore.isLogged()) {
|
|
||||||
focusEl = nickRef.value[0].textArea;
|
|
||||||
} else {
|
|
||||||
focusEl = contentRef.value[0].inputRef;
|
|
||||||
}
|
|
||||||
focusEl.focus();
|
|
||||||
Scroller.toElement(focusEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登录用户
|
|
||||||
const updateNick = () => formData.data.senderNick = userStore.loginUser?.user?.name || "";
|
|
||||||
watch(userStore.loginUser, updateNick);
|
|
||||||
onMounted(updateNick);
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
focus
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.tui-comment-reply-form {
|
|
||||||
padding: 1rem 1rem 1rem 0;
|
|
||||||
|
|
||||||
.nick {
|
|
||||||
width: 12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.captcha-box {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
div {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.captcha {
|
|
||||||
width: 6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.captcha-img {
|
|
||||||
margin: 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit {
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 650px) {
|
|
||||||
.tui-comment-reply-form {
|
|
||||||
|
|
||||||
.captcha-box {
|
|
||||||
|
|
||||||
> div:first-child {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import view from "./index.vue";
|
|
||||||
import { Toolkit } from "timi-web";
|
|
||||||
|
|
||||||
export const Comment = Toolkit.withInstall(view);
|
|
||||||
export default Comment;
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="canComment || (pageResult && 0 < pageResult.total)"
|
|
||||||
class="tui-comment"
|
|
||||||
:class="{ 'can-comment': canComment }"
|
|
||||||
>
|
|
||||||
<span ref="titleAnchorRef"></span>
|
|
||||||
<h4
|
|
||||||
class="title cur-pointer"
|
|
||||||
@click="Scroller.toElement(titleAnchorRef!)"
|
|
||||||
>评论</h4>
|
|
||||||
<comment-form
|
|
||||||
ref="commentFormRef"
|
|
||||||
v-if="canComment"
|
|
||||||
:bizType="bizType" :bizId="bizId"
|
|
||||||
@submit="onSubmit"
|
|
||||||
/>
|
|
||||||
<t-pagination
|
|
||||||
v-if="pageResult && 16 < pageResult.total"
|
|
||||||
class="pages top"
|
|
||||||
:total="pageResult.total"
|
|
||||||
:pageSize="16"
|
|
||||||
:showPageSize="false"
|
|
||||||
v-model:current="pageCurrent"
|
|
||||||
:onCurrentChange="doFetch"
|
|
||||||
>
|
|
||||||
<template #total-content>
|
|
||||||
<div class="total" v-text="`共 ${pageResult.total} 条评论`"></div>
|
|
||||||
</template>
|
|
||||||
</t-pagination>
|
|
||||||
<comment-list class="list" v-if="pageResult" :items="pageResult.list" />
|
|
||||||
<t-pagination
|
|
||||||
v-if="pageResult && 16 < pageResult.total"
|
|
||||||
class="pages bottom"
|
|
||||||
:total="pageResult.total"
|
|
||||||
:pageSize="16"
|
|
||||||
:showPageSize="false"
|
|
||||||
v-model:current="pageCurrent"
|
|
||||||
:onCurrentChange="doFetch"
|
|
||||||
>
|
|
||||||
<template #total-content>
|
|
||||||
<div class="total" v-text="`共 ${pageResult.total} 条评论`"></div>
|
|
||||||
</template>
|
|
||||||
</t-pagination>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { Comment, CommentAPI, CommentBizType, Page, PageResult, Scroller } from "timi-web";
|
|
||||||
import { CommentForm, CommentList } from "~/components";
|
|
||||||
|
|
||||||
// 评论总成
|
|
||||||
defineOptions({
|
|
||||||
name: "Comment"
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
bizType: CommentBizType,
|
|
||||||
bizId: number,
|
|
||||||
canComment?: boolean,
|
|
||||||
titleStickyOffset?: number,
|
|
||||||
}>(), {
|
|
||||||
canComment: true,
|
|
||||||
titleStickyOffset: 0
|
|
||||||
});
|
|
||||||
const { bizType, bizId, canComment } = toRefs(props);
|
|
||||||
|
|
||||||
// 数据列表
|
|
||||||
const page = reactive<Page<Comment>>({
|
|
||||||
index: 0,
|
|
||||||
size: 16,
|
|
||||||
equalsExample: {
|
|
||||||
bizType: bizType.value,
|
|
||||||
bizId: bizId.value
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// 当前页
|
|
||||||
const pageCurrent = ref(1);
|
|
||||||
const pageResult = ref<PageResult<Comment>>();
|
|
||||||
async function doFetch() {
|
|
||||||
page.index = pageCurrent.value - 1;
|
|
||||||
pageResult.value = await CommentAPI.page(page);
|
|
||||||
for (let i = 0; i < pageResult.value.list.length; i++) {
|
|
||||||
const item = pageResult.value.list[i];
|
|
||||||
item.repliesCurrent = 1;
|
|
||||||
item.repliesPage = {
|
|
||||||
index: 0,
|
|
||||||
size: 6,
|
|
||||||
equalsExample: {
|
|
||||||
commentId: item.id!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onMounted(doFetch);
|
|
||||||
|
|
||||||
const titleAnchorRef = ref<HTMLSpanElement>();
|
|
||||||
|
|
||||||
/** 提交评论 */
|
|
||||||
async function onSubmit() {
|
|
||||||
page.index = 0;
|
|
||||||
await doFetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.tui-comment {
|
|
||||||
|
|
||||||
.title {
|
|
||||||
top: v-bind("titleStickyOffset + 'px'");
|
|
||||||
margin: 0;
|
|
||||||
padding: .5rem 1rem;
|
|
||||||
z-index: 2;
|
|
||||||
position: sticky;
|
|
||||||
background: #E4EFFA;
|
|
||||||
box-shadow: 0 -1px 0 var(--tui-light-gray);
|
|
||||||
border-bottom: var(--tui-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.can-comment {
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
border-top: 2px solid #CDDEF0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pages {
|
|
||||||
padding: 4px 4px 4px 1rem;
|
|
||||||
|
|
||||||
&.top {
|
|
||||||
border-top: 1px solid #E4EFFA;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.bottom {
|
|
||||||
border-bottom: 1px solid #E4EFFA;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* emits
|
|
||||||
*/
|
|
||||||
export interface Emits {
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useHandler = (emit: Emits) => {
|
|
||||||
|
|
||||||
return {
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import view from "./index.vue";
|
|
||||||
import { Toolkit } from "timi-web";
|
|
||||||
|
|
||||||
export const CommentList = Toolkit.withInstall(view);
|
|
||||||
export default CommentList;
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="items" class="tui-comment-list">
|
|
||||||
<div class="comment" v-for="comment in items" :key="comment.id">
|
|
||||||
<!-- 主评论 -->
|
|
||||||
<div class="user">
|
|
||||||
<!-- 主评论头像 -->
|
|
||||||
<template v-if="comment.user">
|
|
||||||
<a
|
|
||||||
:href="`/user/space/${comment.userId}`"
|
|
||||||
target="_blank"
|
|
||||||
v-popup:config="popupUserStore.config"
|
|
||||||
@mouseenter="popupUserStore.user.value = comment.user"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
class="avatar"
|
|
||||||
:class="(<any>ImageType)[comment.user.profile.avatarType]"
|
|
||||||
:src="UserAPI.getAvatarURL(comment.user.profile)"
|
|
||||||
alt="主评论头像"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="name pink selectable"
|
|
||||||
:href="`/user/space/${comment.userId}`"
|
|
||||||
v-text="comment.user.name"
|
|
||||||
v-popup:config="popupUserStore.config"
|
|
||||||
@mouseenter="popupUserStore.user.value = comment.user"
|
|
||||||
></a>
|
|
||||||
</template>
|
|
||||||
<!-- 游客的 -->
|
|
||||||
<template v-else>
|
|
||||||
<img class="avatar ir-pixelated" :src="defAvatarURL" alt="头像" />
|
|
||||||
<div class="name gray selectable clip-text" v-text="comment.nick"></div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<!-- 主评论内容 -->
|
|
||||||
<markdown-view class="value" :content="comment.content" />
|
|
||||||
<div class="reply-to">
|
|
||||||
<a class="button" href="javascript:" @click="replyTo(comment)">回复</a>
|
|
||||||
<span
|
|
||||||
class="light-gray"
|
|
||||||
v-text="Time.toPassedDate(comment.createdAt)"
|
|
||||||
v-popup:text="Time.toDateTime(comment.createdAt)"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
<!-- 子评论 -->
|
|
||||||
<comment-reply-list :comment="comment" @reply="replyTo" />
|
|
||||||
<t-pagination
|
|
||||||
class="pages"
|
|
||||||
v-if="6 < comment.repliesLength!"
|
|
||||||
:total="comment.repliesLength"
|
|
||||||
:pageSize="6"
|
|
||||||
:showPageSize="false"
|
|
||||||
v-model:current="comment.repliesCurrent"
|
|
||||||
@change="() => doFetchReply(comment)"
|
|
||||||
>
|
|
||||||
<template #total-content>
|
|
||||||
<div class="total" v-text="`共 ${comment.repliesLength} 条回复`"></div>
|
|
||||||
</template>
|
|
||||||
</t-pagination>
|
|
||||||
<!-- 回复表单,此表单跟随(页面上)激活的回复对象 -->
|
|
||||||
<comment-reply-form
|
|
||||||
v-if="canReply && comment.id && activeReply === comment.id"
|
|
||||||
:commentId="comment.id"
|
|
||||||
:replyId="replyId"
|
|
||||||
:replyToNick="replyToNick"
|
|
||||||
@submit="onSubmitReply"
|
|
||||||
@cancel="activeReply = -1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import {
|
|
||||||
Comment,
|
|
||||||
CommentAPI,
|
|
||||||
CommentReply,
|
|
||||||
CommonAPI,
|
|
||||||
ImageType,
|
|
||||||
MarkdownView,
|
|
||||||
SettingKey,
|
|
||||||
SettingMapper,
|
|
||||||
Time,
|
|
||||||
UserAPI
|
|
||||||
} from "timi-web";
|
|
||||||
import { CommentReplyForm, CommentReplyList } from "~/components";
|
|
||||||
import { popupUserStore } from "timi-tdesign-pc";
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: "CommentList"
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
items: Comment[],
|
|
||||||
canReply?: boolean,
|
|
||||||
}>(), {
|
|
||||||
canReply: true
|
|
||||||
});
|
|
||||||
const { items, canReply } = toRefs(props);
|
|
||||||
|
|
||||||
// 默认头像
|
|
||||||
const defAvatarURL = ref();
|
|
||||||
onMounted(async () => {
|
|
||||||
const res = JSON.parse(SettingMapper.getValue(SettingKey.PUBLIC_RESOURCES) as string);
|
|
||||||
defAvatarURL.value = CommonAPI.getAttachmentReadAPI(res.user.avatar);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 激活的回复评论下标
|
|
||||||
const activeReply = ref();
|
|
||||||
const replyId = ref();
|
|
||||||
// 被回复昵称
|
|
||||||
const replyToNick = ref();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取回复
|
|
||||||
*
|
|
||||||
* @param comment 所属评论
|
|
||||||
*/
|
|
||||||
async function doFetchReply(comment: Comment) {
|
|
||||||
// 组件下标以 1 开始,分页参数以 0 开始
|
|
||||||
comment.repliesPage!.index = comment.repliesCurrent! - 1;
|
|
||||||
const result = await CommentAPI.pageReply(comment.repliesPage!);
|
|
||||||
comment.replies!.length = 0;
|
|
||||||
comment.replies!.push(...result.list);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 触发回复表单(表单会跟随评论)
|
|
||||||
*
|
|
||||||
* @param comment 跟随的评论
|
|
||||||
* @param replyTo 被回复对象
|
|
||||||
*/
|
|
||||||
async function replyTo(comment: Comment, replyTo?: CommentReply) {
|
|
||||||
activeReply.value = comment.id;
|
|
||||||
if (replyTo) {
|
|
||||||
// 被回复对象
|
|
||||||
replyToNick.value = replyTo.sender?.name || replyTo.senderNick;
|
|
||||||
replyId.value = replyTo.id;
|
|
||||||
} else {
|
|
||||||
replyToNick.value = comment.user?.name || comment.nick;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSubmitReply(reply: CommentReply) {
|
|
||||||
activeReply.value = -1;
|
|
||||||
|
|
||||||
const comment = items.value.find(comment => comment.id === reply.commentId)!;
|
|
||||||
comment.repliesLength!++;
|
|
||||||
comment.repliesCurrent = Math.ceil(comment.repliesLength! / 6); // 上取整最后一页
|
|
||||||
doFetchReply(comment);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.tui-comment-list {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
|
|
||||||
.comment {
|
|
||||||
display: flex;
|
|
||||||
line-height: 1.5;
|
|
||||||
border-bottom: 2px solid #CDDEF0;
|
|
||||||
|
|
||||||
.user {
|
|
||||||
width: 120px;
|
|
||||||
padding: 16px 0;
|
|
||||||
text-align: center;
|
|
||||||
background: #E4EFFA;
|
|
||||||
transition: .5s var(--tui-bezier);
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 64px;
|
|
||||||
border: 1px solid #FBC7D4;
|
|
||||||
margin: 0 auto 10px auto;
|
|
||||||
padding: 3px;
|
|
||||||
background: #FFF;
|
|
||||||
transition: .5s var(--tui-bezier);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
width: calc(100% - 120px);
|
|
||||||
transition: .5s var(--tui-bezier);
|
|
||||||
|
|
||||||
.value {
|
|
||||||
width: calc(100% - 2rem);
|
|
||||||
padding: .5rem;
|
|
||||||
min-height: 92px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pages {
|
|
||||||
padding: 4px 4px 4px 1rem;
|
|
||||||
border-top: 1px solid #E4EFFA;
|
|
||||||
|
|
||||||
.total {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-to {
|
|
||||||
display: flex;
|
|
||||||
padding-right: .5rem;
|
|
||||||
justify-content: end;
|
|
||||||
|
|
||||||
> a {
|
|
||||||
margin-right: .5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 650px) {
|
|
||||||
.tui-comment-list {
|
|
||||||
|
|
||||||
.comment {
|
|
||||||
|
|
||||||
.user {
|
|
||||||
width: 60px;
|
|
||||||
padding: 8px 0;
|
|
||||||
transition: .5s var(--tui-bezier);
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 32px;
|
|
||||||
margin: 0 auto 4px auto;
|
|
||||||
padding: 2px;
|
|
||||||
transition: .5s var(--tui-bezier);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
width: calc(100% - 60px);
|
|
||||||
transition: .5s var(--tui-bezier);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Comment, CommentReply } from "timi-web";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* emits
|
|
||||||
*/
|
|
||||||
export interface Emits {
|
|
||||||
(event: "reply", comment: Comment, replyTo?: CommentReply): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useHandler = (emit: Emits) => {
|
|
||||||
|
|
||||||
const onReply = async (comment: Comment, replyTo?: CommentReply) => await emit("reply", comment, replyTo);
|
|
||||||
|
|
||||||
return {
|
|
||||||
onReply,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import view from "./index.vue";
|
|
||||||
import { Toolkit } from "timi-web";
|
|
||||||
|
|
||||||
export const CommentReplyList = Toolkit.withInstall(view);
|
|
||||||
export default CommentReplyList;
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="tui-comment-reply-list">
|
|
||||||
<div v-if="comment && comment.replies" class="reply" v-for="reply in comment.replies" :key="reply.id">
|
|
||||||
<div class="header">
|
|
||||||
<!-- 发送者头像 -->
|
|
||||||
<div class="sender-avatar">
|
|
||||||
<a
|
|
||||||
v-if="reply.sender"
|
|
||||||
:href="`/user/space/${reply.senderId}`"
|
|
||||||
v-popup:config="popupUserStore.config"
|
|
||||||
@mouseenter="popupUserStore.user.value = reply.sender"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:class="(<any>ImageType)[reply.sender.profile.avatarType]"
|
|
||||||
:src="UserAPI.getAvatarURL(reply.sender.profile)"
|
|
||||||
alt="回复发送者头像"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
<img v-else class="ir-pixelated" :src="defAvatarURL" alt="回复发送者头像" />
|
|
||||||
</div>
|
|
||||||
<!-- 被回复用户 -->
|
|
||||||
<a
|
|
||||||
v-if="reply.sender"
|
|
||||||
class="pink bold selectable"
|
|
||||||
:href="`/user/space/${reply.senderId}`"
|
|
||||||
target="_blank"
|
|
||||||
v-text="reply.sender.name"
|
|
||||||
v-popup:config="popupUserStore.config"
|
|
||||||
@mouseenter="popupUserStore.user.value = reply.sender"
|
|
||||||
></a>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="gray selectable"
|
|
||||||
v-text="reply.senderNick"
|
|
||||||
></span>
|
|
||||||
<!-- 非回复层主时才显示被回复用户 -->
|
|
||||||
<template v-if="!!reply.replyId">
|
|
||||||
<span> 回复 </span>
|
|
||||||
<!-- 被回复用户 -->
|
|
||||||
<a
|
|
||||||
v-if="reply.receiver"
|
|
||||||
class="pink bold selectable"
|
|
||||||
:href="`/user/space/${reply.receiverId}`"
|
|
||||||
target="_blank"
|
|
||||||
v-text="reply.receiver.name"
|
|
||||||
v-popup:config="popupUserStore.config"
|
|
||||||
@mouseenter="popupUserStore.user.value = reply.receiver"
|
|
||||||
></a>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="gray selectable"
|
|
||||||
v-text="reply.receiverNick"
|
|
||||||
></span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<!-- 回复内容 -->
|
|
||||||
<markdown-view class="content" :content="reply.content" />
|
|
||||||
<!-- 其他回复信息 -->
|
|
||||||
<div class="reply-to">
|
|
||||||
<a href="javascript:" @click="onReply(comment, reply)">回复</a>
|
|
||||||
<span
|
|
||||||
class="light-gray"
|
|
||||||
v-text="Time.toPassedDate(reply.createdAt)"
|
|
||||||
v-popup:text="Time.toDateTime(reply.createdAt)"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import {
|
|
||||||
Comment,
|
|
||||||
CommonAPI,
|
|
||||||
ImageType,
|
|
||||||
MarkdownView,
|
|
||||||
PublicResources,
|
|
||||||
SettingKey,
|
|
||||||
SettingMapper,
|
|
||||||
Time,
|
|
||||||
UserAPI
|
|
||||||
} from "timi-web";
|
|
||||||
import { Emits, useHandler } from "~/components/comment/list/reply/emits";
|
|
||||||
import { popupUserStore } from "~/store/popupUser";
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: "CommentReplyList"
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
comment: Comment,
|
|
||||||
}>(), {});
|
|
||||||
const { comment } = toRefs(props);
|
|
||||||
|
|
||||||
const emits = defineEmits<Emits>();
|
|
||||||
const { onReply } = useHandler(emits);
|
|
||||||
|
|
||||||
// 默认头像
|
|
||||||
const defAvatarURL = ref();
|
|
||||||
onMounted(async () => {
|
|
||||||
const res = JSON.parse(SettingMapper.getValue(SettingKey.PUBLIC_RESOURCES) as string) as PublicResources;
|
|
||||||
defAvatarURL.value = CommonAPI.getAttachmentReadAPI(res.user.avatar);
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.tui-comment-reply-list {
|
|
||||||
|
|
||||||
.reply {
|
|
||||||
padding: 6px;
|
|
||||||
|
|
||||||
&:nth-child(n + 2) {
|
|
||||||
border-top: 1px #d7d7d7 dotted;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
border-top: 2px solid #E4EFFA;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.sender-avatar {
|
|
||||||
border: 1px solid #FBC7D4;
|
|
||||||
padding: 1px;
|
|
||||||
margin-right: .5rem;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
transition: .5s var(--tui-bezier);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-to {
|
|
||||||
display: flex;
|
|
||||||
padding-right: 0;
|
|
||||||
justify-content: end;
|
|
||||||
|
|
||||||
> a {
|
|
||||||
margin-right: .5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@media screen and (max-width: 650px) {
|
|
||||||
.tui-comment-reply-list {
|
|
||||||
|
|
||||||
.reply {
|
|
||||||
padding: 3px;
|
|
||||||
transition: .5s var(--tui-bezier);
|
|
||||||
|
|
||||||
.header {
|
|
||||||
|
|
||||||
.sender-avatar {
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
transition: .5s var(--tui-bezier);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="tui-editor-search" @mousedown.stop>
|
||||||
|
<div class="row">
|
||||||
|
<t-button
|
||||||
|
class="toggle-btn"
|
||||||
|
shape="square"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:title="expanded ? '收起替换' : '展开替换'"
|
||||||
|
@click="emit('update:expanded', !expanded)"
|
||||||
|
>
|
||||||
|
<t-icon :name="expanded ? 'chevron-down-s' : 'chevron-right-s'" />
|
||||||
|
</t-button>
|
||||||
|
<t-input
|
||||||
|
ref="keywordInputRef"
|
||||||
|
:value="keyword"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="查找"
|
||||||
|
size="small"
|
||||||
|
@change="onKeywordChange"
|
||||||
|
@enter="onKeywordEnter"
|
||||||
|
@keydown="onKeywordKeydown"
|
||||||
|
>
|
||||||
|
<template #suffix-icon>
|
||||||
|
<t-button
|
||||||
|
shape="square"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
:theme="caseSensitive ? 'primary' : 'default'"
|
||||||
|
title="大小写匹配"
|
||||||
|
@click="emit('update:case-sensitive', !caseSensitive)"
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</t-button>
|
||||||
|
<t-button
|
||||||
|
shape="square"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
:theme="useRegex ? 'primary' : 'default'"
|
||||||
|
title="正则搜索"
|
||||||
|
@click="emit('update:use-regex', !useRegex)"
|
||||||
|
>
|
||||||
|
.*
|
||||||
|
</t-button>
|
||||||
|
</template>
|
||||||
|
</t-input>
|
||||||
|
<span class="status" v-text="resolvedStatusText" />
|
||||||
|
<t-button shape="square" size="small" type="button" variant="text" title="上一个" @click="emit('previous')">
|
||||||
|
<t-icon name="arrow-up" />
|
||||||
|
</t-button>
|
||||||
|
<t-button shape="square" size="small" type="button" variant="text" title="下一个" @click="emit('next', false)">
|
||||||
|
<t-icon name="arrow-down" />
|
||||||
|
</t-button>
|
||||||
|
<t-button shape="square" size="small" type="button" variant="text" title="关闭" @click="emit('close')">
|
||||||
|
<t-icon name="close" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="expanded" class="row replace-row">
|
||||||
|
<div class="toggle-holder" />
|
||||||
|
<t-input
|
||||||
|
ref="replaceInputRef"
|
||||||
|
:value="replaceValue"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="替换"
|
||||||
|
size="small"
|
||||||
|
@change="onReplaceChange"
|
||||||
|
@enter="emit('replace')"
|
||||||
|
@keydown="onReplaceKeydown"
|
||||||
|
/>
|
||||||
|
<div class="replace-actions">
|
||||||
|
<t-button size="small" type="button" theme="primary" @click="emit('replace')">替换</t-button>
|
||||||
|
<t-button size="small" type="button" variant="outline" @click="emit('replace-all')">全部替换</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ComponentPublicInstance } from "vue";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "EditorSearchBar"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
visible?: boolean;
|
||||||
|
expanded?: boolean;
|
||||||
|
keyword?: string;
|
||||||
|
replaceValue?: string;
|
||||||
|
statusText?: string;
|
||||||
|
statusError?: string;
|
||||||
|
matchIndex?: number;
|
||||||
|
matchCount?: number;
|
||||||
|
caseSensitive?: boolean;
|
||||||
|
useRegex?: boolean;
|
||||||
|
}>(), {
|
||||||
|
visible: false,
|
||||||
|
expanded: false,
|
||||||
|
keyword: "",
|
||||||
|
replaceValue: "",
|
||||||
|
statusText: undefined,
|
||||||
|
statusError: "",
|
||||||
|
matchIndex: -1,
|
||||||
|
matchCount: 0,
|
||||||
|
caseSensitive: false,
|
||||||
|
useRegex: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "update:expanded", value: boolean): void;
|
||||||
|
(event: "update:keyword", value: string): void;
|
||||||
|
(event: "update:replace-value", value: string): void;
|
||||||
|
(event: "update:case-sensitive", value: boolean): void;
|
||||||
|
(event: "update:use-regex", value: boolean): void;
|
||||||
|
(event: "previous"): void;
|
||||||
|
(event: "next", shiftKey: boolean): void;
|
||||||
|
(event: "replace"): void;
|
||||||
|
(event: "replace-all"): void;
|
||||||
|
(event: "close"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
type InputInstance = ComponentPublicInstance & {
|
||||||
|
$el: HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InputKeyboardContext = {
|
||||||
|
e: KeyboardEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const keywordInputRef = ref<InputInstance>();
|
||||||
|
const replaceInputRef = ref<InputInstance>();
|
||||||
|
|
||||||
|
const resolvedStatusText = computed(() => {
|
||||||
|
if (undefined !== props.statusText) {
|
||||||
|
return props.statusText;
|
||||||
|
}
|
||||||
|
if (props.statusError) {
|
||||||
|
return props.statusError;
|
||||||
|
}
|
||||||
|
if (!props.keyword) {
|
||||||
|
return "无结果";
|
||||||
|
}
|
||||||
|
if (1 > props.matchCount) {
|
||||||
|
return "未找到";
|
||||||
|
}
|
||||||
|
return `${props.matchIndex + 1} / ${props.matchCount}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onKeywordChange = (value: string | number) => {
|
||||||
|
emit("update:keyword", `${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReplaceChange = (value: string | number) => {
|
||||||
|
emit("update:replace-value", `${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeywordEnter = (_value: string | number, context: InputKeyboardContext) => {
|
||||||
|
emit("next", context.e.shiftKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeywordKeydown = (_value: string | number, context: InputKeyboardContext) => {
|
||||||
|
if ("Escape" !== context.e.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.e.preventDefault();
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReplaceKeydown = (_value: string | number, context: InputKeyboardContext) => {
|
||||||
|
if ("Escape" !== context.e.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.e.preventDefault();
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNativeInput = (instance?: InputInstance) => instance?.$el.querySelector("input") || undefined;
|
||||||
|
|
||||||
|
const focusKeyword = (selectAll = false) => {
|
||||||
|
const input = getNativeInput(keywordInputRef.value);
|
||||||
|
input?.focus();
|
||||||
|
if (selectAll && input) {
|
||||||
|
input.selectionStart = 0;
|
||||||
|
input.selectionEnd = input.value.length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusReplace = (selectAll = false) => {
|
||||||
|
const input = getNativeInput(replaceInputRef.value);
|
||||||
|
input?.focus();
|
||||||
|
if (selectAll && input) {
|
||||||
|
input.selectionStart = 0;
|
||||||
|
input.selectionEnd = input.value.length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focusKeyword,
|
||||||
|
focusReplace,
|
||||||
|
keywordInputRef,
|
||||||
|
replaceInputRef
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tui-editor-search {
|
||||||
|
top: .5rem;
|
||||||
|
right: .5rem;
|
||||||
|
gap: .375rem;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
padding: .5rem;
|
||||||
|
position: absolute;
|
||||||
|
min-width: 20rem;
|
||||||
|
max-width: calc(100% - 1rem);
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: var(--td-radius-default, .375rem);
|
||||||
|
background: var(--td-bg-color-container, #fff);
|
||||||
|
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .12);
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.row {
|
||||||
|
gap: .375rem;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.toggle-holder {
|
||||||
|
flex: none;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
width: 12rem;
|
||||||
|
|
||||||
|
:deep(.t-input) {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
width: 3rem;
|
||||||
|
color: var(--td-text-color-secondary, #666);
|
||||||
|
font-size: .875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.replace-actions {
|
||||||
|
gap: .375rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.tui-editor-search {
|
||||||
|
background: var(--td-bg-color-container, #1f1f1f);
|
||||||
|
border-color: var(--td-component-stroke, #434343);
|
||||||
|
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+39
-17
@@ -1,34 +1,56 @@
|
|||||||
/** 导出所有组件 */
|
/** 导出所有组件 */
|
||||||
import AuthorizeForm from "~/components/authorize-form";
|
import AuthorizeForm from "~/components/authorize-form";
|
||||||
import RootLayout from "~/components/root-layout";
|
import RootLayout from "~/components/root-layout";
|
||||||
import Comment from "~/components/comment";
|
|
||||||
import CommentForm from "~/components/comment/form";
|
|
||||||
import CommentList from "~/components/comment/list";
|
|
||||||
import CommentReplyList from "~/components/comment/list/reply";
|
|
||||||
import CommentReplyForm from "~/components/comment/form/reply";
|
|
||||||
import UserProfile from "~/components/user-profile";
|
import UserProfile from "~/components/user-profile";
|
||||||
import UserProfilePopup from "~/components/user-profile/popup";
|
import UserProfilePopup from "~/components/user-profile/popup";
|
||||||
|
import CodeEditor from "~/components/code-editor";
|
||||||
|
import TiptapEditor from "~/components/tiptap-editor";
|
||||||
|
import TiptapView from "~/components/tiptap-view";
|
||||||
|
import ArticleEditor from "~/components/article-editor";
|
||||||
|
import ArticleView from "~/components/article-view";
|
||||||
|
import NotifyEditor from "~/components/notify-editor";
|
||||||
|
import NotifyView from "~/components/notify-view";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
RootLayout,
|
RootLayout,
|
||||||
AuthorizeForm,
|
AuthorizeForm,
|
||||||
Comment,
|
|
||||||
CommentForm,
|
|
||||||
CommentReplyForm,
|
|
||||||
CommentList,
|
|
||||||
CommentReplyList,
|
|
||||||
UserProfile,
|
UserProfile,
|
||||||
UserProfilePopup
|
UserProfilePopup,
|
||||||
|
CodeEditor,
|
||||||
|
TiptapEditor,
|
||||||
|
TiptapView,
|
||||||
|
ArticleEditor,
|
||||||
|
ArticleView,
|
||||||
|
NotifyEditor,
|
||||||
|
NotifyView
|
||||||
];
|
];
|
||||||
|
|
||||||
export {
|
export {
|
||||||
RootLayout,
|
RootLayout,
|
||||||
AuthorizeForm,
|
AuthorizeForm,
|
||||||
Comment,
|
|
||||||
CommentForm,
|
|
||||||
CommentReplyForm,
|
|
||||||
CommentList,
|
|
||||||
CommentReplyList,
|
|
||||||
UserProfile,
|
UserProfile,
|
||||||
UserProfilePopup
|
UserProfilePopup,
|
||||||
|
CodeEditor,
|
||||||
|
TiptapEditor,
|
||||||
|
TiptapView,
|
||||||
|
ArticleEditor,
|
||||||
|
ArticleView,
|
||||||
|
NotifyEditor,
|
||||||
|
NotifyView
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CodeEditorExpose
|
||||||
|
} from "./code-editor";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
TiptapEditorExpose
|
||||||
|
} from "./tiptap-editor";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ArticleEditorExpose
|
||||||
|
} from "./article-editor";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
NotifyEditorExpose
|
||||||
|
} from "./notify-editor";
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { MessagePlugin } from "tdesign-vue-next";
|
||||||
|
|
||||||
|
type MessageType = "info" | "success" | "warning" | "error";
|
||||||
|
|
||||||
|
export async function showPlatformMessage(type: MessageType, content: string) {
|
||||||
|
await MessagePlugin[type](content);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import { Toolkit } from "timi-web";
|
||||||
|
import type { NotifyDetail } from "timi-web/editor";
|
||||||
|
|
||||||
|
export type NotifyEditorExpose = {
|
||||||
|
submit: () => Promise<NotifyDetail>;
|
||||||
|
getPendingMediaCount: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotifyEditor = Toolkit.withInstall(view);
|
||||||
|
export default NotifyEditor;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<article-editor
|
||||||
|
ref="articleEditorRef"
|
||||||
|
:model-value="articleValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:upload-image="uploadImage"
|
||||||
|
:upload-video="uploadVideo"
|
||||||
|
:resolve-attachment-url="resolveAttachmentUrl"
|
||||||
|
:resolve-temp-url="resolveTempUrl"
|
||||||
|
@change="onArticleChange"
|
||||||
|
@update:model-value="onArticleCommit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Article, ArticleContentType, Attachment, NotifyDetail, UploadResult } from "timi-web/editor";
|
||||||
|
import type { ArticleEditorExpose } from "../article-editor";
|
||||||
|
import { ArticleEditor } from "../article-editor";
|
||||||
|
import { normalizeNotifyContentType } from "timi-web/editor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "NotifyEditor"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue?: NotifyDetail;
|
||||||
|
placeholder?: string;
|
||||||
|
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||||
|
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||||
|
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
|
||||||
|
resolveTempUrl?: (tempFileId: string) => string | Promise<string>;
|
||||||
|
}>(), {
|
||||||
|
modelValue: () => ({}),
|
||||||
|
placeholder: "请输入通知内容"
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
change: [value: NotifyDetail];
|
||||||
|
"update:modelValue": [value: NotifyDetail];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const articleEditorRef = ref<ArticleEditorExpose>();
|
||||||
|
|
||||||
|
const articleValue = computed<Article>(() => ({
|
||||||
|
contentType: normalizeNotifyContentType(props.modelValue?.contentType) as ArticleContentType,
|
||||||
|
content: props.modelValue?.content || "",
|
||||||
|
attachmentList: props.modelValue?.attachmentList
|
||||||
|
}));
|
||||||
|
|
||||||
|
function buildNotifyValue(value: Article): NotifyDetail {
|
||||||
|
return {
|
||||||
|
...(props.modelValue || {}),
|
||||||
|
contentType: normalizeNotifyContentType(value.contentType),
|
||||||
|
content: value.content || "",
|
||||||
|
attachmentList: value.attachmentList || []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onArticleChange(value: Article) {
|
||||||
|
emit("change", buildNotifyValue(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onArticleCommit(value: Article) {
|
||||||
|
emit("update:modelValue", buildNotifyValue(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const value = await articleEditorRef.value?.submit();
|
||||||
|
return buildNotifyValue(value || articleValue.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
submit,
|
||||||
|
getPendingMediaCount: () => articleEditorRef.value?.getPendingMediaCount() || 0
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import { Toolkit } from "timi-web";
|
||||||
|
|
||||||
|
export const NotifyView = Toolkit.withInstall(view);
|
||||||
|
export default NotifyView;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<article-view
|
||||||
|
:article="articleValue"
|
||||||
|
:empty-text="emptyText"
|
||||||
|
:resolve-attachment-url="resolveAttachmentUrl"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Article, ArticleContentType, Attachment, NotifyDetail } from "timi-web/editor";
|
||||||
|
import { ArticleView } from "../article-view";
|
||||||
|
import { normalizeNotifyContentType } from "timi-web/editor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "NotifyView"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
detail?: NotifyDetail;
|
||||||
|
emptyText?: string;
|
||||||
|
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
|
||||||
|
}>(), {
|
||||||
|
detail: () => ({}),
|
||||||
|
emptyText: "暂无通知内容"
|
||||||
|
});
|
||||||
|
|
||||||
|
const articleValue = computed<Article>(() => ({
|
||||||
|
contentType: normalizeNotifyContentType(props.detail?.contentType) as ArticleContentType,
|
||||||
|
content: props.detail?.content || "",
|
||||||
|
attachmentList: props.detail?.attachmentList
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
.tp-content-theme() {
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p:empty:before {
|
||||||
|
content: "\00a0";
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
margin: 0 0 .75rem;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
margin: .75rem 0;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
video,
|
||||||
|
iframe {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-content-body {
|
||||||
|
.tp-content-theme();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<t-popconfirm
|
||||||
|
v-model:visible="visible"
|
||||||
|
trigger="click"
|
||||||
|
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||||
|
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||||
|
placement="bottom"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
>
|
||||||
|
<template #icon><span></span></template>
|
||||||
|
<template #content>
|
||||||
|
<t-input v-model="codeLanguage" placeholder="代码语言,可留空" />
|
||||||
|
</template>
|
||||||
|
<t-button v-popup="'代码块'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||||
|
<t-icon name="code" />
|
||||||
|
</t-button>
|
||||||
|
</t-popconfirm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapCodeBlockButton"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
active: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const codeLanguage = ref("");
|
||||||
|
|
||||||
|
function onVisibleChange(nextVisible: boolean) {
|
||||||
|
if (!nextVisible || !props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
codeLanguage.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const language = codeLanguage.value.trim();
|
||||||
|
if (!language) {
|
||||||
|
props.editor.chain().focus().setCodeBlock().run();
|
||||||
|
visible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.editor.chain().focus().setCodeBlock({ language }).run();
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<t-popconfirm
|
||||||
|
class="tiptap-iframe-button"
|
||||||
|
v-model:visible="visible"
|
||||||
|
trigger="click"
|
||||||
|
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||||
|
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||||
|
placement="bottom"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
>
|
||||||
|
<template #icon><span></span></template>
|
||||||
|
<template #content>
|
||||||
|
<t-form class="popconfirm-content" label-align="left" label-width="auto">
|
||||||
|
<t-form-item label="网页地址">
|
||||||
|
<t-input v-model="iframeForm.src" placeholder="请输入网页地址" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="网页标题">
|
||||||
|
<t-input v-model="iframeForm.title" placeholder="请输入网页标题,可留空" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="宽度">
|
||||||
|
<t-input v-model="iframeForm.width" placeholder="请输入宽度,默认 100%" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="高度">
|
||||||
|
<t-input v-model="iframeForm.height" placeholder="请输入高度,默认 28rem" />
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
</template>
|
||||||
|
<t-button v-popup="'插入内嵌网页'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||||
|
<t-icon name="web" />
|
||||||
|
</t-button>
|
||||||
|
</t-popconfirm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { showPlatformMessage } from "../message";
|
||||||
|
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapIframeButton"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
active: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const iframeForm = reactive({
|
||||||
|
src: "",
|
||||||
|
title: "",
|
||||||
|
width: "100%",
|
||||||
|
height: "28rem"
|
||||||
|
});
|
||||||
|
|
||||||
|
function onVisibleChange(nextVisible: boolean) {
|
||||||
|
if (!nextVisible || !props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attributes = props.editor.getAttributes("iframe");
|
||||||
|
iframeForm.src = (attributes.src as string) || "https://";
|
||||||
|
iframeForm.title = (attributes.title as string) || "";
|
||||||
|
iframeForm.width = (attributes.width as string) || "100%";
|
||||||
|
iframeForm.height = (attributes.height as string) || "28rem";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidHttpUrl(value: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirm() {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const src = iframeForm.src.trim();
|
||||||
|
if (!src) {
|
||||||
|
visible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidHttpUrl(src)) {
|
||||||
|
await showPlatformMessage("warning", "请输入有效的 http 或 https 地址");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({
|
||||||
|
type: "iframe",
|
||||||
|
attrs: {
|
||||||
|
src,
|
||||||
|
title: iframeForm.title.trim() || "内嵌网页",
|
||||||
|
width: iframeForm.width.trim() || "100%",
|
||||||
|
height: iframeForm.height.trim() || "28rem"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.popconfirm-content {
|
||||||
|
--td-comp-margin-xxl: .5rem;
|
||||||
|
|
||||||
|
padding: 0 .5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<t-popconfirm
|
||||||
|
v-model:visible="visible"
|
||||||
|
trigger="click"
|
||||||
|
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||||
|
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||||
|
placement="bottom"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
>
|
||||||
|
<template #icon><span></span></template>
|
||||||
|
<template #content>
|
||||||
|
<div class="link-form">
|
||||||
|
<t-input v-model="linkForm.href" placeholder="请输入链接地址" />
|
||||||
|
<t-input v-model="linkForm.text" placeholder="请输入显示文本,可留空" />
|
||||||
|
<t-checkbox v-model="linkForm.syncToHref">显示文本同步为链接</t-checkbox>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<t-button v-popup="'插入链接'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||||
|
<t-icon name="link-1" />
|
||||||
|
</t-button>
|
||||||
|
</t-popconfirm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapLinkButton"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
active: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const linkForm = reactive({
|
||||||
|
href: "",
|
||||||
|
text: "",
|
||||||
|
syncToHref: false
|
||||||
|
});
|
||||||
|
|
||||||
|
function onVisibleChange(nextVisible: boolean) {
|
||||||
|
if (!nextVisible || !props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previousUrl = (props.editor.getAttributes("link").href as string) || "";
|
||||||
|
const selectedText = props.editor.state.doc.textBetween(
|
||||||
|
props.editor.state.selection.from,
|
||||||
|
props.editor.state.selection.to,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
linkForm.href = previousUrl || "https://";
|
||||||
|
linkForm.text = selectedText || "";
|
||||||
|
linkForm.syncToHref = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const href = linkForm.href.trim();
|
||||||
|
if (!href) {
|
||||||
|
props.editor.chain().focus().unsetLink().run();
|
||||||
|
visible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = linkForm.syncToHref ? href : linkForm.text.trim();
|
||||||
|
if (text) {
|
||||||
|
props.editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({
|
||||||
|
type: "text",
|
||||||
|
text,
|
||||||
|
marks: [{ type: "link", attrs: { href } }]
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
visible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.link-form {
|
||||||
|
gap: .75rem;
|
||||||
|
width: 20rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<t-popconfirm
|
||||||
|
class="tiptap-media-button"
|
||||||
|
v-model:visible="visible"
|
||||||
|
trigger="click"
|
||||||
|
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||||
|
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||||
|
placement="bottom"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
>
|
||||||
|
<template #icon><span></span></template>
|
||||||
|
<template #content>
|
||||||
|
<t-form class="popconfirm-content" label-align="left" label-width="auto">
|
||||||
|
<t-form-item label="文件">
|
||||||
|
<t-button size="small" variant="outline" @click="pickFile">
|
||||||
|
<span
|
||||||
|
class="file-name keep-text"
|
||||||
|
v-text="selectedFile ? '重新选择' : `选择${typeLabel}`"
|
||||||
|
/>
|
||||||
|
</t-button>
|
||||||
|
<span class="word-space gray" v-text="selectedFile?.name || `未选择${typeLabel}文件`" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="标题">
|
||||||
|
<t-input v-model="mediaForm.title" :placeholder="`请输入${typeLabel}标题,可留空`" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="宽度">
|
||||||
|
<t-input v-model="mediaForm.width" placeholder="请输入宽度,默认 100%" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="高度">
|
||||||
|
<t-input v-model="mediaForm.height" placeholder="请输入高度,默认 auto" />
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
</template>
|
||||||
|
<t-button variant="outline" :theme="active ? 'primary' : 'default'" :loading="uploading">
|
||||||
|
<t-icon :name="iconName" />
|
||||||
|
</t-button>
|
||||||
|
</t-popconfirm>
|
||||||
|
<input ref="fileInputRef" class="file-input" type="file" :accept="accept" @change="onFileChange" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { showPlatformMessage } from "../message";
|
||||||
|
import type { TiptapEditorInstance, UploadResult } from "timi-web/editor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapMediaButton"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
active: boolean;
|
||||||
|
type: "image" | "video";
|
||||||
|
upload?: (file: File) => Promise<UploadResult>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const selectedFile = ref<File>();
|
||||||
|
const fileInputRef = ref<HTMLInputElement>();
|
||||||
|
const mediaForm = reactive({
|
||||||
|
title: "",
|
||||||
|
width: "100%",
|
||||||
|
height: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeLabel = computed(() => {
|
||||||
|
return props.type === "image" ? "图片" : "视频";
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconName = computed(() => {
|
||||||
|
return props.type === "image" ? "image-add" : "video";
|
||||||
|
});
|
||||||
|
|
||||||
|
const accept = computed(() => {
|
||||||
|
return props.type === "image" ? "image/*" : "video/*";
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildDefaultHeight() {
|
||||||
|
return "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFile(event?: MouseEvent) {
|
||||||
|
event?.stopPropagation();
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibleChange(nextVisible: boolean) {
|
||||||
|
if (!nextVisible || !props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedFile.value = undefined;
|
||||||
|
const attributes = props.editor.getAttributes(props.type);
|
||||||
|
mediaForm.title = (attributes.title as string) || "";
|
||||||
|
mediaForm.width = (attributes.width as string) || "100%";
|
||||||
|
mediaForm.height = (attributes.height as string) || buildDefaultHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
selectedFile.value = target.files?.[0];
|
||||||
|
target.value = "";
|
||||||
|
if (!selectedFile.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mediaForm.title.trim()) {
|
||||||
|
mediaForm.title = selectedFile.value.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirm() {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!props.upload) {
|
||||||
|
await showPlatformMessage("warning", `${typeLabel.value}上传功能未启用`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedFile.value) {
|
||||||
|
await showPlatformMessage("warning", `请选择${typeLabel.value}文件`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
const file = selectedFile.value;
|
||||||
|
const result = await props.upload(file);
|
||||||
|
const title = mediaForm.title.trim() || result.name || file.name;
|
||||||
|
const attrs = {
|
||||||
|
src: result.url,
|
||||||
|
title,
|
||||||
|
width: mediaForm.width.trim() || "100%",
|
||||||
|
height: mediaForm.height.trim() || buildDefaultHeight(),
|
||||||
|
attachmentId: result.id,
|
||||||
|
tempFileId: result.id
|
||||||
|
};
|
||||||
|
if (props.type === "image") {
|
||||||
|
props.editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setImage({
|
||||||
|
src: attrs.src,
|
||||||
|
alt: title,
|
||||||
|
title
|
||||||
|
})
|
||||||
|
.updateAttributes("image", {
|
||||||
|
attachmentId: attrs.attachmentId,
|
||||||
|
tempFileId: attrs.tempFileId,
|
||||||
|
width: attrs.width,
|
||||||
|
height: attrs.height
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
props.editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({
|
||||||
|
type: "video",
|
||||||
|
attrs
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
visible.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`上传${typeLabel.value}失败:`, error);
|
||||||
|
await showPlatformMessage("error", `上传${typeLabel.value}失败`);
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.popconfirm-content {
|
||||||
|
--td-comp-margin-xxl: .5rem;
|
||||||
|
|
||||||
|
padding: 0 .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<t-popconfirm
|
||||||
|
v-model:visible="visible"
|
||||||
|
trigger="click"
|
||||||
|
placement="bottom-right"
|
||||||
|
:show-arrow="false"
|
||||||
|
:confirm-btn="null"
|
||||||
|
:cancel-btn="null"
|
||||||
|
:popup-props="{ overlayClassName: 'tp-padding-pop' }"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
>
|
||||||
|
<template #icon><span></span></template>
|
||||||
|
<template #content>
|
||||||
|
<div class="padding-form">
|
||||||
|
<div class="item">
|
||||||
|
<div class="head">
|
||||||
|
<span class="label">上下内边距</span>
|
||||||
|
<span class="value" v-text="formatPaddingValue(paddingTB)" />
|
||||||
|
</div>
|
||||||
|
<t-slider :min="0" :max="6" :step=".25" :value="paddingTB" @change="onPaddingTBChange" />
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<div class="head">
|
||||||
|
<span class="label">左右内边距</span>
|
||||||
|
<span class="value" v-text="formatPaddingValue(paddingLR)" />
|
||||||
|
</div>
|
||||||
|
<t-slider :min="0" :max="16" :step=".25" :value="paddingLR" @change="onPaddingLRChange" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<t-button v-popup="'内边距'" variant="outline">
|
||||||
|
<t-icon name="fullscreen-exit-1" />
|
||||||
|
</t-button>
|
||||||
|
</t-popconfirm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { formatPaddingValue, normalizeSliderValue } from "timi-web/editor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapPaddingButton"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
paddingTB: number;
|
||||||
|
paddingLR: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:paddingTB": [value: number];
|
||||||
|
"update:paddingLR": [value: number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
|
||||||
|
function onVisibleChange(nextVisible: boolean) {
|
||||||
|
if (nextVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaddingTBChange(value: number | number[]) {
|
||||||
|
emit("update:paddingTB", normalizeSliderValue(value, props.paddingTB));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaddingLRChange(value: number | number[]) {
|
||||||
|
emit("update:paddingLR", normalizeSliderValue(value, props.paddingLR));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.padding-form {
|
||||||
|
gap: .75rem;
|
||||||
|
width: 18rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
font-size: .75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<t-popconfirm
|
||||||
|
v-model:visible="visible"
|
||||||
|
trigger="click"
|
||||||
|
placement="bottom"
|
||||||
|
:show-arrow="false"
|
||||||
|
:popup-props="{ overlayClassName: 'tp-table-pop' }"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
>
|
||||||
|
<template #icon><span></span></template>
|
||||||
|
<template #content>
|
||||||
|
<div class="table-picker" @mouseleave="onReset">
|
||||||
|
<div class="tip" v-text="`${hoverRow} × ${hoverCol}`" />
|
||||||
|
<div class="grid">
|
||||||
|
<button
|
||||||
|
v-for="item in tablePickerItems"
|
||||||
|
:key="item.key"
|
||||||
|
class="cell"
|
||||||
|
:class="{ active: item.active }"
|
||||||
|
type="button"
|
||||||
|
@mousemove="onHover(item.row, item.col)"
|
||||||
|
@click="onSelect(item.row, item.col)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<t-button v-popup="'插入表格'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||||
|
<t-icon name="table" />
|
||||||
|
</t-button>
|
||||||
|
</t-popconfirm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { TABLE_PICKER_SIZE } from "timi-web/editor";
|
||||||
|
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapTableButton"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
active: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const hoverRow = ref(1);
|
||||||
|
const hoverCol = ref(1);
|
||||||
|
|
||||||
|
const tablePickerItems = computed(() => {
|
||||||
|
const items: { key: string; row: number; col: number; active: boolean }[] = [];
|
||||||
|
for (let row = 1; row < TABLE_PICKER_SIZE + 1; row += 1) {
|
||||||
|
for (let col = 1; col < TABLE_PICKER_SIZE + 1; col += 1) {
|
||||||
|
items.push({
|
||||||
|
key: `${row}-${col}`,
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
active: row < hoverRow.value + 1 && col < hoverCol.value + 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onVisibleChange(nextVisible: boolean) {
|
||||||
|
if (!nextVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hoverRow.value = 1;
|
||||||
|
hoverCol.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReset() {
|
||||||
|
hoverRow.value = 1;
|
||||||
|
hoverCol.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHover(row: number, col: number) {
|
||||||
|
hoverRow.value = row;
|
||||||
|
hoverCol.value = col;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect(row: number, col: number) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hoverRow.value = row;
|
||||||
|
hoverCol.value = col;
|
||||||
|
props.editor.chain().focus().insertTable({ rows: row, cols: col, withHeaderRow: true }).run();
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.table-picker {
|
||||||
|
width: 10.5rem;
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
height: 1.5rem;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
font-size: .75rem;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
gap: .125rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
transition: background .15s ease, border-color .15s ease;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--td-brand-color);
|
||||||
|
background: var(--td-brand-color-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
<template>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="left" :class="{ 'toolbar-disabled': sourceCodeMode }">
|
||||||
|
<div class="fixed">
|
||||||
|
<t-button v-popup="'撤销'" variant="outline" :disabled="!canExec('undo')" @click="run('undo')">
|
||||||
|
<t-icon name="rollback" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'重做'" variant="outline" :disabled="!canExec('redo')" @click="run('redo')">
|
||||||
|
<t-icon name="rollfront" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'清除格式'" variant="outline" @click="run('clear')">
|
||||||
|
<t-icon name="clear-formatting" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
<div class="scroll">
|
||||||
|
<t-dropdown trigger="click" :options="headingOptions" :min-column-width="120" @click="onHeadingSelect">
|
||||||
|
<t-button v-popup="'标题'" variant="outline" :theme="isHeadingActive ? 'primary' : 'default'">
|
||||||
|
<t-icon name="text" />
|
||||||
|
</t-button>
|
||||||
|
</t-dropdown>
|
||||||
|
<t-button v-popup="'加粗'" variant="outline" :theme="isActive('bold') ? 'primary' : 'default'" @click="run('bold')">
|
||||||
|
<t-icon name="textformat-bold" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'斜体'" variant="outline" :theme="isActive('italic') ? 'primary' : 'default'" @click="run('italic')">
|
||||||
|
<t-icon name="textformat-italic" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'下划线'" variant="outline" :theme="isActive('underline') ? 'primary' : 'default'" @click="run('underline')">
|
||||||
|
<t-icon name="textformat-underline" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'删除线'" variant="outline" :theme="isActive('strike') ? 'primary' : 'default'" @click="run('strike')">
|
||||||
|
<t-icon name="textformat-strikethrough" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'前景色'" variant="outline" @click="pickColor('text')">
|
||||||
|
<t-icon name="textformat-color" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'背景色'" variant="outline" @click="pickColor('bg')">
|
||||||
|
<t-icon name="fill-color-1" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'有序列表'" variant="outline" :theme="isActive('orderedList') ? 'primary' : 'default'" @click="run('orderedList')">
|
||||||
|
<t-icon name="order-list" />
|
||||||
|
</t-button>
|
||||||
|
<tiptap-table-button :editor="editor" :active="isActive('table')" />
|
||||||
|
<t-button v-popup="'无序列表'" variant="outline" :theme="isActive('bulletList') ? 'primary' : 'default'" @click="run('bulletList')">
|
||||||
|
<t-icon name="bulletpoint" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'行内代码'" variant="outline" :theme="isActive('code') ? 'primary' : 'default'" @click="run('code')">
|
||||||
|
<t-icon name="highlighted-block" />
|
||||||
|
</t-button>
|
||||||
|
<tiptap-code-block-button :editor="editor" :active="isActive('codeBlock')" />
|
||||||
|
<t-button v-popup="'下标'" variant="outline" :theme="isActive('subscript') ? 'primary' : 'default'" @click="run('subscript')">
|
||||||
|
<t-icon name="subscript" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'上标'" variant="outline" :theme="isActive('superscript') ? 'primary' : 'default'" @click="run('superscript')">
|
||||||
|
<t-icon name="superscript" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'左对齐'" variant="outline" :theme="isAlignActive('left') ? 'primary' : 'default'" @click="setAlign('left')">
|
||||||
|
<t-icon name="format-vertical-align-left" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'居中对齐'" variant="outline" :theme="isAlignActive('center') ? 'primary' : 'default'" @click="setAlign('center')">
|
||||||
|
<t-icon name="format-vertical-align-center" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'右对齐'" variant="outline" :theme="isAlignActive('right') ? 'primary' : 'default'" @click="setAlign('right')">
|
||||||
|
<t-icon name="format-vertical-align-right" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'两端对齐'" variant="outline" :theme="isAlignActive('justify') ? 'primary' : 'default'" @click="setAlign('justify')">
|
||||||
|
<t-icon name="expand-horizontal" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'分割线'" variant="outline" @click="run('horizontalRule')">
|
||||||
|
<t-icon name="divider-1" />
|
||||||
|
</t-button>
|
||||||
|
<tiptap-link-button :editor="editor" :active="isActive('link')" />
|
||||||
|
<tiptap-media-button :editor="editor" :active="isActive('image')" type="image" :upload="uploadImage" />
|
||||||
|
<tiptap-media-button :editor="editor" :active="isActive('video')" type="video" :upload="uploadVideo" />
|
||||||
|
<tiptap-iframe-button :editor="editor" :active="isActive('iframe')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<div v-if="showPaddingTool" class="right__group" :class="{ 'toolbar-disabled': sourceCodeMode }">
|
||||||
|
<tiptap-padding-button
|
||||||
|
:padding-t-b="paddingTB"
|
||||||
|
:padding-l-r="paddingLR"
|
||||||
|
@update:padding-t-b="emit('update:paddingTB', $event)"
|
||||||
|
@update:padding-l-r="emit('update:paddingLR', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<t-button
|
||||||
|
v-popup="sourceCodeMode ? '富文本' : '源码'"
|
||||||
|
class="source-btn"
|
||||||
|
variant="outline"
|
||||||
|
:theme="sourceCodeMode ? 'primary' : 'default'"
|
||||||
|
@click="emit('toggle-source-code')"
|
||||||
|
>
|
||||||
|
<t-icon v-if="sourceCodeMode" name="edit-1" />
|
||||||
|
<t-icon v-else name="braces" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
<input ref="textColorRef" class="hidden" type="color" :value="textColor" @input="onTextColorChange" />
|
||||||
|
<input ref="bgColorRef" class="hidden" type="color" :value="bgColor" @input="onBgColorChange" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { AlignType, UploadResult, TiptapEditorInstance } from "timi-web/editor";
|
||||||
|
import { buildHeadingOptions } from "timi-web/editor";
|
||||||
|
import TiptapCodeBlockButton from "./TiptapCodeBlockButton.vue";
|
||||||
|
import TiptapIframeButton from "./TiptapIframeButton.vue";
|
||||||
|
import TiptapLinkButton from "./TiptapLinkButton.vue";
|
||||||
|
import TiptapMediaButton from "./TiptapMediaButton.vue";
|
||||||
|
import TiptapPaddingButton from "./TiptapPaddingButton.vue";
|
||||||
|
import TiptapTableButton from "./TiptapTableButton.vue";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapToolbar"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||||
|
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||||
|
paddingTB: number;
|
||||||
|
paddingLR: number;
|
||||||
|
showPaddingTool?: boolean;
|
||||||
|
sourceCodeMode?: boolean;
|
||||||
|
}>(), {
|
||||||
|
showPaddingTool: true,
|
||||||
|
sourceCodeMode: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:paddingTB": [value: number];
|
||||||
|
"update:paddingLR": [value: number];
|
||||||
|
"toggle-source-code": [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
type RunType =
|
||||||
|
| "undo"
|
||||||
|
| "redo"
|
||||||
|
| "bold"
|
||||||
|
| "italic"
|
||||||
|
| "underline"
|
||||||
|
| "strike"
|
||||||
|
| "orderedList"
|
||||||
|
| "bulletList"
|
||||||
|
| "code"
|
||||||
|
| "subscript"
|
||||||
|
| "superscript"
|
||||||
|
| "horizontalRule"
|
||||||
|
| "clear";
|
||||||
|
|
||||||
|
const textColorRef = ref<HTMLInputElement>();
|
||||||
|
const bgColorRef = ref<HTMLInputElement>();
|
||||||
|
const textColor = ref("#000000");
|
||||||
|
const bgColor = ref("#ffff00");
|
||||||
|
|
||||||
|
const currentHeadingValue = computed(() => {
|
||||||
|
if (!props.editor) {
|
||||||
|
return "p";
|
||||||
|
}
|
||||||
|
for (let level = 1; level < 7; level += 1) {
|
||||||
|
if (props.editor.isActive("heading", { level })) {
|
||||||
|
return `h${level}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "p";
|
||||||
|
});
|
||||||
|
|
||||||
|
const headingOptions = computed(() => {
|
||||||
|
return buildHeadingOptions(currentHeadingValue.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isHeadingActive = computed(() => {
|
||||||
|
return currentHeadingValue.value !== "p";
|
||||||
|
});
|
||||||
|
|
||||||
|
function canExec(type: "undo" | "redo") {
|
||||||
|
if (!props.editor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case "undo":
|
||||||
|
return props.editor.can().undo();
|
||||||
|
case "redo":
|
||||||
|
return props.editor.can().redo();
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(type: string) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return props.editor.isActive(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlignActive(align: AlignType) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return props.editor.isActive({ textAlign: align });
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(type: RunType) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chain = props.editor.chain().focus();
|
||||||
|
switch (type) {
|
||||||
|
case "undo":
|
||||||
|
chain.undo().run();
|
||||||
|
return;
|
||||||
|
case "redo":
|
||||||
|
chain.redo().run();
|
||||||
|
return;
|
||||||
|
case "bold":
|
||||||
|
chain.toggleBold().run();
|
||||||
|
return;
|
||||||
|
case "italic":
|
||||||
|
chain.toggleItalic().run();
|
||||||
|
return;
|
||||||
|
case "underline":
|
||||||
|
chain.toggleUnderline().run();
|
||||||
|
return;
|
||||||
|
case "strike":
|
||||||
|
chain.toggleStrike().run();
|
||||||
|
return;
|
||||||
|
case "orderedList":
|
||||||
|
chain.toggleOrderedList().run();
|
||||||
|
return;
|
||||||
|
case "bulletList":
|
||||||
|
chain.toggleBulletList().run();
|
||||||
|
return;
|
||||||
|
case "code":
|
||||||
|
chain.toggleCode().run();
|
||||||
|
return;
|
||||||
|
case "subscript":
|
||||||
|
chain.toggleSubscript().run();
|
||||||
|
return;
|
||||||
|
case "superscript":
|
||||||
|
chain.toggleSuperscript().run();
|
||||||
|
return;
|
||||||
|
case "horizontalRule":
|
||||||
|
chain.setHorizontalRule().run();
|
||||||
|
return;
|
||||||
|
case "clear":
|
||||||
|
chain.clearNodes().unsetAllMarks().run();
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAlign(align: AlignType) {
|
||||||
|
props.editor?.chain().focus().setTextAlign(align).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHeadingSelect(option: { value?: string | number | Record<string, unknown> }) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = `${option?.value || "p"}`;
|
||||||
|
const chain = props.editor.chain().focus();
|
||||||
|
if (value === "p") {
|
||||||
|
chain.setParagraph().run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const level = Number(value.replace("h", ""));
|
||||||
|
if (0 < level && level < 7) {
|
||||||
|
chain.setHeading({ level: level as 1 | 2 | 3 | 4 | 5 | 6 }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickColor(type: "text" | "bg") {
|
||||||
|
if (type === "text") {
|
||||||
|
textColorRef.value?.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bgColorRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTextColorChange(event: Event) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
textColor.value = target.value;
|
||||||
|
props.editor.chain().focus().setColor(target.value).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBgColorChange(event: Event) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
bgColor.value = target.value;
|
||||||
|
props.editor.chain().focus().setMark("textStyle", { backgroundColor: target.value }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
margin-top: -1px;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
bottom: 0;
|
||||||
|
position: absolute;
|
||||||
|
background: var(--td-component-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left,
|
||||||
|
.right {
|
||||||
|
gap: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
|
||||||
|
:deep(.t-button) {
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
margin-left: -1px;
|
||||||
|
border-radius: 0;
|
||||||
|
--td-comp-paddingLR-l: 8px;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible,
|
||||||
|
&.t-is-active,
|
||||||
|
&.t-button--theme-primary {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.fixed,
|
||||||
|
.scroll {
|
||||||
|
gap: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-button:last-child) {
|
||||||
|
margin-right: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
flex: none;
|
||||||
|
margin-right: -1px;
|
||||||
|
|
||||||
|
.right__group {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-disabled {
|
||||||
|
opacity: .55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-btn {
|
||||||
|
margin-left: -1px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 48rem) {
|
||||||
|
.toolbar {
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
.left {
|
||||||
|
.scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.tp-heading-option) {
|
||||||
|
display: block;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-size(@name, @size, @weight) {
|
||||||
|
:global(.tp-heading-option.@{name}) {
|
||||||
|
font-size: @size;
|
||||||
|
font-weight: @weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-size(p, 1rem, 400);
|
||||||
|
.heading-size(h1, 2rem, 700);
|
||||||
|
.heading-size(h2, 1.5rem, 700);
|
||||||
|
.heading-size(h3, 1.17rem, 700);
|
||||||
|
.heading-size(h4, 1rem, 700);
|
||||||
|
.heading-size(h5, .83rem, 700);
|
||||||
|
.heading-size(h6, .67rem, 700);
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import { Toolkit } from "timi-web";
|
||||||
|
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||||
|
|
||||||
|
export * from "timi-web/editor";
|
||||||
|
export type TiptapEditorExpose = {
|
||||||
|
submit: () => Promise<string>;
|
||||||
|
getPendingMediaCount: () => number;
|
||||||
|
openSearch: (showReplace: boolean) => void;
|
||||||
|
closeSearch: () => void;
|
||||||
|
findNext: () => boolean;
|
||||||
|
findPrevious: () => boolean;
|
||||||
|
replaceCurrent: () => boolean;
|
||||||
|
replaceAll: () => number;
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TiptapEditor = Toolkit.withInstall(view);
|
||||||
|
export default TiptapEditor;
|
||||||
@@ -0,0 +1,624 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tp-editor">
|
||||||
|
<tiptap-toolbar
|
||||||
|
:editor="editor || undefined"
|
||||||
|
:padding-t-b="articlePadding.paddingTB"
|
||||||
|
:padding-l-r="articlePadding.paddingLR"
|
||||||
|
:show-padding-tool="showPaddingTool"
|
||||||
|
:source-code-mode="sourceCodeMode"
|
||||||
|
:upload-image="handleInsertImage"
|
||||||
|
:upload-video="handleInsertVideo"
|
||||||
|
@update:padding-t-b="articlePadding.paddingTB = $event"
|
||||||
|
@update:padding-l-r="articlePadding.paddingLR = $event"
|
||||||
|
@toggle-source-code="toggleSourceCodeMode"
|
||||||
|
/>
|
||||||
|
<div class="content">
|
||||||
|
<code-editor
|
||||||
|
v-if="sourceCodeMode"
|
||||||
|
ref="sourceEditorRef"
|
||||||
|
v-model="sourceCode"
|
||||||
|
class="source-editor"
|
||||||
|
language="html"
|
||||||
|
placeholder="请输入 HTML 源码"
|
||||||
|
/>
|
||||||
|
<div v-else-if="editor" class="value-wrap">
|
||||||
|
<editor-search-bar
|
||||||
|
ref="searchBarRef"
|
||||||
|
:visible="searchVisible"
|
||||||
|
:expanded="replaceExpanded"
|
||||||
|
:keyword="searchKeyword"
|
||||||
|
:replace-value="replaceKeyword"
|
||||||
|
:status-error="searchError"
|
||||||
|
:match-index="searchMatchIndex"
|
||||||
|
:match-count="searchMatchList.length"
|
||||||
|
:case-sensitive="searchOptions.caseSensitive"
|
||||||
|
:use-regex="searchOptions.useRegex"
|
||||||
|
@update:expanded="replaceExpanded = $event"
|
||||||
|
@update:keyword="searchKeyword = $event"
|
||||||
|
@update:replace-value="replaceKeyword = $event"
|
||||||
|
@update:case-sensitive="searchOptions.caseSensitive = $event"
|
||||||
|
@update:use-regex="searchOptions.useRegex = $event"
|
||||||
|
@previous="findPrevious"
|
||||||
|
@next="onSearchNext"
|
||||||
|
@replace="replaceCurrent"
|
||||||
|
@replace-all="replaceAll"
|
||||||
|
@close="closeSearch"
|
||||||
|
/>
|
||||||
|
<editor-content
|
||||||
|
class="value"
|
||||||
|
:style="editorBodyStyle"
|
||||||
|
:editor="editor"
|
||||||
|
:spellcheck="false"
|
||||||
|
@keydown.capture="onRichEditorKeydown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<t-empty v-else description="编辑器初始化中" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import CodeEditor from "../code-editor/index.vue";
|
||||||
|
import EditorSearchBar from "../editor-search/index.vue";
|
||||||
|
import {
|
||||||
|
buildSearchRegex,
|
||||||
|
replaceMatchText,
|
||||||
|
type TextSearchOptions
|
||||||
|
} from "timi-web/editor";
|
||||||
|
import { Toolkit } from "timi-web";
|
||||||
|
import type { PendingMediaItem, UploadMediaType, UploadResult, TiptapEditorInstance } from "timi-web/editor";
|
||||||
|
import TiptapToolbar from "./TiptapToolbar.vue";
|
||||||
|
import { useTiptapEditor } from "timi-web/editor";
|
||||||
|
import {
|
||||||
|
ATTACHMENT_ID_ATTR,
|
||||||
|
LOCAL_MEDIA_ID_PREFIX,
|
||||||
|
TEMP_FILE_ID_ATTR,
|
||||||
|
isLocalMediaId,
|
||||||
|
normalizeContent
|
||||||
|
} from "timi-web/editor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapEditor"
|
||||||
|
});
|
||||||
|
|
||||||
|
type EditorSearchBarExpose = {
|
||||||
|
focusKeyword: (selectAll?: boolean) => void;
|
||||||
|
focusReplace: (selectAll?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CodeEditorExpose = {
|
||||||
|
openSearch: (showReplace: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RichTextMatch = {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
placeholder?: string;
|
||||||
|
paddingTB?: number;
|
||||||
|
paddingLR?: number;
|
||||||
|
showPaddingTool?: boolean;
|
||||||
|
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||||
|
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||||
|
}>(), {
|
||||||
|
modelValue: "",
|
||||||
|
placeholder: "请输入内容",
|
||||||
|
paddingTB: 1,
|
||||||
|
paddingLR: 1.25,
|
||||||
|
showPaddingTool: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: string];
|
||||||
|
"change": [value: string];
|
||||||
|
"update:paddingTB": [value: number];
|
||||||
|
"update:paddingLR": [value: number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { EditorContent, articlePadding, editor, editorBodyStyle } = useTiptapEditor(
|
||||||
|
props,
|
||||||
|
emit,
|
||||||
|
(event, value) => {
|
||||||
|
if (event === "update:paddingTB") {
|
||||||
|
emit("update:paddingTB", value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("update:paddingLR", value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const pendingMediaMap = reactive(new Map<string, PendingMediaItem>());
|
||||||
|
const sourceCodeMode = ref(false);
|
||||||
|
const sourceCode = ref("");
|
||||||
|
const sourceEditorRef = ref<CodeEditorExpose>();
|
||||||
|
const searchBarRef = ref<EditorSearchBarExpose>();
|
||||||
|
const searchVisible = ref(false);
|
||||||
|
const replaceExpanded = ref(false);
|
||||||
|
const searchKeyword = ref("");
|
||||||
|
const replaceKeyword = ref("");
|
||||||
|
const searchError = ref("");
|
||||||
|
const searchMatchIndex = ref(-1);
|
||||||
|
const searchMatchList = ref<RichTextMatch[]>([]);
|
||||||
|
const searchOptions = reactive<TextSearchOptions>({
|
||||||
|
caseSensitive: false,
|
||||||
|
useRegex: false
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function buildLocalMediaId() {
|
||||||
|
return `${LOCAL_MEDIA_ID_PREFIX}${Toolkit.uuid()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeMediaUrl(url?: string) {
|
||||||
|
if (url?.startsWith("blob:")) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePendingMedia(id: string) {
|
||||||
|
const item = pendingMediaMap.get(id);
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
revokeMediaUrl(item.url);
|
||||||
|
pendingMediaMap.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingMedia() {
|
||||||
|
Array.from(pendingMediaMap.keys()).forEach(removePendingMedia);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMediaIds(content?: string) {
|
||||||
|
const rawContent = normalizeContent(content);
|
||||||
|
if (!rawContent) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(rawContent, "text/html");
|
||||||
|
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||||
|
return new Set(
|
||||||
|
mediaList
|
||||||
|
.map(item => item.getAttribute(ATTACHMENT_ID_ATTR) || item.getAttribute(TEMP_FILE_ID_ATTR) || "")
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPendingMedia(content?: string) {
|
||||||
|
const mediaIdSet = collectMediaIds(content);
|
||||||
|
Array.from(pendingMediaMap.keys()).forEach((id) => {
|
||||||
|
if (!mediaIdSet.has(id)) {
|
||||||
|
removePendingMedia(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInsertMedia(file: File, type: UploadMediaType): Promise<UploadResult> {
|
||||||
|
const id = buildLocalMediaId();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
pendingMediaMap.set(id, {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name: file.name,
|
||||||
|
file,
|
||||||
|
url
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
name: file.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInsertImage(file: File) {
|
||||||
|
return handleInsertMedia(file, "image");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInsertVideo(file: File) {
|
||||||
|
return handleInsertMedia(file, "video");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance) {
|
||||||
|
return normalizeContent(props.modelValue);
|
||||||
|
}
|
||||||
|
const rawContent = normalizeContent(instance.getHTML());
|
||||||
|
if (!rawContent) {
|
||||||
|
clearPendingMedia();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(rawContent, "text/html");
|
||||||
|
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||||
|
const uploadedMediaIdList: string[] = [];
|
||||||
|
|
||||||
|
for (const media of mediaList) {
|
||||||
|
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || media.getAttribute(TEMP_FILE_ID_ATTR) || "";
|
||||||
|
if (!isLocalMediaId(attachmentId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pendingMedia = pendingMediaMap.get(attachmentId);
|
||||||
|
if (!pendingMedia) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uploadFn = pendingMedia.type === "image" ? props.uploadImage : props.uploadVideo;
|
||||||
|
if (!uploadFn) {
|
||||||
|
throw new Error(`未配置${pendingMedia.type === "image" ? "图片" : "视频"}上传方法`);
|
||||||
|
}
|
||||||
|
const result = await uploadFn(pendingMedia.file);
|
||||||
|
media.setAttribute("src", result.url);
|
||||||
|
media.setAttribute(ATTACHMENT_ID_ATTR, result.id);
|
||||||
|
media.setAttribute(TEMP_FILE_ID_ATTR, result.id);
|
||||||
|
if (media.tagName.toLowerCase() === "img") {
|
||||||
|
media.setAttribute("alt", result.name || pendingMedia.name);
|
||||||
|
media.setAttribute("title", result.name || pendingMedia.name);
|
||||||
|
} else {
|
||||||
|
media.setAttribute("title", result.name || pendingMedia.name);
|
||||||
|
}
|
||||||
|
uploadedMediaIdList.push(attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextContent = normalizeContent(doc.body.innerHTML);
|
||||||
|
if (nextContent !== rawContent) {
|
||||||
|
instance.commands.setContent(nextContent || "<p></p>");
|
||||||
|
}
|
||||||
|
uploadedMediaIdList.forEach(removePendingMedia);
|
||||||
|
syncPendingMedia(nextContent);
|
||||||
|
return nextContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSourceCode(value?: string) {
|
||||||
|
sourceCode.value = value || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSourceCodeChange(value: string) {
|
||||||
|
sourceCode.value = value;
|
||||||
|
emit("change", value);
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedEditorText() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const { from, to } = instance.state.selection;
|
||||||
|
if (from === to) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return instance.state.doc.textBetween(from, to, "\n", "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSearchMatchState() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance || sourceCodeMode.value) {
|
||||||
|
searchError.value = "";
|
||||||
|
searchMatchList.value = [];
|
||||||
|
searchMatchIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { regex, error } = buildSearchRegex(searchKeyword.value, searchOptions, true);
|
||||||
|
searchError.value = error;
|
||||||
|
if (!regex || error || !searchKeyword.value) {
|
||||||
|
searchMatchList.value = [];
|
||||||
|
searchMatchIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matchList: RichTextMatch[] = [];
|
||||||
|
instance.state.doc.descendants((node, pos) => {
|
||||||
|
if (!node.isText || !node.text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
regex.lastIndex = 0;
|
||||||
|
let match = regex.exec(node.text);
|
||||||
|
while (match) {
|
||||||
|
if (!match[0]) {
|
||||||
|
searchError.value = "正则不能匹配空文本";
|
||||||
|
searchMatchList.value = [];
|
||||||
|
searchMatchIndex.value = -1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
matchList.push({
|
||||||
|
from: pos + match.index,
|
||||||
|
to: pos + match.index + match[0].length,
|
||||||
|
text: match[0]
|
||||||
|
});
|
||||||
|
match = regex.exec(node.text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
searchMatchList.value = matchList;
|
||||||
|
const { from, to } = instance.state.selection;
|
||||||
|
searchMatchIndex.value = matchList.findIndex((match) => match.from === from && match.to === to);
|
||||||
|
if (0 > searchMatchIndex.value && 0 < matchList.length) {
|
||||||
|
searchMatchIndex.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSearch(showReplace: boolean) {
|
||||||
|
if (sourceCodeMode.value) {
|
||||||
|
sourceEditorRef.value?.openSearch(showReplace);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchVisible.value = true;
|
||||||
|
replaceExpanded.value = showReplace;
|
||||||
|
const selectedText = getSelectedEditorText();
|
||||||
|
if (selectedText) {
|
||||||
|
searchKeyword.value = selectedText;
|
||||||
|
}
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
searchBarRef.value?.focusKeyword(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearch() {
|
||||||
|
searchVisible.value = false;
|
||||||
|
replaceExpanded.value = false;
|
||||||
|
editor.value?.commands.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMatch(match: RichTextMatch, matchIndex: number) {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
instance.chain().focus().setTextSelection({ from: match.from, to: match.to }).scrollIntoView().run();
|
||||||
|
searchMatchIndex.value = matchIndex;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNext() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const currentTo = instance.state.selection.to;
|
||||||
|
const nextIndex = searchMatchList.value.findIndex((match) => match.from >= currentTo);
|
||||||
|
const targetIndex = 0 <= nextIndex ? nextIndex : 0;
|
||||||
|
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPrevious() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const currentFrom = instance.state.selection.from;
|
||||||
|
let targetIndex = searchMatchList.value.length - 1;
|
||||||
|
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
|
||||||
|
if (searchMatchList.value[index].from < currentFrom) {
|
||||||
|
targetIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceCurrent() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance || !searchKeyword.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let currentMatchIndex = searchMatchList.value.findIndex((match) => (
|
||||||
|
match.from === instance.state.selection.from && match.to === instance.state.selection.to
|
||||||
|
));
|
||||||
|
if (0 > currentMatchIndex) {
|
||||||
|
if (!findNext()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
currentMatchIndex = searchMatchIndex.value;
|
||||||
|
}
|
||||||
|
const currentMatch = searchMatchList.value[currentMatchIndex];
|
||||||
|
if (!currentMatch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nextText = replaceMatchText(currentMatch.text, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||||
|
const transaction = instance.state.tr.insertText(nextText, currentMatch.from, currentMatch.to);
|
||||||
|
instance.view.dispatch(transaction);
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
const nextMatch = searchMatchList.value[currentMatchIndex] || searchMatchList.value[currentMatchIndex - 1];
|
||||||
|
if (!nextMatch) {
|
||||||
|
instance.commands.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectMatch(nextMatch, Math.min(currentMatchIndex, searchMatchList.value.length - 1));
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAll() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance || !searchKeyword.value) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (searchError.value || 1 > searchMatchList.value.length) {
|
||||||
|
refreshSearchMatchState();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const replaceCount = searchMatchList.value.length;
|
||||||
|
const transaction = instance.state.tr;
|
||||||
|
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
|
||||||
|
const match = searchMatchList.value[index];
|
||||||
|
const nextText = replaceMatchText(match.text, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||||
|
transaction.insertText(nextText, match.from, match.to);
|
||||||
|
}
|
||||||
|
instance.view.dispatch(transaction);
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
if (searchMatchList.value[0]) {
|
||||||
|
selectMatch(searchMatchList.value[0], 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance.commands.focus();
|
||||||
|
});
|
||||||
|
return replaceCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchNext(shiftKey: boolean) {
|
||||||
|
if (shiftKey) {
|
||||||
|
findPrevious();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
findNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRichEditorKeydown(event: KeyboardEvent) {
|
||||||
|
if (sourceCodeMode.value || event.altKey || !(event.ctrlKey || event.metaKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if ("f" === key) {
|
||||||
|
event.preventDefault();
|
||||||
|
openSearch(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("h" === key) {
|
||||||
|
event.preventDefault();
|
||||||
|
openSearch(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSourceCodeMode() {
|
||||||
|
const nextMode = !sourceCodeMode.value;
|
||||||
|
closeSearch();
|
||||||
|
if (nextMode) {
|
||||||
|
syncSourceCode(normalizeContent(editor.value?.getHTML()) || props.modelValue);
|
||||||
|
sourceCodeMode.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceCodeMode.value = false;
|
||||||
|
const nextContent = normalizeContent(sourceCode.value);
|
||||||
|
if (editor.value) {
|
||||||
|
editor.value.commands.setContent(nextContent || "<p></p>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (value) => {
|
||||||
|
syncPendingMedia(value);
|
||||||
|
syncSourceCode(value);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(sourceCode, (value) => {
|
||||||
|
if (!sourceCodeMode.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSourceCodeChange(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([searchKeyword, () => searchOptions.caseSensitive, () => searchOptions.useRegex, sourceCodeMode], () => {
|
||||||
|
refreshSearchMatchState();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => editor.value?.state.selection.from, () => {
|
||||||
|
if (!searchVisible.value || sourceCodeMode.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshSearchMatchState();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearPendingMedia();
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
editor,
|
||||||
|
submit,
|
||||||
|
getPendingMediaCount: () => pendingMediaMap.size,
|
||||||
|
openSearch,
|
||||||
|
closeSearch,
|
||||||
|
findNext,
|
||||||
|
findPrevious,
|
||||||
|
replaceCurrent,
|
||||||
|
replaceAll
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tp-editor {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: text;
|
||||||
|
padding: 2rem 10rem;
|
||||||
|
display: flex;
|
||||||
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--td-bg-color-page);
|
||||||
|
|
||||||
|
.value-wrap {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: var(--td-radius-default);
|
||||||
|
|
||||||
|
:deep(.editor-body) {
|
||||||
|
outline: none;
|
||||||
|
padding: var(--tp-editor-padding-tb) var(--tp-editor-padding-lr);
|
||||||
|
min-height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
pre[class*='language-'] > code {
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.is-editor-empty:first-child:before {
|
||||||
|
height: 0;
|
||||||
|
float: left;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe.tp-iframe {
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-editor {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
border-radius: var(--td-radius-default);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 48rem) {
|
||||||
|
.tp-editor {
|
||||||
|
.content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import { Toolkit } from "timi-web";
|
||||||
|
|
||||||
|
export const TiptapView = Toolkit.withInstall(view);
|
||||||
|
export default TiptapView;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tp-view selectable">
|
||||||
|
<div v-if="renderContent && editor" ref="contentRef" class="content-wrap" :data-max-height="maxHeight">
|
||||||
|
<editor-content class="content" :editor="editor" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty">
|
||||||
|
<span v-text="emptyText" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { normalizeContent, useTiptapView } from "timi-web/editor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapView"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
content?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
maxHeight?: string;
|
||||||
|
}>(), {
|
||||||
|
content: "",
|
||||||
|
emptyText: "暂无内容",
|
||||||
|
maxHeight: "400px"
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderContent = computed(() => normalizeContent(props.content));
|
||||||
|
const { EditorContent, contentRef, editor } = useTiptapView(renderContent, toRef(props, "maxHeight"));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tp-view {
|
||||||
|
width: 100%;
|
||||||
|
.empty {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: .375rem;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
padding: 1rem;
|
||||||
|
min-height: 12rem;
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import "./assets/style/tencent-cloud.less";
|
import "./assets/style/tencent-cloud.less";
|
||||||
import "./assets/style/tencent-cloud-custom.less";
|
import "./assets/style/tencent-cloud-custom.less";
|
||||||
import { popupUserStore } from "./store/popupUser";
|
import { popupUserStore } from "./store/popupUser";
|
||||||
|
import "./components/tiptap-content.less";
|
||||||
|
import "timi-web/style.css";
|
||||||
|
import "../../timi-web/src/components/prism-code-block.less";
|
||||||
|
|
||||||
export * from "./components";
|
export * from "./components";
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
"@/*": [
|
"@/*": [
|
||||||
"./examples/*"
|
"./examples/*"
|
||||||
],
|
],
|
||||||
|
"timi-web/editor": [
|
||||||
|
"../timi-web/src/editor/index.ts"
|
||||||
|
],
|
||||||
"timi-tdesign-pc": [
|
"timi-tdesign-pc": [
|
||||||
"./src/index.ts"
|
"./src/index.ts"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ const alias: Alias[] = [
|
|||||||
find: "*",
|
find: "*",
|
||||||
replacement: resolve("")
|
replacement: resolve("")
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
find: /^timi-web\/editor$/,
|
||||||
|
replacement: resolve(__dirname, "../timi-web/src/editor/index.ts")
|
||||||
|
},
|
||||||
{
|
{
|
||||||
find: /^timi-tdesign-pc(\/(es|lib))?$/,
|
find: /^timi-tdesign-pc(\/(es|lib))?$/,
|
||||||
replacement: resolve(__dirname, "./src/index.ts")
|
replacement: resolve(__dirname, "./src/index.ts")
|
||||||
|
|||||||
Reference in New Issue
Block a user