refactor editor

This commit is contained in:
Timi
2026-08-25 17:36:14 +08:00
parent d8fbbbe616
commit f790122c01
53 changed files with 4175 additions and 1012 deletions
+85 -9
View File
@@ -1,21 +1,97 @@
<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>
<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 { UserProfilePopup } from "~/components";
const isReady = ref(false);
type TabValue = "editor" | "article" | "notify";
onMounted(async () => {
isReady.value = true;
});
const tabs: Array<{ label: string; value: TabValue }> = [
{ label: "编辑器", value: "editor" },
{ label: "文章", value: "article" },
{ label: "通知", value: "notify" }
];
const activeTab = ref<TabValue>("editor");
</script>
<style lang="less" scoped>
.test {
width: 20rem;
margin: 2rem auto;
.demo-app {
width: min(100% - 3rem, 96rem);
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>
+177
View File
@@ -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>
+183
View File
@@ -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>
+137
View File
@@ -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>
+59
View File
@@ -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
View File
@@ -1,14 +1,11 @@
import { createApp } from "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 "~/assets/style/tencent-cloud.less";
import "~/assets/style/tencent-cloud-custom.less";
axios.defaults.baseURL = "http://localhost:8091";
axios.interceptors.request.use(Network.userTokenInterceptors);
const app = createApp(Root);
app.use(TimiWebUI);
app.directive("popup", VPopup);
+3 -3
View File
@@ -36,6 +36,8 @@
"node": ">=16.0.0"
},
"dependencies": {
"tdesign-vue-next": "^1.18.6",
"timi-web": "0.0.24",
"vue": "^3.5.26"
},
"devDependencies": {
@@ -62,8 +64,6 @@
"vite-plugin-vue-setup-extend": "^0.4.0",
"@vue/tsconfig": "^0.8.1",
"vite": "7.3.1",
"vue-tsc": "^3.2.2",
"tdesign-vue-next": "^1.18.6",
"timi-web": "0.0.2"
"vue-tsc": "^3.2.2"
}
}
+143 -32
View File
@@ -8,6 +8,12 @@ importers:
.:
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:
specifier: ^3.5.26
version: 3.5.26(typescript@5.9.3)
@@ -63,12 +69,6 @@ importers:
prettier:
specifier: ^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:
specifier: ^5.9.3
version: 5.9.3
@@ -1462,6 +1462,15 @@ packages:
'@vue/component-compiler-utils@3.3.0':
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':
resolution: {integrity: sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==}
peerDependencies:
@@ -1602,6 +1611,10 @@ packages:
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
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:
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
peerDependencies:
@@ -1719,8 +1732,8 @@ packages:
peerDependencies:
postcss: ^8.1.0
axios@1.13.5:
resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==}
axios@1.18.0:
resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==}
babel-loader@8.4.1:
resolution: {integrity: sha512-nXzRChX+Z1GoE6yWavBQg6jDslyFF3SDjl2paADuoQtQW10JqShJt62R6eJQ5m/pjJFDT8xgKIWSP85OY8eXeA==}
@@ -1768,6 +1781,9 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@@ -2153,6 +2169,10 @@ packages:
copy-anything@2.0.6:
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:
resolution: {integrity: sha512-rxnR7PaGigJzhqETHGmAcxKnLZSR5u1Y3/bcIv/1FnqXedcL/E2ewK7ZCNrArJKCiSv8yVXhTqetJh8inDvfsA==}
engines: {node: '>= 12.13.0'}
@@ -2704,6 +2724,15 @@ packages:
debug:
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:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
@@ -2845,6 +2874,9 @@ packages:
highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
@@ -2908,6 +2940,10 @@ packages:
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
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:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@@ -3611,6 +3647,9 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
picocolors@0.2.1:
resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==}
@@ -3629,6 +3668,15 @@ packages:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
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:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'}
@@ -3895,8 +3943,9 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
@@ -4025,6 +4074,9 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported
@@ -4211,6 +4263,10 @@ packages:
resolution: {integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==}
engines: {node: '>=6.0.0'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
@@ -4280,6 +4336,10 @@ packages:
peerDependencies:
postcss: ^8.2.15
superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
@@ -4339,11 +4399,6 @@ packages:
uglify-js:
optional: true
terser@5.39.0:
resolution: {integrity: sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==}
engines: {node: '>=10'}
hasBin: true
terser@5.46.1:
resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==}
engines: {node: '>=10'}
@@ -4365,8 +4420,8 @@ packages:
thunky@1.1.0:
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
timi-web@0.0.2:
resolution: {integrity: sha512-tXznHwCjFy/AtmWBky35V0SkLTeHG4FayzUhBRoUNe6a7xxlSQ0p8XcQAuSFkugQuCz8/pJsZNHgplfpzs041A==}
timi-web@0.0.24:
resolution: {integrity: sha512-qzcm72W9EE3BrYiUzc8a20J8xqxH17moEm/ovdijhRbIJtJUr81sslXFe2+OjNtx14meEIPJm3IfRooFzRswLg==}
engines: {node: '>=16.0.0'}
tinycolor2@1.6.0:
@@ -4521,6 +4576,7 @@ packages:
uuid@8.3.2:
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
validate-npm-package-license@3.0.4:
@@ -4901,7 +4957,7 @@ snapshots:
'@babel/core': 7.26.9
'@babel/helper-compilation-targets': 7.26.5
'@babel/helper-plugin-utils': 7.26.5
debug: 4.4.0
debug: 4.4.3
lodash.debounce: 4.0.8
resolve: 1.22.10
transitivePeerDependencies:
@@ -6554,6 +6610,24 @@ snapshots:
- walrus
- 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)':
dependencies:
eslint: 9.39.2
@@ -6735,6 +6809,12 @@ snapshots:
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):
optionalDependencies:
ajv: 8.13.0
@@ -6841,13 +6921,15 @@ snapshots:
postcss: 8.5.6
postcss-value-parser: 4.2.0
axios@1.13.5:
axios@1.18.0:
dependencies:
follow-redirects: 1.15.11(debug@4.4.3)
follow-redirects: 1.16.0
form-data: 4.0.5
proxy-from-env: 1.1.0
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
babel-loader@8.4.1(@babel/core@7.26.9)(webpack@5.98.0):
dependencies:
@@ -6904,6 +6986,8 @@ snapshots:
binary-extensions@2.3.0: {}
birpc@2.9.0: {}
bl@4.1.0:
dependencies:
buffer: 5.7.1
@@ -7157,6 +7241,8 @@ snapshots:
dependencies:
is-what: 3.14.1
copy-anything@4.1.0: {}
copy-webpack-plugin@9.1.0(webpack@5.98.0):
dependencies:
fast-glob: 3.3.3
@@ -7769,6 +7855,8 @@ snapshots:
optionalDependencies:
debug: 4.4.3
follow-redirects@1.16.0: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
@@ -7903,6 +7991,8 @@ snapshots:
highlight.js@10.7.3: {}
hookable@5.5.3: {}
hosted-git-info@2.8.9: {}
hpack.js@2.1.6:
@@ -7984,6 +8074,13 @@ snapshots:
transitivePeerDependencies:
- 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: {}
iconv-lite@0.4.24:
@@ -8605,6 +8702,8 @@ snapshots:
pathe@2.0.3: {}
perfect-debounce@1.0.0: {}
picocolors@0.2.1: {}
picocolors@1.1.1: {}
@@ -8616,6 +8715,13 @@ snapshots:
pify@4.0.1:
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:
dependencies:
find-up: 4.1.0
@@ -8878,7 +8984,7 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
proxy-from-env@1.1.0: {}
proxy-from-env@2.1.0: {}
prr@1.0.1:
optional: true
@@ -9015,6 +9121,8 @@ snapshots:
reusify@1.1.0: {}
rfdc@1.4.1: {}
rimraf@3.0.2:
dependencies:
glob: 7.2.3
@@ -9266,6 +9374,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
speakingurl@14.0.1: {}
sprintf-js@1.0.3: {}
ssri@8.0.1:
@@ -9325,6 +9435,10 @@ snapshots:
postcss: 8.5.6
postcss-selector-parser: 6.1.2
superjson@2.2.6:
dependencies:
copy-anything: 4.1.0
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
@@ -9385,16 +9499,9 @@ snapshots:
jest-worker: 27.5.1
schema-utils: 4.3.0
serialize-javascript: 6.0.2
terser: 5.39.0
terser: 5.46.1
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:
dependencies:
'@jridgewell/source-map': 0.3.6
@@ -9421,18 +9528,22 @@ snapshots:
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:
axios: 1.13.5
axios: 1.18.0
less: 4.5.1
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-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
terser: 5.46.1
transitivePeerDependencies:
- debug
- supports-color
- typescript
- vue
tinycolor2@1.6.0: {}
+11
View File
@@ -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;
+289
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const ArticleView = Toolkit.withInstall(view);
export default ArticleView;
+118
View File
@@ -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>
+4 -4
View File
@@ -9,7 +9,7 @@
<t-form-item label="验证码" name="captcha">
<div class="captcha-box">
<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>
</t-form-item>
<div class="exec">
@@ -24,7 +24,7 @@
</template>
<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 { MessagePlugin } from "tdesign-vue-next";
@@ -55,7 +55,7 @@ const { onLoginSuccess } = useHandler(emits);
const doSubmitting = ref<boolean>(false);
const loginRequest = reactive<CaptchaData<LoginRequest>>({
from: CaptchaFrom.LOGIN,
captchaId: "",
captcha: "",
data: {
user: "",
@@ -66,7 +66,7 @@ const loginRequest = reactive<CaptchaData<LoginRequest>>({
const doSubmit = async () => {
doSubmitting.value = true;
UserAPI.login(loginRequest).then(async (response) => {
await userStore.updateToken(response);
// await userStore.updateToken(response);
await onLoginSuccess();
doSubmitting.value = false;
}).catch(msg => {
+4 -4
View File
@@ -15,7 +15,7 @@
<t-form-item label="验证码" name="captcha">
<div class="captcha-box">
<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>
</t-form-item>
<div class="exec">
@@ -30,7 +30,7 @@
</template>
<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 { Emits, useHandler } from "~/components/authorize-form/emits";
@@ -44,7 +44,7 @@ const { onRegisterSuccess } = useHandler(emits);
const doSubmitting = ref<boolean>(false);
const formData = reactive<CaptchaData<RegisterForm>>({
from: CaptchaFrom.LOGIN,
captchaId: "",
captcha: "",
data: {
name: "",
@@ -101,7 +101,7 @@ const doSubmit = ({validateResult, e}: SubmitContext) => {
if (validateResult === true) {
doSubmitting.value = true;
UserAPI.register({...formData}).then(async (response) => {
await userStore.updateToken(response);
// await userStore.updateToken(response);
await onRegisterSuccess();
doSubmitting.value = false;
}).catch(msg => {
+25
View File
@@ -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;
+434
View File
@@ -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>
+202
View File
@@ -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%);
}
}
}
-17
View File
@@ -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,
};
};
-5
View File
@@ -1,5 +0,0 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const CommentForm = Toolkit.withInstall(view);
export default CommentForm;
-114
View File
@@ -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;
-168
View File
@@ -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="`&nbsp;回复 ${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>
-5
View File
@@ -1,5 +0,0 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const Comment = Toolkit.withInstall(view);
export default Comment;
-148
View File
@@ -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>
-11
View File
@@ -1,11 +0,0 @@
/**
* emits
*/
export interface Emits {
}
export const useHandler = (emit: Emits) => {
return {
};
};
-5
View File
@@ -1,5 +0,0 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const CommentList = Toolkit.withInstall(view);
export default CommentList;
-240
View File
@@ -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;
-179
View File
@@ -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>&nbsp;回复&nbsp;</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>
+271
View File
@@ -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
View File
@@ -1,34 +1,56 @@
/** 导出所有组件 */
import AuthorizeForm from "~/components/authorize-form";
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 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 [
RootLayout,
AuthorizeForm,
Comment,
CommentForm,
CommentReplyForm,
CommentList,
CommentReplyList,
UserProfile,
UserProfilePopup
UserProfilePopup,
CodeEditor,
TiptapEditor,
TiptapView,
ArticleEditor,
ArticleView,
NotifyEditor,
NotifyView
];
export {
RootLayout,
AuthorizeForm,
Comment,
CommentForm,
CommentReplyForm,
CommentList,
CommentReplyList,
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";
+7
View File
@@ -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);
}
+11
View File
@@ -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;
+76
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const NotifyView = Toolkit.withInstall(view);
export default NotifyView;
+32
View File
@@ -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>
+46
View File
@@ -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>
+19
View File
@@ -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;
+624
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const TiptapView = Toolkit.withInstall(view);
export default TiptapView;
+51
View File
@@ -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>
+3
View File
@@ -1,6 +1,9 @@
import "./assets/style/tencent-cloud.less";
import "./assets/style/tencent-cloud-custom.less";
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";
+3
View File
@@ -23,6 +23,9 @@
"@/*": [
"./examples/*"
],
"timi-web/editor": [
"../timi-web/src/editor/index.ts"
],
"timi-tdesign-pc": [
"./src/index.ts"
]
+4
View File
@@ -20,6 +20,10 @@ const alias: Alias[] = [
find: "*",
replacement: resolve("")
},
{
find: /^timi-web\/editor$/,
replacement: resolve(__dirname, "../timi-web/src/editor/index.ts")
},
{
find: /^timi-tdesign-pc(\/(es|lib))?$/,
replacement: resolve(__dirname, "./src/index.ts")