refactor editor
This commit is contained in:
+92
-11
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+148
-32
@@ -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: {}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import view from "./index.vue";
|
||||
import { Toolkit } from "timi-web";
|
||||
import type { Article } from "timi-web/editor";
|
||||
|
||||
export type ArticleEditorExpose = {
|
||||
submit: () => Promise<Article>;
|
||||
getPendingMediaCount: () => number;
|
||||
};
|
||||
|
||||
export const ArticleEditor = Toolkit.withInstall(view);
|
||||
export default ArticleEditor;
|
||||
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<t-textarea
|
||||
v-if="isTextContent"
|
||||
:model-value="editorContent"
|
||||
:autosize="textareaAutosize"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="onSimpleEditorCommit"
|
||||
/>
|
||||
<markdown-editor
|
||||
v-else-if="isMarkdownContent"
|
||||
:data="editorContent"
|
||||
@update:data="onSimpleEditorCommit"
|
||||
/>
|
||||
<tiptap-editor
|
||||
v-else
|
||||
ref="tiptapEditorRef"
|
||||
:model-value="editorContent"
|
||||
:placeholder="placeholder"
|
||||
:upload-image="handleUploadImage"
|
||||
:upload-video="handleUploadVideo"
|
||||
@change="onEditorChange"
|
||||
@update:model-value="onEditorCommit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Article, ArticleContentType, Attachment, UploadResult } from "timi-web/editor";
|
||||
import { AttachmentAPI } from "timi-web/editor";
|
||||
import { MarkdownEditor } from "timi-web";
|
||||
import { TiptapEditor } from "../tiptap-editor";
|
||||
import type { TiptapEditorExpose } from "../tiptap-editor";
|
||||
import {
|
||||
ATTACHMENT_ID_ATTR,
|
||||
TEMP_FILE_ID_ATTR,
|
||||
buildAttachmentListFromContent,
|
||||
getAttachmentIdFromSrc,
|
||||
getTempFileIdFromSrc,
|
||||
normalizeArticleContentType,
|
||||
normalizeContent,
|
||||
normalizeContentForStore,
|
||||
normalizeRawArticleContent,
|
||||
sameAttachmentList
|
||||
} from "timi-web/editor";
|
||||
|
||||
defineOptions({
|
||||
name: "ArticleEditor"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: Article;
|
||||
placeholder?: string;
|
||||
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
|
||||
resolveTempUrl?: (tempFileId: string) => string | Promise<string>;
|
||||
}>(), {
|
||||
modelValue: () => ({}),
|
||||
placeholder: "请输入文章正文内容"
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"change": [value: Article];
|
||||
"update:modelValue": [value: Article];
|
||||
}>();
|
||||
|
||||
const textareaAutosize = {
|
||||
minRows: 8,
|
||||
maxRows: 32
|
||||
};
|
||||
|
||||
const editorContent = ref("");
|
||||
const tiptapEditorRef = ref<TiptapEditorExpose>();
|
||||
const lastOutgoingContent = ref("");
|
||||
const lastOutgoingAttachmentList = ref<Attachment[]>([]);
|
||||
|
||||
const contentType = computed(() => {
|
||||
return normalizeArticleContentType(props.modelValue?.contentType);
|
||||
});
|
||||
const isTextContent = computed(() => contentType.value === "TEXT");
|
||||
const isMarkdownContent = computed(() => contentType.value === "MARKDOWN");
|
||||
|
||||
const attachmentMap = computed(() => {
|
||||
return new Map((props.modelValue?.attachmentList || []).map(item => [item.id, item]));
|
||||
});
|
||||
|
||||
async function defaultUploadImage(file: File) {
|
||||
return defaultUploadMedia(file);
|
||||
}
|
||||
|
||||
async function defaultUploadVideo(file: File) {
|
||||
return defaultUploadMedia(file);
|
||||
}
|
||||
|
||||
async function defaultUploadMedia(file: File) {
|
||||
const tempFileList = await AttachmentAPI.upload([file]);
|
||||
const tempFileId = tempFileList[0]?.id || "";
|
||||
if (!tempFileId) {
|
||||
throw new Error("上传临时文件失败");
|
||||
}
|
||||
return {
|
||||
id: tempFileId,
|
||||
url: AttachmentAPI.getTempReadURL(tempFileId),
|
||||
name: file.name
|
||||
};
|
||||
}
|
||||
|
||||
async function handleUploadImage(file: File) {
|
||||
if (props.uploadImage) {
|
||||
return props.uploadImage(file);
|
||||
}
|
||||
return defaultUploadImage(file);
|
||||
}
|
||||
|
||||
async function handleUploadVideo(file: File) {
|
||||
if (props.uploadVideo) {
|
||||
return props.uploadVideo(file);
|
||||
}
|
||||
return defaultUploadVideo(file);
|
||||
}
|
||||
|
||||
async function resolveAttachmentPreviewUrl(attachmentId: string) {
|
||||
const attachment = attachmentMap.value.get(attachmentId);
|
||||
if (props.resolveAttachmentUrl) {
|
||||
const customUrl = await props.resolveAttachmentUrl(attachmentId, attachment);
|
||||
if (customUrl) {
|
||||
return customUrl;
|
||||
}
|
||||
}
|
||||
const previewUrl = `${attachment?.metadata?.previewUrl || ""}`;
|
||||
if (previewUrl) {
|
||||
return previewUrl;
|
||||
}
|
||||
return attachmentId ? AttachmentAPI.getReadURL(attachmentId) : "";
|
||||
}
|
||||
|
||||
async function resolveTempPreviewUrl(tempFileId: string) {
|
||||
if (props.resolveTempUrl) {
|
||||
return props.resolveTempUrl(tempFileId);
|
||||
}
|
||||
return AttachmentAPI.getTempReadURL(tempFileId);
|
||||
}
|
||||
|
||||
async function convertContentForEditor(value?: string) {
|
||||
if (contentType.value !== "TIP_TAP") {
|
||||
return normalizeRawArticleContent(value);
|
||||
}
|
||||
const rawContent = normalizeContent(value);
|
||||
if (!rawContent) {
|
||||
return "";
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawContent, "text/html");
|
||||
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||
for (const media of mediaList) {
|
||||
const src = media.getAttribute("src") || "";
|
||||
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
|
||||
const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src);
|
||||
if (tempFileId) {
|
||||
media.setAttribute(TEMP_FILE_ID_ATTR, tempFileId);
|
||||
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId || tempFileId);
|
||||
media.setAttribute("src", await resolveTempPreviewUrl(tempFileId));
|
||||
continue;
|
||||
}
|
||||
if (!attachmentId) {
|
||||
continue;
|
||||
}
|
||||
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId);
|
||||
const previewUrl = await resolveAttachmentPreviewUrl(attachmentId);
|
||||
if (previewUrl) {
|
||||
media.setAttribute("src", previewUrl);
|
||||
}
|
||||
}
|
||||
return normalizeContent(doc.body.innerHTML);
|
||||
}
|
||||
|
||||
function normalizeIncomingArticleContent(content: string | undefined, currentContentType: ArticleContentType) {
|
||||
if (currentContentType === "TIP_TAP") {
|
||||
return normalizeContent(content);
|
||||
}
|
||||
return normalizeRawArticleContent(content);
|
||||
}
|
||||
|
||||
function buildStoredContent(content: string, currentContentType: ArticleContentType) {
|
||||
if (currentContentType === "TIP_TAP") {
|
||||
return normalizeContentForStore(content);
|
||||
}
|
||||
return normalizeRawArticleContent(content);
|
||||
}
|
||||
|
||||
function buildArticleAttachmentList(content: string, currentContentType: ArticleContentType) {
|
||||
if (currentContentType === "TIP_TAP") {
|
||||
return buildAttachmentListFromContent(content);
|
||||
}
|
||||
return props.modelValue?.attachmentList || [];
|
||||
}
|
||||
|
||||
function buildArticleValue(content: string) {
|
||||
const nextContentType = contentType.value;
|
||||
return {
|
||||
...(props.modelValue || {}),
|
||||
contentType: nextContentType,
|
||||
content: buildStoredContent(content, nextContentType),
|
||||
attachmentList: buildArticleAttachmentList(content, nextContentType)
|
||||
};
|
||||
}
|
||||
|
||||
function syncLastOutgoingArticle(value: Article) {
|
||||
const nextContentType = normalizeArticleContentType(value.contentType);
|
||||
lastOutgoingContent.value = normalizeIncomingArticleContent(value.content, nextContentType);
|
||||
lastOutgoingAttachmentList.value = value.attachmentList || [];
|
||||
}
|
||||
|
||||
// 实时变更只给预览态使用,不能反向驱动编辑器提交态
|
||||
function onEditorChange(value: string) {
|
||||
const nextValue = buildArticleValue(value);
|
||||
syncLastOutgoingArticle(nextValue);
|
||||
emit("change", {
|
||||
...nextValue
|
||||
});
|
||||
}
|
||||
|
||||
// 提交态只在离开代码块后的同步时机更新
|
||||
function onEditorCommit(value: string) {
|
||||
editorContent.value = value;
|
||||
const nextValue = buildArticleValue(value);
|
||||
syncLastOutgoingArticle(nextValue);
|
||||
emit("update:modelValue", {
|
||||
...nextValue
|
||||
});
|
||||
}
|
||||
|
||||
function onSimpleEditorCommit(value: string) {
|
||||
editorContent.value = value;
|
||||
const nextValue = buildArticleValue(value);
|
||||
syncLastOutgoingArticle(nextValue);
|
||||
emit("change", {
|
||||
...nextValue
|
||||
});
|
||||
emit("update:modelValue", {
|
||||
...nextValue
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => [props.modelValue?.content, contentType.value] as const, async ([value, nextContentType]) => {
|
||||
const nextValue = normalizeIncomingArticleContent(value, nextContentType);
|
||||
if (nextValue === lastOutgoingContent.value) {
|
||||
return;
|
||||
}
|
||||
if (buildStoredContent(editorContent.value, nextContentType) === nextValue) {
|
||||
return;
|
||||
}
|
||||
const nextContent = await convertContentForEditor(nextValue);
|
||||
if (nextContent === editorContent.value) {
|
||||
return;
|
||||
}
|
||||
editorContent.value = nextContent;
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => props.modelValue?.attachmentList, async (value) => {
|
||||
if (contentType.value !== "TIP_TAP") {
|
||||
return;
|
||||
}
|
||||
if (sameAttachmentList(lastOutgoingAttachmentList.value, value)) {
|
||||
return;
|
||||
}
|
||||
if (sameAttachmentList(buildAttachmentListFromContent(editorContent.value), value)) {
|
||||
return;
|
||||
}
|
||||
const nextContent = await convertContentForEditor(props.modelValue?.content);
|
||||
if (nextContent === editorContent.value) {
|
||||
return;
|
||||
}
|
||||
editorContent.value = nextContent;
|
||||
}, { deep: true });
|
||||
|
||||
async function submit() {
|
||||
const nextEditorContent = contentType.value === "TIP_TAP" && tiptapEditorRef.value ? await tiptapEditorRef.value.submit() : editorContent.value;
|
||||
const nextValue = buildArticleValue(nextEditorContent);
|
||||
editorContent.value = nextEditorContent;
|
||||
syncLastOutgoingArticle(nextValue);
|
||||
emit("update:modelValue", nextValue);
|
||||
return nextValue;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
submit,
|
||||
getPendingMediaCount: () => tiptapEditorRef.value?.getPendingMediaCount() || 0
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
import view from "./index.vue";
|
||||
import { Toolkit } from "timi-web";
|
||||
|
||||
export const ArticleView = Toolkit.withInstall(view);
|
||||
export default ArticleView;
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<t-textarea
|
||||
v-if="isTextContent && hasRawContent"
|
||||
class="text-view"
|
||||
:model-value="rawContent"
|
||||
:autosize="textareaAutosize"
|
||||
readonly
|
||||
/>
|
||||
<t-empty v-else-if="isTextContent" :description="emptyText" />
|
||||
<markdown-view v-else-if="isMarkdownContent && hasRawContent" :content="rawContent" />
|
||||
<t-empty v-else-if="isMarkdownContent" :description="emptyText" />
|
||||
<tiptap-view v-else :content="renderContent" :empty-text="emptyText" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Article, Attachment } from "timi-web/editor";
|
||||
import { AttachmentAPI } from "timi-web/editor";
|
||||
import { MarkdownView } from "timi-web";
|
||||
import { TiptapView } from "../tiptap-view";
|
||||
import {
|
||||
ATTACHMENT_ID_ATTR,
|
||||
getAttachmentIdFromSrc,
|
||||
normalizeArticleContentType,
|
||||
normalizeContent,
|
||||
normalizeRawArticleContent
|
||||
} from "timi-web/editor";
|
||||
|
||||
defineOptions({
|
||||
name: "ArticleView"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
article?: Article;
|
||||
emptyText?: string;
|
||||
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
|
||||
}>(), {
|
||||
article: () => ({}),
|
||||
emptyText: "暂无正文内容"
|
||||
});
|
||||
|
||||
const textareaAutosize = {
|
||||
minRows: 8,
|
||||
maxRows: 32
|
||||
};
|
||||
|
||||
const renderContent = ref("");
|
||||
|
||||
const contentType = computed(() => {
|
||||
return normalizeArticleContentType(props.article?.contentType);
|
||||
});
|
||||
const isTextContent = computed(() => contentType.value === "TEXT");
|
||||
const isMarkdownContent = computed(() => contentType.value === "MARKDOWN");
|
||||
const rawContent = computed(() => {
|
||||
return normalizeRawArticleContent(props.article?.content);
|
||||
});
|
||||
const hasRawContent = computed(() => {
|
||||
return 0 < rawContent.value.trim().length;
|
||||
});
|
||||
|
||||
const attachmentMap = computed(() => {
|
||||
return new Map((props.article?.attachmentList || []).map(item => [item.id, item]));
|
||||
});
|
||||
|
||||
async function buildAttachmentPreviewUrl(attachmentId: string) {
|
||||
const attachment = attachmentMap.value.get(attachmentId);
|
||||
if (props.resolveAttachmentUrl) {
|
||||
const customUrl = await props.resolveAttachmentUrl(attachmentId, attachment);
|
||||
if (customUrl) {
|
||||
return customUrl;
|
||||
}
|
||||
}
|
||||
const previewUrl = `${attachment?.metadata?.previewUrl || ""}`;
|
||||
if (previewUrl) {
|
||||
return previewUrl;
|
||||
}
|
||||
return attachmentId ? AttachmentAPI.getReadURL(attachmentId) : "";
|
||||
}
|
||||
|
||||
async function buildRenderContent(content?: string) {
|
||||
const rawContent = normalizeContent(content);
|
||||
if (!rawContent) {
|
||||
return "";
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawContent, "text/html");
|
||||
const nodes = Array.from(doc.querySelectorAll("img, video, source"));
|
||||
for (const node of nodes) {
|
||||
const src = node.getAttribute("src") || "";
|
||||
const attachmentId = node.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
|
||||
if (!attachmentId) {
|
||||
continue;
|
||||
}
|
||||
const previewUrl = await buildAttachmentPreviewUrl(attachmentId);
|
||||
if (previewUrl) {
|
||||
node.setAttribute("src", previewUrl);
|
||||
}
|
||||
}
|
||||
return normalizeContent(doc.body.innerHTML);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.article?.content, props.article?.attachmentList, contentType.value] as const,
|
||||
async ([, , nextContentType]) => {
|
||||
if (nextContentType !== "TIP_TAP") {
|
||||
renderContent.value = "";
|
||||
return;
|
||||
}
|
||||
renderContent.value = await buildRenderContent(props.article?.content);
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.text-view {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
import view from "./index.vue";
|
||||
import { Toolkit } from "timi-web";
|
||||
import "./style.less";
|
||||
|
||||
export type CodeEditorScrollState = {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
export type CodeEditorExpose = {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
getScrollState: () => CodeEditorScrollState;
|
||||
setScrollState: (state: Partial<CodeEditorScrollState>) => void;
|
||||
findNext: () => boolean;
|
||||
findPrevious: () => boolean;
|
||||
replaceCurrent: () => boolean;
|
||||
replaceAll: () => number;
|
||||
openSearch: (showReplace: boolean) => void;
|
||||
closeSearch: () => void;
|
||||
textareaRef?: HTMLTextAreaElement;
|
||||
};
|
||||
|
||||
export const CodeEditor = Toolkit.withInstall(view);
|
||||
export default CodeEditor;
|
||||
@@ -0,0 +1,434 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="tui-code-editor"
|
||||
:class="{
|
||||
readonly,
|
||||
disabled,
|
||||
'with-line-numbers': lineNumbers
|
||||
}"
|
||||
>
|
||||
<div v-if="lineNumbers" ref="gutterRef" class="gutter diselect" aria-hidden="true">
|
||||
<span v-for="line in lineCount" :key="line" v-text="line" />
|
||||
</div>
|
||||
<div class="body">
|
||||
<editor-search-bar
|
||||
ref="searchBarRef"
|
||||
:visible="searchVisible"
|
||||
:expanded="replaceExpanded"
|
||||
:keyword="searchKeyword"
|
||||
:replace-value="replaceKeyword"
|
||||
:status-error="searchError"
|
||||
:match-index="searchMatchIndex"
|
||||
:match-count="searchMatchList.length"
|
||||
:case-sensitive="searchOptions.caseSensitive"
|
||||
:use-regex="searchOptions.useRegex"
|
||||
@update:expanded="replaceExpanded = $event"
|
||||
@update:keyword="searchKeyword = $event"
|
||||
@update:replace-value="replaceKeyword = $event"
|
||||
@update:case-sensitive="searchOptions.caseSensitive = $event"
|
||||
@update:use-regex="searchOptions.useRegex = $event"
|
||||
@previous="findPrevious"
|
||||
@next="onSearchNext"
|
||||
@replace="replaceCurrent"
|
||||
@replace-all="replaceAll"
|
||||
@close="closeSearch"
|
||||
/>
|
||||
<pre
|
||||
ref="preRef"
|
||||
class="preview cur-text"
|
||||
:class="languageClass"
|
||||
@scroll="onPreviewScroll"
|
||||
><code :class="languageClass" v-html="highlightedHTML"></code></pre>
|
||||
<textarea
|
||||
v-if="!readonly"
|
||||
ref="textareaRef"
|
||||
v-model="currentValue"
|
||||
class="input"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
wrap="off"
|
||||
:spellcheck="false"
|
||||
@input="onInput"
|
||||
@scroll="onScroll"
|
||||
@keydown="onKeydown"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import EditorSearchBar from "../editor-search/index.vue";
|
||||
import {
|
||||
findTextMatches,
|
||||
replaceAllText,
|
||||
replaceMatchText,
|
||||
type TextSearchMatch,
|
||||
type TextSearchOptions
|
||||
} from "timi-web/editor";
|
||||
import { highlightCode, resolveCodeEditorKeydown, resolvePrismLanguage } from "timi-web/editor";
|
||||
|
||||
defineOptions({
|
||||
name: "CodeEditor"
|
||||
});
|
||||
|
||||
type ScrollState = {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
type EditorSearchBarExpose = {
|
||||
focusKeyword: (selectAll?: boolean) => void;
|
||||
focusReplace: (selectAll?: boolean) => void;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string;
|
||||
value?: string;
|
||||
language?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
lineNumbers?: boolean;
|
||||
}>(), {
|
||||
modelValue: "",
|
||||
language: "typescript",
|
||||
placeholder: "",
|
||||
readonly: false,
|
||||
disabled: false,
|
||||
lineNumbers: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: string): void;
|
||||
(event: "change", value: string): void;
|
||||
}>();
|
||||
|
||||
const bindValue = computed(() => props.value ?? props.modelValue);
|
||||
const currentValue = ref(bindValue.value);
|
||||
const rootRef = ref<HTMLDivElement>();
|
||||
const gutterRef = ref<HTMLDivElement>();
|
||||
const preRef = ref<HTMLPreElement>();
|
||||
const textareaRef = ref<HTMLTextAreaElement>();
|
||||
const searchBarRef = ref<EditorSearchBarExpose>();
|
||||
const searchVisible = ref(false);
|
||||
const replaceExpanded = ref(false);
|
||||
const searchKeyword = ref("");
|
||||
const replaceKeyword = ref("");
|
||||
const searchError = ref("");
|
||||
const searchMatchIndex = ref(-1);
|
||||
const searchMatchList = ref<TextSearchMatch[]>([]);
|
||||
const scrollState = reactive<ScrollState>({
|
||||
top: 0,
|
||||
left: 0
|
||||
});
|
||||
const searchOptions = reactive<TextSearchOptions>({
|
||||
caseSensitive: false,
|
||||
useRegex: false
|
||||
});
|
||||
|
||||
const resolvedLanguage = computed(() => resolvePrismLanguage(props.language));
|
||||
const languageClass = computed(() => resolvedLanguage.value ? `language-${resolvedLanguage.value}` : "");
|
||||
const lineCount = computed(() => Math.max(currentValue.value.split("\n").length, 1));
|
||||
const highlightedHTML = computed(() => {
|
||||
return highlightCode(currentValue.value, resolvedLanguage.value);
|
||||
});
|
||||
|
||||
watch(bindValue, (value) => {
|
||||
if (value === currentValue.value) {
|
||||
return;
|
||||
}
|
||||
currentValue.value = value;
|
||||
refreshSearchMatchState();
|
||||
nextTick(syncScrollPosition);
|
||||
});
|
||||
|
||||
watch([searchKeyword, () => searchOptions.caseSensitive, () => searchOptions.useRegex], () => {
|
||||
refreshSearchMatchState();
|
||||
});
|
||||
|
||||
const onInput = () => {
|
||||
emit("update:modelValue", currentValue.value);
|
||||
emit("change", currentValue.value);
|
||||
refreshSearchMatchState();
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
syncScrollPosition();
|
||||
};
|
||||
|
||||
const onPreviewScroll = () => {
|
||||
if (!props.readonly || !preRef.value || !gutterRef.value) {
|
||||
return;
|
||||
}
|
||||
scrollState.top = preRef.value.scrollTop;
|
||||
scrollState.left = preRef.value.scrollLeft;
|
||||
gutterRef.value.scrollTop = preRef.value.scrollTop;
|
||||
};
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
|
||||
const key = event.key.toLowerCase();
|
||||
if ("f" === key) {
|
||||
event.preventDefault();
|
||||
openSearch(false);
|
||||
return;
|
||||
}
|
||||
if ("h" === key && !props.readonly) {
|
||||
event.preventDefault();
|
||||
openSearch(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (props.readonly || props.disabled || !textareaRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = textareaRef.value;
|
||||
const nextState = resolveCodeEditorKeydown(currentValue.value, {
|
||||
start: textarea.selectionStart,
|
||||
end: textarea.selectionEnd
|
||||
}, event);
|
||||
if (!nextState) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
currentValue.value = nextState.value;
|
||||
emit("update:modelValue", currentValue.value);
|
||||
emit("change", currentValue.value);
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
textarea.selectionStart = nextState.start;
|
||||
textarea.selectionEnd = nextState.end;
|
||||
syncScrollPosition();
|
||||
});
|
||||
};
|
||||
|
||||
const getScrollState = (): ScrollState => ({
|
||||
top: scrollState.top,
|
||||
left: scrollState.left
|
||||
});
|
||||
|
||||
const setScrollState = (nextState: Partial<ScrollState>) => {
|
||||
scrollState.top = Math.max(0, nextState.top || 0);
|
||||
scrollState.left = Math.max(0, nextState.left || 0);
|
||||
applyScrollState();
|
||||
};
|
||||
|
||||
const syncScrollPosition = () => {
|
||||
if (textareaRef.value) {
|
||||
scrollState.top = textareaRef.value.scrollTop;
|
||||
scrollState.left = textareaRef.value.scrollLeft;
|
||||
applyScrollState();
|
||||
return;
|
||||
}
|
||||
if (props.readonly && preRef.value) {
|
||||
scrollState.top = preRef.value.scrollTop;
|
||||
scrollState.left = preRef.value.scrollLeft;
|
||||
applyScrollState();
|
||||
}
|
||||
};
|
||||
|
||||
const applyScrollState = () => {
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.scrollTop = scrollState.top;
|
||||
textareaRef.value.scrollLeft = scrollState.left;
|
||||
}
|
||||
if (preRef.value) {
|
||||
preRef.value.scrollTop = scrollState.top;
|
||||
preRef.value.scrollLeft = scrollState.left;
|
||||
}
|
||||
if (gutterRef.value) {
|
||||
gutterRef.value.scrollTop = scrollState.top;
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectionKeyword = () => {
|
||||
if (!textareaRef.value) {
|
||||
return "";
|
||||
}
|
||||
const { selectionStart, selectionEnd } = textareaRef.value;
|
||||
if (selectionStart === selectionEnd) {
|
||||
return "";
|
||||
}
|
||||
return currentValue.value.slice(selectionStart, selectionEnd);
|
||||
};
|
||||
|
||||
const refreshSearchMatchState = () => {
|
||||
const result = findTextMatches(currentValue.value, searchKeyword.value, searchOptions);
|
||||
searchError.value = result.error;
|
||||
searchMatchList.value = result.matches;
|
||||
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
|
||||
searchMatchIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
const { selectionStart, selectionEnd } = textareaRef.value;
|
||||
searchMatchIndex.value = searchMatchList.value.findIndex((match) => (
|
||||
match.start === selectionStart && match.end === selectionEnd
|
||||
));
|
||||
if (0 > searchMatchIndex.value) {
|
||||
searchMatchIndex.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const openSearch = (showReplace: boolean) => {
|
||||
searchVisible.value = true;
|
||||
replaceExpanded.value = showReplace;
|
||||
const selectedText = getSelectionKeyword();
|
||||
if (selectedText) {
|
||||
searchKeyword.value = selectedText;
|
||||
}
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
searchBarRef.value?.focusKeyword(true);
|
||||
});
|
||||
};
|
||||
|
||||
const closeSearch = () => {
|
||||
searchVisible.value = false;
|
||||
replaceExpanded.value = false;
|
||||
focus();
|
||||
};
|
||||
|
||||
const selectMatch = (match: TextSearchMatch, matchIndex: number) => {
|
||||
if (!textareaRef.value) {
|
||||
return false;
|
||||
}
|
||||
textareaRef.value.focus();
|
||||
textareaRef.value.selectionStart = match.start;
|
||||
textareaRef.value.selectionEnd = match.end;
|
||||
textareaRef.value.setSelectionRange(match.start, match.end);
|
||||
textareaRef.value.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
scrollState.top = textareaRef.value.scrollTop;
|
||||
scrollState.left = textareaRef.value.scrollLeft;
|
||||
applyScrollState();
|
||||
searchMatchIndex.value = matchIndex;
|
||||
return true;
|
||||
};
|
||||
|
||||
const findNext = () => {
|
||||
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
|
||||
return false;
|
||||
}
|
||||
const { selectionEnd } = textareaRef.value;
|
||||
const nextIndex = searchMatchList.value.findIndex((match) => match.start >= selectionEnd);
|
||||
const targetIndex = 0 <= nextIndex ? nextIndex : 0;
|
||||
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||
};
|
||||
|
||||
const findPrevious = () => {
|
||||
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
|
||||
return false;
|
||||
}
|
||||
const { selectionStart } = textareaRef.value;
|
||||
let targetIndex = searchMatchList.value.length - 1;
|
||||
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
|
||||
if (searchMatchList.value[index].start < selectionStart) {
|
||||
targetIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||
};
|
||||
|
||||
const onSearchNext = (shiftKey: boolean) => {
|
||||
if (shiftKey) {
|
||||
findPrevious();
|
||||
return;
|
||||
}
|
||||
findNext();
|
||||
};
|
||||
|
||||
const replaceSelection = (match: TextSearchMatch) => {
|
||||
const nextText = replaceMatchText(match.text, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||
currentValue.value = currentValue.value.slice(0, match.start) + nextText + currentValue.value.slice(match.end);
|
||||
emit("update:modelValue", currentValue.value);
|
||||
emit("change", currentValue.value);
|
||||
};
|
||||
|
||||
const replaceCurrent = () => {
|
||||
if (props.readonly || !textareaRef.value || !searchKeyword.value) {
|
||||
return false;
|
||||
}
|
||||
const { selectionStart, selectionEnd } = textareaRef.value;
|
||||
let currentMatchIndex = searchMatchList.value.findIndex((match) => (
|
||||
match.start === selectionStart && match.end === selectionEnd
|
||||
));
|
||||
if (0 > currentMatchIndex) {
|
||||
if (!findNext()) {
|
||||
return false;
|
||||
}
|
||||
currentMatchIndex = searchMatchIndex.value;
|
||||
}
|
||||
const currentMatch = searchMatchList.value[currentMatchIndex];
|
||||
if (!currentMatch) {
|
||||
return false;
|
||||
}
|
||||
replaceSelection(currentMatch);
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
const nextMatch = searchMatchList.value[currentMatchIndex] || searchMatchList.value[currentMatchIndex - 1];
|
||||
if (!nextMatch) {
|
||||
focus();
|
||||
return;
|
||||
}
|
||||
selectMatch(nextMatch, Math.min(currentMatchIndex, searchMatchList.value.length - 1));
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const replaceAll = () => {
|
||||
if (props.readonly || !searchKeyword.value) {
|
||||
return 0;
|
||||
}
|
||||
const result = replaceAllText(currentValue.value, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||
searchError.value = result.error;
|
||||
if (result.error || 1 > result.count) {
|
||||
refreshSearchMatchState();
|
||||
return 0;
|
||||
}
|
||||
currentValue.value = result.value;
|
||||
emit("update:modelValue", currentValue.value);
|
||||
emit("change", currentValue.value);
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
if (searchMatchList.value[0]) {
|
||||
selectMatch(searchMatchList.value[0], 0);
|
||||
return;
|
||||
}
|
||||
focus();
|
||||
});
|
||||
return result.count;
|
||||
};
|
||||
|
||||
const focus = () => {
|
||||
textareaRef.value?.focus();
|
||||
};
|
||||
|
||||
const blur = () => {
|
||||
textareaRef.value?.blur();
|
||||
};
|
||||
|
||||
onActivated(() => {
|
||||
nextTick(() => {
|
||||
applyScrollState();
|
||||
});
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
rootRef,
|
||||
textareaRef,
|
||||
focus,
|
||||
blur,
|
||||
getScrollState,
|
||||
setScrollState,
|
||||
findNext,
|
||||
findPrevious,
|
||||
replaceCurrent,
|
||||
replaceAll,
|
||||
openSearch,
|
||||
closeSearch
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,202 @@
|
||||
.tui-code-editor {
|
||||
width: calc(100% - 2px);
|
||||
min-width: 0;
|
||||
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border-radius: var(--td-radius-medium, 0);
|
||||
|
||||
.gutter {
|
||||
flex: none;
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
background: #F2F2F2E6;
|
||||
border-right: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||
|
||||
> span {
|
||||
width: 3.25rem;
|
||||
height: 1.5rem;
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||
line-height: 1.5rem;
|
||||
padding-right: .25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
top: .5rem;
|
||||
right: .5rem;
|
||||
gap: .5rem;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
padding: .5rem;
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
border-radius: var(--td-radius-default, .375rem);
|
||||
background: var(--td-bg-color-container, #fff);
|
||||
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .12);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 10rem;
|
||||
height: 2rem;
|
||||
padding: 0 .5rem;
|
||||
color: var(--td-text-color-primary, #111);
|
||||
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||
background: var(--td-bg-color-container, #fff);
|
||||
border-radius: var(--td-radius-default, .375rem);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
gap: .25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
height: 2rem;
|
||||
padding: 0 .625rem;
|
||||
color: var(--td-text-color-primary, #111);
|
||||
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||
background: var(--td-bg-color-container, #fff);
|
||||
border-radius: var(--td-radius-default, .375rem);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-container-hover, #f6f6f6);
|
||||
}
|
||||
}
|
||||
|
||||
.search-status {
|
||||
color: var(--td-text-color-secondary, #666);
|
||||
white-space: nowrap;
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.preview,
|
||||
.input {
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
tab-size: 4;
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
line-height: 1.5rem;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
.preview {
|
||||
top: 0;
|
||||
left: 0;
|
||||
border: none;
|
||||
position: absolute;
|
||||
word-break: normal;
|
||||
background: transparent;
|
||||
transition: none;
|
||||
white-space: pre;
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
display: block;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
text-shadow: none;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
top: 0;
|
||||
left: 0;
|
||||
color: transparent;
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
caret-color: var(--td-text-color-primary, #111);
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
-webkit-text-fill-color: var(--td-text-color-placeholder, #999);
|
||||
}
|
||||
|
||||
.input::selection {
|
||||
background: rgb(0 122 255 / 18%);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: .65;
|
||||
}
|
||||
|
||||
&.readonly {
|
||||
.preview {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tui-code-editor {
|
||||
border-color: var(--td-component-stroke, #434343);
|
||||
|
||||
.gutter {
|
||||
border-right-color: var(--td-component-stroke, #434343);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
background: var(--td-bg-color-container, #1f1f1f);
|
||||
border-color: var(--td-component-stroke, #434343);
|
||||
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .28);
|
||||
}
|
||||
|
||||
.search-input,
|
||||
.search-button {
|
||||
color: var(--td-text-color-primary, #F5F5F5);
|
||||
border-color: var(--td-component-stroke, #434343);
|
||||
background: var(--td-bg-color-container, #1f1f1f);
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
background: var(--td-bg-color-container-hover, #2b2b2b);
|
||||
}
|
||||
|
||||
.input {
|
||||
caret-color: var(--td-text-color-primary, #F5F5F5);
|
||||
}
|
||||
|
||||
.input::selection {
|
||||
background: rgb(64 153 255 / 28%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import view from "./index.vue";
|
||||
import { Toolkit } from "timi-web";
|
||||
|
||||
export const DurationPicker = Toolkit.withInstall(view);
|
||||
export default DurationPicker;
|
||||
@@ -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>
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import view from "./index.vue";
|
||||
import { Toolkit } from "timi-web";
|
||||
import type { NotifyDetail } from "timi-web/editor";
|
||||
|
||||
export type NotifyEditorExpose = {
|
||||
submit: () => Promise<NotifyDetail>;
|
||||
getPendingMediaCount: () => number;
|
||||
};
|
||||
|
||||
export const NotifyEditor = Toolkit.withInstall(view);
|
||||
export default NotifyEditor;
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<article-editor
|
||||
ref="articleEditorRef"
|
||||
:model-value="articleValue"
|
||||
:placeholder="placeholder"
|
||||
:upload-image="uploadImage"
|
||||
:upload-video="uploadVideo"
|
||||
:resolve-attachment-url="resolveAttachmentUrl"
|
||||
:resolve-temp-url="resolveTempUrl"
|
||||
@change="onArticleChange"
|
||||
@update:model-value="onArticleCommit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Article, ArticleContentType, Attachment, NotifyDetail, UploadResult } from "timi-web/editor";
|
||||
import type { ArticleEditorExpose } from "../article-editor";
|
||||
import { ArticleEditor } from "../article-editor";
|
||||
import { normalizeNotifyContentType } from "timi-web/editor";
|
||||
|
||||
defineOptions({
|
||||
name: "NotifyEditor"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: NotifyDetail;
|
||||
placeholder?: string;
|
||||
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
|
||||
resolveTempUrl?: (tempFileId: string) => string | Promise<string>;
|
||||
}>(), {
|
||||
modelValue: () => ({}),
|
||||
placeholder: "请输入通知内容"
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [value: NotifyDetail];
|
||||
"update:modelValue": [value: NotifyDetail];
|
||||
}>();
|
||||
|
||||
const articleEditorRef = ref<ArticleEditorExpose>();
|
||||
|
||||
const articleValue = computed<Article>(() => ({
|
||||
contentType: normalizeNotifyContentType(props.modelValue?.contentType) as ArticleContentType,
|
||||
content: props.modelValue?.content || "",
|
||||
attachmentList: props.modelValue?.attachmentList
|
||||
}));
|
||||
|
||||
function buildNotifyValue(value: Article): NotifyDetail {
|
||||
return {
|
||||
...(props.modelValue || {}),
|
||||
contentType: normalizeNotifyContentType(value.contentType),
|
||||
content: value.content || "",
|
||||
attachmentList: value.attachmentList || []
|
||||
};
|
||||
}
|
||||
|
||||
function onArticleChange(value: Article) {
|
||||
emit("change", buildNotifyValue(value));
|
||||
}
|
||||
|
||||
function onArticleCommit(value: Article) {
|
||||
emit("update:modelValue", buildNotifyValue(value));
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const value = await articleEditorRef.value?.submit();
|
||||
return buildNotifyValue(value || articleValue.value);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
submit,
|
||||
getPendingMediaCount: () => articleEditorRef.value?.getPendingMediaCount() || 0
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
import view from "./index.vue";
|
||||
import { Toolkit } from "timi-web";
|
||||
|
||||
export const NotifyView = Toolkit.withInstall(view);
|
||||
export default NotifyView;
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<article-view
|
||||
:article="articleValue"
|
||||
:empty-text="emptyText"
|
||||
:resolve-attachment-url="resolveAttachmentUrl"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Article, ArticleContentType, Attachment, NotifyDetail } from "timi-web/editor";
|
||||
import { ArticleView } from "../article-view";
|
||||
import { normalizeNotifyContentType } from "timi-web/editor";
|
||||
|
||||
defineOptions({
|
||||
name: "NotifyView"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
detail?: NotifyDetail;
|
||||
emptyText?: string;
|
||||
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
|
||||
}>(), {
|
||||
detail: () => ({}),
|
||||
emptyText: "暂无通知内容"
|
||||
});
|
||||
|
||||
const articleValue = computed<Article>(() => ({
|
||||
contentType: normalizeNotifyContentType(props.detail?.contentType) as ArticleContentType,
|
||||
content: props.detail?.content || "",
|
||||
attachmentList: props.detail?.attachmentList
|
||||
}));
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
.tp-content-theme() {
|
||||
color: var(--td-text-color-primary);
|
||||
line-height: 1.6;
|
||||
|
||||
p {
|
||||
margin: 0 0 .75rem;
|
||||
}
|
||||
|
||||
p:empty:before {
|
||||
content: "\00a0";
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 .75rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
margin: .75rem 0;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--td-component-border);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
|
||||
img,
|
||||
video,
|
||||
iframe {
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.tp-content-body {
|
||||
.tp-content-theme();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,19 @@
|
||||
import view from "./index.vue";
|
||||
import { Toolkit } from "timi-web";
|
||||
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||
|
||||
export * from "timi-web/editor";
|
||||
export type TiptapEditorExpose = {
|
||||
submit: () => Promise<string>;
|
||||
getPendingMediaCount: () => number;
|
||||
openSearch: (showReplace: boolean) => void;
|
||||
closeSearch: () => void;
|
||||
findNext: () => boolean;
|
||||
findPrevious: () => boolean;
|
||||
replaceCurrent: () => boolean;
|
||||
replaceAll: () => number;
|
||||
editor?: TiptapEditorInstance;
|
||||
};
|
||||
|
||||
export const TiptapEditor = Toolkit.withInstall(view);
|
||||
export default TiptapEditor;
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import view from "./index.vue";
|
||||
import { Toolkit } from "timi-web";
|
||||
|
||||
export const TiptapView = Toolkit.withInstall(view);
|
||||
export default TiptapView;
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"@/*": [
|
||||
"./examples/*"
|
||||
],
|
||||
"timi-web/editor": [
|
||||
"../timi-web/src/editor/index.ts"
|
||||
],
|
||||
"timi-tdesign-mobile": [
|
||||
"./src/index.ts"
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user