refactor editor

This commit is contained in:
Timi
2026-08-25 17:35:42 +08:00
parent bc375e1b2d
commit 13bd61326d
40 changed files with 4444 additions and 55 deletions
+92 -11
View File
@@ -1,21 +1,102 @@
<template>
<div class="root">
<t-cell-group title="测试 cell" theme="card">
<t-cell title="title" arrow>测试</t-cell>
<t-cell title="title" arrow>测试</t-cell>
<t-cell title="title" arrow>测试</t-cell>
<t-cell title="title" arrow>测试</t-cell>
</t-cell-group>
<t-button theme="primary">按钮</t-button>
<div class="demo-app">
<header class="hero">
<div>
<p class="eyebrow">timi-tdesign-mobile</p>
<h1>移动端组件演示台</h1>
<p>每个页面都使用真实公开组件和可重复的本地演示数据。</p>
</div>
<t-tag theme="primary" variant="light">TDesign Mobile</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-if="activeTab === 'notify'" />
<duration-showcase v-else />
</main>
</div>
</template>
<script lang="ts" setup>
import ArticleShowcase from "./components/ArticleShowcase.vue";
import DurationShowcase from "./components/DurationShowcase.vue";
import EditorShowcase from "./components/EditorShowcase.vue";
import NotifyShowcase from "./components/NotifyShowcase.vue";
type TabValue = "editor" | "article" | "notify" | "duration";
const tabs: Array<{ label: string; value: TabValue }> = [
{ label: "编辑器", value: "editor" },
{ label: "文章", value: "article" },
{ label: "通知", value: "notify" },
{ label: "移动端组件", value: "duration" }
];
const activeTab = ref<TabValue>("editor");
</script>
<style lang="less" scoped>
.root {
width: 100%;
background: lightgray;
.demo-app {
width: min(100% - 2rem, 80rem);
min-height: 100vh;
margin: 0 auto;
padding: 2rem 0 4rem;
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(1.75rem, 6vw, 3rem);
}
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;
overflow-x: auto;
padding-bottom: .25rem;
}
@media (max-width: 40rem) {
.demo-app {
width: min(100% - 1rem, 80rem);
padding-top: 1rem;
}
.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-mobile";
import type { ArticleEditorExpose } from "timi-tdesign-mobile";
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>
+88
View File
@@ -0,0 +1,88 @@
<template>
<section class="demo-section">
<div class="section-heading">
<div>
<p class="eyebrow">移动端专属</p>
<h2>DurationPicker</h2>
<p>验证 Picker 交互和毫秒值双向转换。</p>
</div>
<span class="value" v-text="`${durationMs} ms`" />
</div>
<div class="demo-card">
<t-cell title="当前毫秒值" arrow @click="visible = true">
<span v-text="formatDuration(durationMs)" />
</t-cell>
</div>
<t-popup v-model="visible" placement="bottom" destroy-on-close>
<div class="picker-panel">
<duration-picker v-model="durationMs" @change="visible = false" />
</div>
</t-popup>
</section>
</template>
<script lang="ts" setup>
import { DurationPicker } from "timi-tdesign-mobile";
const visible = ref(false);
const durationMs = ref(1000);
function formatDuration(value: number) {
if (60 * 60 * 1000 <= value && value % (60 * 60 * 1000) === 0) {
return `${value / (60 * 60 * 1000)} 小时`;
}
if (60 * 1000 <= value && value % (60 * 1000) === 0) {
return `${value / (60 * 1000)} 分钟`;
}
if (1000 <= value && value % 1000 === 0) {
return `${value / 1000} 秒`;
}
return `${value} 毫秒`;
}
</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);
}
.value {
font-size: 1.25rem;
font-weight: 600;
}
.demo-card {
padding: .25rem;
border: 1px solid var(--td-component-border);
border-radius: .75rem;
background: var(--td-bg-color-container);
}
.picker-panel {
padding-top: .5rem;
border-radius: 1rem 1rem 0 0;
background: var(--td-bg-color-container);
}
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<template>
<section class="demo-section">
<div class="section-heading">
<div>
<p class="eyebrow">编辑器基础能力</p>
<h2>文本、富文本和代码</h2>
<p>通过同一组公共数据规则验证移动端 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-mobile";
import type {
CodeEditorExpose,
TiptapEditorExpose
} from "timi-tdesign-mobile";
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 {
min-width: 0;
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 {
width: 100%;
min-width: 0;
height: 24rem;
margin-top: 1rem;
}
.rich-editor {
height: 28rem;
}
.height-input {
width: 9rem;
}
@media (max-width: 40rem) {
.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-mobile";
import type { NotifyEditorExpose } from "timi-tdesign-mobile";
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)
};
}
+2 -3
View File
@@ -1,15 +1,14 @@
import { createApp } from "vue";
import Root from "./Root.vue";
import { axios, VPopup } from "timi-web";
import TimiWebUI, { VPopup } from "timi-web";
import "timi-web/style.css";
import "tdesign-mobile-vue/es/style/index.css";
import "../src/assets/style/tencent-cloud.less";
import "../src/assets/style/tencent-cloud-custom.less";
axios.defaults.baseURL = "http://localhost:8091";
const app = createApp(Root);
app.use(TimiWebUI);
app.directive("popup", VPopup);
app.mount("#root");
+10 -3
View File
@@ -1,9 +1,16 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Vite + Vue + TS</title>
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#f5f5f5"/>
<meta name="color-scheme" content="light dark"/>
<meta name="format-detection" content="telephone=no"/>
<meta name="mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
<meta name="apple-mobile-web-app-title" content="Timi Mobile"/>
<title>Timi TDesign Mobile</title>
</head>
<body>
<div id="root"></div>
+4 -4
View File
@@ -5,7 +5,7 @@
"module": "./dist/timi-tdesign-mobile.mjs",
"style": "./dist/timi-tdesign-mobile.css",
"private": false,
"version": "0.0.9",
"version": "0.0.10",
"license": "MIT",
"scripts": {
"dev": "vite",
@@ -36,6 +36,8 @@
"node": ">=16.0.0"
},
"dependencies": {
"tdesign-mobile-vue": "^1.13.2",
"timi-web": "0.0.24",
"vue": "^3.5.13",
"vue-router": "4.5.0"
},
@@ -63,8 +65,6 @@
"vite-plugin-vue-setup-extend": "^0.4.0",
"@vue/tsconfig": "^0.7.0",
"vite": "7.1.11",
"vue-tsc": "^2.2.10",
"tdesign-mobile-vue": "^1.13.2",
"timi-web": "0.0.2"
"vue-tsc": "^2.2.10"
}
}
+148 -32
View File
@@ -8,6 +8,12 @@ importers:
.:
dependencies:
tdesign-mobile-vue:
specifier: ^1.13.2
version: 1.13.2(vue@3.5.13(typescript@5.8.3))
timi-web:
specifier: 0.0.24
version: 0.0.24(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))
vue:
specifier: ^3.5.13
version: 3.5.13(typescript@5.8.3)
@@ -66,12 +72,6 @@ importers:
prettier:
specifier: ^3.5.2
version: 3.5.3
tdesign-mobile-vue:
specifier: ^1.13.2
version: 1.13.2(vue@3.5.13(typescript@5.8.3))
timi-web:
specifier: 0.0.2
version: 0.0.2
typescript:
specifier: ^5.8.3
version: 5.8.3
@@ -1455,6 +1455,15 @@ packages:
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@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:
@@ -1613,6 +1622,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:
@@ -1730,8 +1743,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==}
@@ -1779,6 +1792,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==}
@@ -2169,6 +2185,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'}
@@ -2756,6 +2776,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'}
@@ -2912,6 +2941,9 @@ packages:
resolution: {integrity: sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg==}
engines: {node: '>=16.9.0'}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
@@ -2979,6 +3011,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'}
@@ -3449,6 +3485,9 @@ packages:
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
engines: {node: '>=8'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
@@ -3708,6 +3747,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==}
@@ -3730,6 +3772,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
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
@@ -4001,8 +4052,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==}
@@ -4132,6 +4184,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
@@ -4322,6 +4377,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==}
@@ -4395,6 +4454,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'}
@@ -4454,11 +4517,6 @@ packages:
uglify-js:
optional: true
terser@5.39.0:
resolution: {integrity: sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==}
engines: {node: '>=10'}
hasBin: true
terser@5.46.0:
resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==}
engines: {node: '>=10'}
@@ -4480,8 +4538,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:
@@ -4652,6 +4710,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:
@@ -5062,7 +5121,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:
@@ -6717,6 +6776,24 @@ snapshots:
'@vue/devtools-api@6.6.4': {}
'@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.26.0)(prettier@3.5.3)':
dependencies:
eslint: 9.26.0
@@ -6921,6 +6998,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
@@ -7031,13 +7114,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:
@@ -7094,6 +7179,8 @@ snapshots:
binary-extensions@2.3.0: {}
birpc@2.9.0: {}
bl@4.1.0:
dependencies:
buffer: 5.7.1
@@ -7359,6 +7446,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
@@ -8046,6 +8135,8 @@ snapshots:
optionalDependencies:
debug: 4.4.3
follow-redirects@1.16.0: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
@@ -8190,6 +8281,8 @@ snapshots:
hono@4.11.10: {}
hookable@5.5.3: {}
hosted-git-info@2.8.9: {}
hpack.js@2.1.6:
@@ -8279,6 +8372,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:
@@ -8683,6 +8783,8 @@ snapshots:
dependencies:
yallist: 4.0.0
mitt@3.0.1: {}
mkdirp@0.5.6:
dependencies:
minimist: 1.2.8
@@ -8917,6 +9019,8 @@ snapshots:
pathe@2.0.3: {}
perfect-debounce@1.0.0: {}
picocolors@0.2.1: {}
picocolors@1.1.1: {}
@@ -8930,6 +9034,13 @@ snapshots:
pify@4.0.1:
optional: true
pinia@3.0.4(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)):
dependencies:
'@vue/devtools-api': 7.7.10
vue: 3.5.13(typescript@5.8.3)
optionalDependencies:
typescript: 5.8.3
pkce-challenge@5.0.1: {}
pkg-dir@4.2.0:
@@ -9194,7 +9305,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
@@ -9338,6 +9449,8 @@ snapshots:
reusify@1.1.0: {}
rfdc@1.4.1: {}
rimraf@3.0.2:
dependencies:
glob: 7.2.3
@@ -9623,6 +9736,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
speakingurl@14.0.1: {}
sprintf-js@1.0.3: {}
ssri@8.0.1:
@@ -9684,6 +9799,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
@@ -9742,16 +9861,9 @@ snapshots:
jest-worker: 27.5.1
schema-utils: 4.3.0
serialize-javascript: 6.0.2
terser: 5.39.0
terser: 5.46.0
webpack: 5.98.0
terser@5.39.0:
dependencies:
'@jridgewell/source-map': 0.3.6
acorn: 8.14.1
commander: 2.20.3
source-map-support: 0.5.21
terser@5.46.0:
dependencies:
'@jridgewell/source-map': 0.3.6
@@ -9778,18 +9890,22 @@ snapshots:
thunky@1.1.0: {}
timi-web@0.0.2:
timi-web@0.0.24(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)):
dependencies:
axios: 1.13.5
axios: 1.18.0
less: 4.5.1
marked: 17.0.3
marked-gfm-heading-id: 4.1.3(marked@17.0.3)
marked-highlight: 2.2.3(marked@17.0.3)
marked-mangle: 1.1.12(marked@17.0.3)
pinia: 3.0.4(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))
prismjs: 1.30.0
terser: 5.46.0
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>
+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%);
}
}
}
+5
View File
@@ -0,0 +1,5 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const DurationPicker = Toolkit.withInstall(view);
export default DurationPicker;
+158
View File
@@ -0,0 +1,158 @@
<template>
<t-picker
:model-value="pickerValue"
:columns="pickerColumns"
@pick="handlePickerPick"
@change="handlePickerConfirm"
@confirm="handlePickerConfirm"
/>
</template>
<script lang="ts" setup>
import type { PickerColumnItem, PickerValue } from "tdesign-mobile-vue";
defineOptions({
name: "DurationPicker"
});
type DurationUnit = "year" | "day" | "hour" | "minute" | "second" | "millisecond";
interface UnitConfig {
label: string;
max: number;
min: number;
step: number;
toMs: number;
value: DurationUnit;
}
const UNIT_CONFIG_LIST: UnitConfig[] = [
{ label: "年", value: "year", min: 1, max: 100, step: 1, toMs: 365 * 24 * 60 * 60 * 1000 },
{ label: "天", value: "day", min: 1, max: 364, step: 1, toMs: 24 * 60 * 60 * 1000 },
{ label: "小时", value: "hour", min: 1, max: 23, step: 1, toMs: 60 * 60 * 1000 },
{ label: "分钟", value: "minute", min: 1, max: 59, step: 1, toMs: 60 * 1000 },
{ label: "秒", value: "second", min: 1, max: 59, step: 1, toMs: 1000 },
{ label: "毫秒", value: "millisecond", min: 50, max: 950, step: 50, toMs: 1 }
];
const UNIT_MAP = new Map<DurationUnit, UnitConfig>(
UNIT_CONFIG_LIST.map((unit) => [unit.value, unit])
);
const props = withDefaults(defineProps<{
modelValue?: number;
}>(), {
modelValue: 1000
});
const emit = defineEmits<{
(e: "update:modelValue", value: number): void;
(e: "change", value: number): void;
}>();
const selectedUnit = ref<DurationUnit>("second");
const selectedValue = ref<number>(1);
const activeUnitConfig = computed(() => UNIT_MAP.get(selectedUnit.value) ?? UNIT_CONFIG_LIST[0]);
const valueColumn = computed(() => {
const items: PickerColumnItem[] = [];
const unit = activeUnitConfig.value;
for (let index = unit.min; index <= unit.max; index += unit.step) {
items.push({
label: String(index),
value: String(index)
});
}
return items;
});
const unitColumn = computed(() => UNIT_CONFIG_LIST.map((item) => ({
label: item.label,
value: item.value
})));
const pickerColumns = computed(() => [
valueColumn.value,
unitColumn.value
]);
const pickerValue = computed<PickerValue[]>(() => [
String(selectedValue.value),
selectedUnit.value
]);
const currentMs = computed(() => {
const unit = activeUnitConfig.value;
return selectedValue.value * unit.toMs;
});
function clampByUnit(value: number, unit: UnitConfig): number {
if (value < unit.min) {
return unit.min;
}
if (unit.max < value) {
return unit.max;
}
const offset = value - unit.min;
const stepCount = Math.round(offset / unit.step);
return unit.min + stepCount * unit.step;
}
function pickStateFromMs(inputMs: number): { value: number; unit: DurationUnit } {
const positiveMs = Math.max(1, Math.floor(inputMs));
for (const unit of UNIT_CONFIG_LIST) {
if (unit.toMs <= positiveMs && positiveMs % unit.toMs === 0) {
const candidate = positiveMs / unit.toMs;
if (unit.min <= candidate && candidate <= unit.max && (candidate - unit.min) % unit.step === 0) {
return {
value: candidate,
unit: unit.value
};
}
}
}
const msUnit = UNIT_MAP.get("millisecond") as UnitConfig;
return {
value: clampByUnit(positiveMs, msUnit),
unit: "millisecond"
};
}
function applyMsState(inputMs: number): void {
const state = pickStateFromMs(inputMs);
selectedUnit.value = state.unit;
selectedValue.value = state.value;
}
function handlePickerPick(values: PickerValue[]): void {
applyPickerValues(values);
}
function handlePickerConfirm(values: PickerValue[]): void {
applyPickerValues(values);
const nextMs = currentMs.value;
emit("update:modelValue", nextMs);
emit("change", nextMs);
}
function applyPickerValues(values: PickerValue[]): void {
const nextValue = Number(values[0]);
const nextUnit = String(values[1]) as DurationUnit;
const targetUnit = UNIT_MAP.get(nextUnit) ?? UNIT_CONFIG_LIST[0];
selectedUnit.value = targetUnit.value;
selectedValue.value = clampByUnit(nextValue, targetUnit);
}
watch(() => props.modelValue, (value) => {
if (value === currentMs.value) {
return;
}
applyMsState(value);
}, { immediate: true });
</script>
+273
View File
@@ -0,0 +1,273 @@
<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"
@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"
@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 onKeywordKeydown = (_value: string | number, context: InputKeyboardContext) => {
if ("Enter" === context.e.key) {
emit("next", context.e.shiftKey);
return;
}
if ("Escape" !== context.e.key) {
return;
}
context.e.preventDefault();
emit("close");
};
const onReplaceKeydown = (_value: string | number, context: InputKeyboardContext) => {
if ("Enter" === context.e.key) {
emit("replace");
return;
}
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>
+42 -2
View File
@@ -1,10 +1,50 @@
/** 导出所有组件 */
import RootLayout from "./root-layout";
import DurationPicker from "./duration-picker";
import CodeEditor from "./code-editor";
import TiptapEditor from "./tiptap-editor";
import TiptapView from "./tiptap-view";
import ArticleEditor from "./article-editor";
import ArticleView from "./article-view";
import NotifyEditor from "./notify-editor";
import NotifyView from "./notify-view";
export default [
RootLayout
RootLayout,
DurationPicker,
CodeEditor,
TiptapEditor,
TiptapView,
ArticleEditor,
ArticleView,
NotifyEditor,
NotifyView
];
export {
RootLayout
RootLayout,
DurationPicker,
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";
+9
View File
@@ -0,0 +1,9 @@
import { ToastPlugin } from "tdesign-mobile-vue";
type MessageType = "warning" | "error";
export async function showPlatformMessage(type: MessageType, content: string) {
await Promise.resolve(ToastPlugin[type]({
message: 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,95 @@
<template>
<t-button v-popup="'代码块'" variant="outline" :theme="active ? 'primary' : 'default'" @click="openDialog">
<t-icon name="code" />
</t-button>
<t-popup
v-model="visible"
placement="bottom"
destroy-on-close
>
<div class="form-popup">
<div class="popup-head">
<span class="title">插入代码块</span>
<t-button shape="square" size="small" variant="text" title="关闭" @click="visible = false">
<t-icon name="close" />
</t-button>
</div>
<t-input v-model="codeLanguage" placeholder="代码语言,可留空" />
<div class="popup-actions">
<t-button variant="outline" block @click="visible = false">取消</t-button>
<t-button theme="primary" block @click="onConfirm">插入</t-button>
</div>
</div>
</t-popup>
</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 openDialog() {
if (!props.editor) {
return;
}
codeLanguage.value = "";
visible.value = true;
}
function onConfirm() {
if (!props.editor) {
return;
}
const language = codeLanguage.value.trim();
if (language) {
props.editor.chain().focus().setCodeBlock({ language }).run();
} else {
props.editor.chain().focus().setCodeBlock().run();
}
visible.value = false;
}
</script>
<style lang="less" scoped>
.form-popup {
width: 100%;
max-height: 85vh;
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
box-sizing: border-box;
overflow-y: auto;
background: var(--td-bg-color-container);
.popup-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
.title {
font-size: 1rem;
font-weight: 600;
color: var(--td-text-color-primary, #000);
}
}
.popup-actions {
gap: .75rem;
display: flex;
margin-top: 1rem;
:deep(.t-button) {
flex: 1;
}
}
}
</style>
@@ -0,0 +1,149 @@
<template>
<t-button v-popup="'插入内嵌网页'" variant="outline" :theme="active ? 'primary' : 'default'" @click="openDialog">
<t-icon name="web" />
</t-button>
<t-popup
v-model="visible"
placement="bottom"
destroy-on-close
>
<div class="form-popup">
<div class="popup-head">
<span class="title">插入内嵌网页</span>
<t-button shape="square" size="small" variant="text" title="关闭" @click="visible = false">
<t-icon name="close" />
</t-button>
</div>
<t-form class="form-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>
<div class="popup-actions">
<t-button variant="outline" block @click="visible = false">取消</t-button>
<t-button theme="primary" block @click="onConfirm">插入</t-button>
</div>
</div>
</t-popup>
</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 openDialog() {
if (!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";
visible.value = true;
}
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>
.form-popup {
width: 100%;
max-height: 85vh;
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
box-sizing: border-box;
overflow-y: auto;
background: var(--td-bg-color-container);
.popup-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
.title {
font-size: 1rem;
font-weight: 600;
color: var(--td-text-color-primary, #000);
}
}
.form-content {
:deep(.t-form__item) {
margin-bottom: .875rem;
}
}
.popup-actions {
gap: .75rem;
display: flex;
margin-top: 1rem;
:deep(.t-button) {
flex: 1;
}
}
}
</style>
@@ -0,0 +1,131 @@
<template>
<t-button v-popup="'插入链接'" variant="outline" :theme="active ? 'primary' : 'default'" @click="openDialog">
<t-icon name="link-1" />
</t-button>
<t-popup
v-model="visible"
placement="bottom"
destroy-on-close
>
<div class="form-popup">
<div class="popup-head">
<span class="title">插入链接</span>
<t-button shape="square" size="small" variant="text" title="关闭" @click="visible = false">
<t-icon name="close" />
</t-button>
</div>
<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>
<div class="popup-actions">
<t-button variant="outline" block @click="visible = false">取消</t-button>
<t-button theme="primary" block @click="onConfirm">插入</t-button>
</div>
</div>
</t-popup>
</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 openDialog() {
if (!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;
visible.value = true;
}
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();
} else {
props.editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
}
visible.value = false;
}
</script>
<style lang="less" scoped>
.link-form {
gap: .75rem;
display: flex;
flex-direction: column;
}
.form-popup {
width: 100%;
max-height: 85vh;
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
box-sizing: border-box;
overflow-y: auto;
background: var(--td-bg-color-container);
.popup-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
.title {
font-size: 1rem;
font-weight: 600;
color: var(--td-text-color-primary, #000);
}
}
.popup-actions {
gap: .75rem;
display: flex;
margin-top: 1rem;
:deep(.t-button) {
flex: 1;
}
}
}
</style>
@@ -0,0 +1,216 @@
<template>
<t-button variant="outline" :theme="active ? 'primary' : 'default'" :loading="uploading" @click="openDialog">
<t-icon :name="iconName" />
</t-button>
<t-popup
v-model="visible"
placement="bottom"
destroy-on-close
>
<div class="form-popup">
<div class="popup-head">
<span class="title" v-text="`插入${typeLabel}`" />
<t-button shape="square" size="small" variant="text" title="关闭" @click="visible = false">
<t-icon name="close" />
</t-button>
</div>
<t-form class="form-content" label-align="left" label-width="auto">
<t-form-item label="文件">
<t-button size="small" variant="outline" @click="pickFile">
<span 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>
<div class="popup-actions">
<t-button variant="outline" block :disabled="uploading" @click="visible = false">取消</t-button>
<t-button theme="primary" block :loading="uploading" @click="onConfirm">插入</t-button>
</div>
</div>
</t-popup>
<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 openDialog() {
if (!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();
visible.value = true;
}
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>
.form-popup {
width: 100%;
max-height: 85vh;
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
box-sizing: border-box;
overflow-y: auto;
background: var(--td-bg-color-container);
.popup-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
.title {
font-size: 1rem;
font-weight: 600;
color: var(--td-text-color-primary, #000);
}
}
.form-content {
:deep(.t-form__item) {
margin-bottom: .875rem;
}
}
.popup-actions {
gap: .75rem;
display: flex;
margin-top: 1rem;
:deep(.t-button) {
flex: 1;
}
}
}
.file-input {
display: none;
}
</style>
@@ -0,0 +1,113 @@
<template>
<t-button v-popup="'插入表格'" variant="outline" :theme="active ? 'primary' : 'default'" @click="openPopup">
<t-icon name="table" />
</t-button>
<t-popup v-model="visible" placement="bottom" destroy-on-close>
<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>
</t-popup>
</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 openPopup() {
hoverRow.value = 1;
hoverCol.value = 1;
visible.value = true;
}
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;
}
props.editor.chain().focus().insertTable({ rows: row, cols: col, withHeaderRow: true }).run();
visible.value = false;
}
</script>
<style lang="less" scoped>
.table-picker {
width: min(20rem, calc(100vw - 2rem));
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
box-sizing: border-box;
background: var(--td-bg-color-container, #fff);
.tip {
height: 1.5rem;
color: var(--td-text-color-secondary, #666);
font-size: .75rem;
line-height: 1.5rem;
}
.grid {
gap: .125rem;
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.cell {
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: 1px solid var(--td-component-border, #dcdcdc);
background: var(--td-bg-color-container, #fff);
&.active {
border-color: var(--td-brand-color, #0052d9);
background: var(--td-brand-color-light, #e0edff);
}
}
}
</style>
@@ -0,0 +1,405 @@
<template>
<div class="toolbar">
<div class="toolbar-head">
<div class="history" :class="{ 'toolbar-disabled': sourceCodeMode }">
<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>
<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>
<div class="scroll" :class="{ 'toolbar-disabled': sourceCodeMode }">
<div class="actions">
<t-button v-popup="'标题'" variant="outline" :theme="isHeadingActive ? 'primary' : 'default'" @click="headingVisible = true">
<t-icon name="text" />
</t-button>
<t-popup v-model="headingVisible" placement="bottom" destroy-on-close>
<div class="heading-picker">
<t-button
v-for="option in headingMeta"
:key="option.value"
variant="text"
block
@click="onHeadingSelect(option)"
>
<span :class="['tp-heading-option', option.className]" v-text="option.label" />
</t-button>
</div>
</t-popup>
<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>
<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 { headingMeta } 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 TiptapTableButton from "./TiptapTableButton.vue";
defineOptions({
name: "TiptapToolbar"
});
const props = withDefaults(defineProps<{
editor?: TiptapEditorInstance;
uploadImage?: (file: File) => Promise<UploadResult>;
uploadVideo?: (file: File) => Promise<UploadResult>;
sourceCodeMode?: boolean;
}>(), {
sourceCodeMode: false
});
const emit = defineEmits<{
"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 headingVisible = ref(false);
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 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> }) {
headingVisible.value = false;
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 {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
max-width: 100%;
border-bottom: 1px solid var(--td-component-border);
background: var(--td-bg-color-container);
&:after {
content: "";
display: none;
}
.hidden {
display: none;
}
.toolbar-head {
width: 100%;
display: flex;
gap: .5rem;
align-items: center;
min-width: 0;
min-height: 3.25rem;
padding: .5rem .625rem;
box-sizing: border-box;
border-bottom: 1px solid var(--td-component-border);
:deep(.t-button) {
flex: none;
width: 2.5rem;
height: 2.5rem;
padding: 0;
}
}
.history {
display: flex;
gap: .5rem;
}
.source-btn {
margin-left: auto;
}
.scroll {
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.actions {
gap: .375rem;
display: flex;
width: max-content;
min-width: 100%;
padding: .5rem .625rem;
box-sizing: border-box;
> * {
flex: none;
}
:deep(.t-button) {
width: 2.5rem;
height: 2.5rem;
padding: 0;
}
}
.toolbar-disabled {
opacity: .55;
pointer-events: 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);
.heading-picker {
width: min(22rem, calc(100vw - 2rem));
padding: .5rem .75rem calc(.5rem + env(safe-area-inset-bottom));
}
</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;
+603
View File
@@ -0,0 +1,603 @@
<template>
<div class="tp-editor">
<tiptap-toolbar
:editor="editorInstance"
:source-code-mode="sourceCodeMode"
:upload-image="handleInsertImage"
:upload-video="handleInsertVideo"
@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="editorInstance" 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"
:editor="editorInstance"
: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 } 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;
uploadImage?: (file: File) => Promise<UploadResult>;
uploadVideo?: (file: File) => Promise<UploadResult>;
}>(), {
modelValue: "",
placeholder: "请输入内容"
});
const emit = defineEmits<{
"update:modelValue": [value: string];
"change": [value: string];
}>();
const { EditorContent, editor } = useTiptapEditor(props, emit);
const editorInstance = computed(() => editor.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%;
max-width: 100%;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border: 1px solid var(--td-component-border);
border-radius: var(--td-radius-default);
overflow: hidden;
background: var(--td-bg-color-page);
.content {
flex: 1;
width: 100%;
height: 100%;
display: flex;
min-width: 0;
min-height: 0;
padding: .5rem;
overflow: auto;
box-sizing: border-box;
cursor: text;
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);
border-radius: var(--td-radius-default);
box-sizing: border-box;
box-shadow: var(--td-shadow-1);
:deep(.editor-body) {
outline: none;
padding: 1rem .875rem;
min-height: 100%;
box-sizing: border-box;
overflow-wrap: anywhere;
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: 20rem;
background: var(--td-bg-color-container);
border-radius: var(--td-radius-default);
box-shadow: var(--td-shadow-1);
}
}
}
</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;
+54
View File
@@ -0,0 +1,54 @@
<template>
<div class="tp-view selectable">
<div v-if="renderContent && editorInstance" ref="contentRef" class="content-wrap" :data-max-height="maxHeight">
<editor-content class="content" :editor="editorInstance" />
</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(() => {
return normalizeContent(props.content);
});
const { EditorContent, contentRef, editor } = useTiptapView(renderContent, toRef(props, "maxHeight"));
const editorInstance = computed(() => editor.value);
</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,5 +1,8 @@
import "./assets/style/tencent-cloud.less";
import "./assets/style/tencent-cloud-custom.less";
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
@@ -20,6 +20,9 @@
"@/*": [
"./examples/*"
],
"timi-web/editor": [
"../timi-web/src/editor/index.ts"
],
"timi-tdesign-mobile": [
"./src/index.ts"
]
+4
View File
@@ -12,6 +12,10 @@ const alias: Alias[] = [
find: "@",
replacement: resolve(__dirname, "./examples")
},
{
find: /^timi-web\/editor$/,
replacement: resolve(__dirname, "../timi-web/src/editor/index.ts")
},
{
find: /^timi-tdesign-mobile(\/(es|lib))?$/,
replacement: resolve(__dirname, "./src/index.ts")