refactor editor
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<t-popconfirm
|
||||
v-model:visible="visible"
|
||||
trigger="click"
|
||||
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||
placement="bottom"
|
||||
@visible-change="onVisibleChange"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<template #icon><span></span></template>
|
||||
<template #content>
|
||||
<t-input v-model="codeLanguage" placeholder="代码语言,可留空" />
|
||||
</template>
|
||||
<t-button v-popup="'代码块'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||
<t-icon name="code" />
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapCodeBlockButton"
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
editor?: TiptapEditorInstance;
|
||||
active: boolean;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const codeLanguage = ref("");
|
||||
|
||||
function onVisibleChange(nextVisible: boolean) {
|
||||
if (!nextVisible || !props.editor) {
|
||||
return;
|
||||
}
|
||||
codeLanguage.value = "";
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const language = codeLanguage.value.trim();
|
||||
if (!language) {
|
||||
props.editor.chain().focus().setCodeBlock().run();
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
props.editor.chain().focus().setCodeBlock({ language }).run();
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<t-popconfirm
|
||||
class="tiptap-iframe-button"
|
||||
v-model:visible="visible"
|
||||
trigger="click"
|
||||
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||
placement="bottom"
|
||||
@visible-change="onVisibleChange"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<template #icon><span></span></template>
|
||||
<template #content>
|
||||
<t-form class="popconfirm-content" label-align="left" label-width="auto">
|
||||
<t-form-item label="网页地址">
|
||||
<t-input v-model="iframeForm.src" placeholder="请输入网页地址" />
|
||||
</t-form-item>
|
||||
<t-form-item label="网页标题">
|
||||
<t-input v-model="iframeForm.title" placeholder="请输入网页标题,可留空" />
|
||||
</t-form-item>
|
||||
<t-form-item label="宽度">
|
||||
<t-input v-model="iframeForm.width" placeholder="请输入宽度,默认 100%" />
|
||||
</t-form-item>
|
||||
<t-form-item label="高度">
|
||||
<t-input v-model="iframeForm.height" placeholder="请输入高度,默认 28rem" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</template>
|
||||
<t-button v-popup="'插入内嵌网页'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||
<t-icon name="web" />
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { showPlatformMessage } from "../message";
|
||||
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapIframeButton"
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
editor?: TiptapEditorInstance;
|
||||
active: boolean;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const iframeForm = reactive({
|
||||
src: "",
|
||||
title: "",
|
||||
width: "100%",
|
||||
height: "28rem"
|
||||
});
|
||||
|
||||
function onVisibleChange(nextVisible: boolean) {
|
||||
if (!nextVisible || !props.editor) {
|
||||
return;
|
||||
}
|
||||
const attributes = props.editor.getAttributes("iframe");
|
||||
iframeForm.src = (attributes.src as string) || "https://";
|
||||
iframeForm.title = (attributes.title as string) || "";
|
||||
iframeForm.width = (attributes.width as string) || "100%";
|
||||
iframeForm.height = (attributes.height as string) || "28rem";
|
||||
}
|
||||
|
||||
function isValidHttpUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const src = iframeForm.src.trim();
|
||||
if (!src) {
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
if (!isValidHttpUrl(src)) {
|
||||
await showPlatformMessage("warning", "请输入有效的 http 或 https 地址");
|
||||
return;
|
||||
}
|
||||
props.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: "iframe",
|
||||
attrs: {
|
||||
src,
|
||||
title: iframeForm.title.trim() || "内嵌网页",
|
||||
width: iframeForm.width.trim() || "100%",
|
||||
height: iframeForm.height.trim() || "28rem"
|
||||
}
|
||||
})
|
||||
.run();
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.popconfirm-content {
|
||||
--td-comp-margin-xxl: .5rem;
|
||||
|
||||
padding: 0 .5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<t-popconfirm
|
||||
v-model:visible="visible"
|
||||
trigger="click"
|
||||
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||
placement="bottom"
|
||||
@visible-change="onVisibleChange"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<template #icon><span></span></template>
|
||||
<template #content>
|
||||
<div class="link-form">
|
||||
<t-input v-model="linkForm.href" placeholder="请输入链接地址" />
|
||||
<t-input v-model="linkForm.text" placeholder="请输入显示文本,可留空" />
|
||||
<t-checkbox v-model="linkForm.syncToHref">显示文本同步为链接</t-checkbox>
|
||||
</div>
|
||||
</template>
|
||||
<t-button v-popup="'插入链接'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||
<t-icon name="link-1" />
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapLinkButton"
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
editor?: TiptapEditorInstance;
|
||||
active: boolean;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const linkForm = reactive({
|
||||
href: "",
|
||||
text: "",
|
||||
syncToHref: false
|
||||
});
|
||||
|
||||
function onVisibleChange(nextVisible: boolean) {
|
||||
if (!nextVisible || !props.editor) {
|
||||
return;
|
||||
}
|
||||
const previousUrl = (props.editor.getAttributes("link").href as string) || "";
|
||||
const selectedText = props.editor.state.doc.textBetween(
|
||||
props.editor.state.selection.from,
|
||||
props.editor.state.selection.to,
|
||||
""
|
||||
);
|
||||
linkForm.href = previousUrl || "https://";
|
||||
linkForm.text = selectedText || "";
|
||||
linkForm.syncToHref = false;
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const href = linkForm.href.trim();
|
||||
if (!href) {
|
||||
props.editor.chain().focus().unsetLink().run();
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
const text = linkForm.syncToHref ? href : linkForm.text.trim();
|
||||
if (text) {
|
||||
props.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: "text",
|
||||
text,
|
||||
marks: [{ type: "link", attrs: { href } }]
|
||||
})
|
||||
.run();
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
props.editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.link-form {
|
||||
gap: .75rem;
|
||||
width: 20rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<t-popconfirm
|
||||
class="tiptap-media-button"
|
||||
v-model:visible="visible"
|
||||
trigger="click"
|
||||
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||
placement="bottom"
|
||||
@visible-change="onVisibleChange"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<template #icon><span></span></template>
|
||||
<template #content>
|
||||
<t-form class="popconfirm-content" label-align="left" label-width="auto">
|
||||
<t-form-item label="文件">
|
||||
<t-button size="small" variant="outline" @click="pickFile">
|
||||
<span
|
||||
class="file-name keep-text"
|
||||
v-text="selectedFile ? '重新选择' : `选择${typeLabel}`"
|
||||
/>
|
||||
</t-button>
|
||||
<span class="word-space gray" v-text="selectedFile?.name || `未选择${typeLabel}文件`" />
|
||||
</t-form-item>
|
||||
<t-form-item label="标题">
|
||||
<t-input v-model="mediaForm.title" :placeholder="`请输入${typeLabel}标题,可留空`" />
|
||||
</t-form-item>
|
||||
<t-form-item label="宽度">
|
||||
<t-input v-model="mediaForm.width" placeholder="请输入宽度,默认 100%" />
|
||||
</t-form-item>
|
||||
<t-form-item label="高度">
|
||||
<t-input v-model="mediaForm.height" placeholder="请输入高度,默认 auto" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</template>
|
||||
<t-button variant="outline" :theme="active ? 'primary' : 'default'" :loading="uploading">
|
||||
<t-icon :name="iconName" />
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
<input ref="fileInputRef" class="file-input" type="file" :accept="accept" @change="onFileChange" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { showPlatformMessage } from "../message";
|
||||
import type { TiptapEditorInstance, UploadResult } from "timi-web/editor";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapMediaButton"
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
editor?: TiptapEditorInstance;
|
||||
active: boolean;
|
||||
type: "image" | "video";
|
||||
upload?: (file: File) => Promise<UploadResult>;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const uploading = ref(false);
|
||||
const selectedFile = ref<File>();
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
const mediaForm = reactive({
|
||||
title: "",
|
||||
width: "100%",
|
||||
height: ""
|
||||
});
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
return props.type === "image" ? "图片" : "视频";
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
return props.type === "image" ? "image-add" : "video";
|
||||
});
|
||||
|
||||
const accept = computed(() => {
|
||||
return props.type === "image" ? "image/*" : "video/*";
|
||||
});
|
||||
|
||||
function buildDefaultHeight() {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
function pickFile(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
function onVisibleChange(nextVisible: boolean) {
|
||||
if (!nextVisible || !props.editor) {
|
||||
return;
|
||||
}
|
||||
selectedFile.value = undefined;
|
||||
const attributes = props.editor.getAttributes(props.type);
|
||||
mediaForm.title = (attributes.title as string) || "";
|
||||
mediaForm.width = (attributes.width as string) || "100%";
|
||||
mediaForm.height = (attributes.height as string) || buildDefaultHeight();
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
selectedFile.value = target.files?.[0];
|
||||
target.value = "";
|
||||
if (!selectedFile.value) {
|
||||
return;
|
||||
}
|
||||
if (!mediaForm.title.trim()) {
|
||||
mediaForm.title = selectedFile.value.name;
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
if (!props.upload) {
|
||||
await showPlatformMessage("warning", `${typeLabel.value}上传功能未启用`);
|
||||
return;
|
||||
}
|
||||
if (!selectedFile.value) {
|
||||
await showPlatformMessage("warning", `请选择${typeLabel.value}文件`);
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
try {
|
||||
const file = selectedFile.value;
|
||||
const result = await props.upload(file);
|
||||
const title = mediaForm.title.trim() || result.name || file.name;
|
||||
const attrs = {
|
||||
src: result.url,
|
||||
title,
|
||||
width: mediaForm.width.trim() || "100%",
|
||||
height: mediaForm.height.trim() || buildDefaultHeight(),
|
||||
attachmentId: result.id,
|
||||
tempFileId: result.id
|
||||
};
|
||||
if (props.type === "image") {
|
||||
props.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: attrs.src,
|
||||
alt: title,
|
||||
title
|
||||
})
|
||||
.updateAttributes("image", {
|
||||
attachmentId: attrs.attachmentId,
|
||||
tempFileId: attrs.tempFileId,
|
||||
width: attrs.width,
|
||||
height: attrs.height
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
props.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: "video",
|
||||
attrs
|
||||
})
|
||||
.run();
|
||||
}
|
||||
visible.value = false;
|
||||
} catch (error) {
|
||||
console.error(`上传${typeLabel.value}失败:`, error);
|
||||
await showPlatformMessage("error", `上传${typeLabel.value}失败`);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.popconfirm-content {
|
||||
--td-comp-margin-xxl: .5rem;
|
||||
|
||||
padding: 0 .5rem;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<t-popconfirm
|
||||
v-model:visible="visible"
|
||||
trigger="click"
|
||||
placement="bottom-right"
|
||||
:show-arrow="false"
|
||||
:confirm-btn="null"
|
||||
:cancel-btn="null"
|
||||
:popup-props="{ overlayClassName: 'tp-padding-pop' }"
|
||||
@visible-change="onVisibleChange"
|
||||
>
|
||||
<template #icon><span></span></template>
|
||||
<template #content>
|
||||
<div class="padding-form">
|
||||
<div class="item">
|
||||
<div class="head">
|
||||
<span class="label">上下内边距</span>
|
||||
<span class="value" v-text="formatPaddingValue(paddingTB)" />
|
||||
</div>
|
||||
<t-slider :min="0" :max="6" :step=".25" :value="paddingTB" @change="onPaddingTBChange" />
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="head">
|
||||
<span class="label">左右内边距</span>
|
||||
<span class="value" v-text="formatPaddingValue(paddingLR)" />
|
||||
</div>
|
||||
<t-slider :min="0" :max="16" :step=".25" :value="paddingLR" @change="onPaddingLRChange" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<t-button v-popup="'内边距'" variant="outline">
|
||||
<t-icon name="fullscreen-exit-1" />
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { formatPaddingValue, normalizeSliderValue } from "timi-web/editor";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapPaddingButton"
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
paddingTB: number;
|
||||
paddingLR: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:paddingTB": [value: number];
|
||||
"update:paddingLR": [value: number];
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
|
||||
function onVisibleChange(nextVisible: boolean) {
|
||||
if (nextVisible) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onPaddingTBChange(value: number | number[]) {
|
||||
emit("update:paddingTB", normalizeSliderValue(value, props.paddingTB));
|
||||
}
|
||||
|
||||
function onPaddingLRChange(value: number | number[]) {
|
||||
emit("update:paddingLR", normalizeSliderValue(value, props.paddingLR));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.padding-form {
|
||||
gap: .75rem;
|
||||
width: 18rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.item {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.label {
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<t-popconfirm
|
||||
v-model:visible="visible"
|
||||
trigger="click"
|
||||
placement="bottom"
|
||||
:show-arrow="false"
|
||||
:popup-props="{ overlayClassName: 'tp-table-pop' }"
|
||||
@visible-change="onVisibleChange"
|
||||
>
|
||||
<template #icon><span></span></template>
|
||||
<template #content>
|
||||
<div class="table-picker" @mouseleave="onReset">
|
||||
<div class="tip" v-text="`${hoverRow} × ${hoverCol}`" />
|
||||
<div class="grid">
|
||||
<button
|
||||
v-for="item in tablePickerItems"
|
||||
:key="item.key"
|
||||
class="cell"
|
||||
:class="{ active: item.active }"
|
||||
type="button"
|
||||
@mousemove="onHover(item.row, item.col)"
|
||||
@click="onSelect(item.row, item.col)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<t-button v-popup="'插入表格'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||
<t-icon name="table" />
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { TABLE_PICKER_SIZE } from "timi-web/editor";
|
||||
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapTableButton"
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
editor?: TiptapEditorInstance;
|
||||
active: boolean;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const hoverRow = ref(1);
|
||||
const hoverCol = ref(1);
|
||||
|
||||
const tablePickerItems = computed(() => {
|
||||
const items: { key: string; row: number; col: number; active: boolean }[] = [];
|
||||
for (let row = 1; row < TABLE_PICKER_SIZE + 1; row += 1) {
|
||||
for (let col = 1; col < TABLE_PICKER_SIZE + 1; col += 1) {
|
||||
items.push({
|
||||
key: `${row}-${col}`,
|
||||
row,
|
||||
col,
|
||||
active: row < hoverRow.value + 1 && col < hoverCol.value + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
function onVisibleChange(nextVisible: boolean) {
|
||||
if (!nextVisible) {
|
||||
return;
|
||||
}
|
||||
hoverRow.value = 1;
|
||||
hoverCol.value = 1;
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
hoverRow.value = 1;
|
||||
hoverCol.value = 1;
|
||||
}
|
||||
|
||||
function onHover(row: number, col: number) {
|
||||
hoverRow.value = row;
|
||||
hoverCol.value = col;
|
||||
}
|
||||
|
||||
function onSelect(row: number, col: number) {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
hoverRow.value = row;
|
||||
hoverCol.value = col;
|
||||
props.editor.chain().focus().insertTable({ rows: row, cols: col, withHeaderRow: true }).run();
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.table-picker {
|
||||
width: 10.5rem;
|
||||
|
||||
.tip {
|
||||
height: 1.5rem;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .75rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
gap: .125rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
|
||||
.cell {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--td-component-border);
|
||||
cursor: pointer;
|
||||
background: var(--td-bg-color-container);
|
||||
transition: background .15s ease, border-color .15s ease;
|
||||
|
||||
&.active {
|
||||
border-color: var(--td-brand-color);
|
||||
background: var(--td-brand-color-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,429 @@
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div class="left" :class="{ 'toolbar-disabled': sourceCodeMode }">
|
||||
<div class="fixed">
|
||||
<t-button v-popup="'撤销'" variant="outline" :disabled="!canExec('undo')" @click="run('undo')">
|
||||
<t-icon name="rollback" />
|
||||
</t-button>
|
||||
<t-button v-popup="'重做'" variant="outline" :disabled="!canExec('redo')" @click="run('redo')">
|
||||
<t-icon name="rollfront" />
|
||||
</t-button>
|
||||
<t-button v-popup="'清除格式'" variant="outline" @click="run('clear')">
|
||||
<t-icon name="clear-formatting" />
|
||||
</t-button>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<t-dropdown trigger="click" :options="headingOptions" :min-column-width="120" @click="onHeadingSelect">
|
||||
<t-button v-popup="'标题'" variant="outline" :theme="isHeadingActive ? 'primary' : 'default'">
|
||||
<t-icon name="text" />
|
||||
</t-button>
|
||||
</t-dropdown>
|
||||
<t-button v-popup="'加粗'" variant="outline" :theme="isActive('bold') ? 'primary' : 'default'" @click="run('bold')">
|
||||
<t-icon name="textformat-bold" />
|
||||
</t-button>
|
||||
<t-button v-popup="'斜体'" variant="outline" :theme="isActive('italic') ? 'primary' : 'default'" @click="run('italic')">
|
||||
<t-icon name="textformat-italic" />
|
||||
</t-button>
|
||||
<t-button v-popup="'下划线'" variant="outline" :theme="isActive('underline') ? 'primary' : 'default'" @click="run('underline')">
|
||||
<t-icon name="textformat-underline" />
|
||||
</t-button>
|
||||
<t-button v-popup="'删除线'" variant="outline" :theme="isActive('strike') ? 'primary' : 'default'" @click="run('strike')">
|
||||
<t-icon name="textformat-strikethrough" />
|
||||
</t-button>
|
||||
<t-button v-popup="'前景色'" variant="outline" @click="pickColor('text')">
|
||||
<t-icon name="textformat-color" />
|
||||
</t-button>
|
||||
<t-button v-popup="'背景色'" variant="outline" @click="pickColor('bg')">
|
||||
<t-icon name="fill-color-1" />
|
||||
</t-button>
|
||||
<t-button v-popup="'有序列表'" variant="outline" :theme="isActive('orderedList') ? 'primary' : 'default'" @click="run('orderedList')">
|
||||
<t-icon name="order-list" />
|
||||
</t-button>
|
||||
<tiptap-table-button :editor="editor" :active="isActive('table')" />
|
||||
<t-button v-popup="'无序列表'" variant="outline" :theme="isActive('bulletList') ? 'primary' : 'default'" @click="run('bulletList')">
|
||||
<t-icon name="bulletpoint" />
|
||||
</t-button>
|
||||
<t-button v-popup="'行内代码'" variant="outline" :theme="isActive('code') ? 'primary' : 'default'" @click="run('code')">
|
||||
<t-icon name="highlighted-block" />
|
||||
</t-button>
|
||||
<tiptap-code-block-button :editor="editor" :active="isActive('codeBlock')" />
|
||||
<t-button v-popup="'下标'" variant="outline" :theme="isActive('subscript') ? 'primary' : 'default'" @click="run('subscript')">
|
||||
<t-icon name="subscript" />
|
||||
</t-button>
|
||||
<t-button v-popup="'上标'" variant="outline" :theme="isActive('superscript') ? 'primary' : 'default'" @click="run('superscript')">
|
||||
<t-icon name="superscript" />
|
||||
</t-button>
|
||||
<t-button v-popup="'左对齐'" variant="outline" :theme="isAlignActive('left') ? 'primary' : 'default'" @click="setAlign('left')">
|
||||
<t-icon name="format-vertical-align-left" />
|
||||
</t-button>
|
||||
<t-button v-popup="'居中对齐'" variant="outline" :theme="isAlignActive('center') ? 'primary' : 'default'" @click="setAlign('center')">
|
||||
<t-icon name="format-vertical-align-center" />
|
||||
</t-button>
|
||||
<t-button v-popup="'右对齐'" variant="outline" :theme="isAlignActive('right') ? 'primary' : 'default'" @click="setAlign('right')">
|
||||
<t-icon name="format-vertical-align-right" />
|
||||
</t-button>
|
||||
<t-button v-popup="'两端对齐'" variant="outline" :theme="isAlignActive('justify') ? 'primary' : 'default'" @click="setAlign('justify')">
|
||||
<t-icon name="expand-horizontal" />
|
||||
</t-button>
|
||||
<t-button v-popup="'分割线'" variant="outline" @click="run('horizontalRule')">
|
||||
<t-icon name="divider-1" />
|
||||
</t-button>
|
||||
<tiptap-link-button :editor="editor" :active="isActive('link')" />
|
||||
<tiptap-media-button :editor="editor" :active="isActive('image')" type="image" :upload="uploadImage" />
|
||||
<tiptap-media-button :editor="editor" :active="isActive('video')" type="video" :upload="uploadVideo" />
|
||||
<tiptap-iframe-button :editor="editor" :active="isActive('iframe')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div v-if="showPaddingTool" class="right__group" :class="{ 'toolbar-disabled': sourceCodeMode }">
|
||||
<tiptap-padding-button
|
||||
:padding-t-b="paddingTB"
|
||||
:padding-l-r="paddingLR"
|
||||
@update:padding-t-b="emit('update:paddingTB', $event)"
|
||||
@update:padding-l-r="emit('update:paddingLR', $event)"
|
||||
/>
|
||||
</div>
|
||||
<t-button
|
||||
v-popup="sourceCodeMode ? '富文本' : '源码'"
|
||||
class="source-btn"
|
||||
variant="outline"
|
||||
:theme="sourceCodeMode ? 'primary' : 'default'"
|
||||
@click="emit('toggle-source-code')"
|
||||
>
|
||||
<t-icon v-if="sourceCodeMode" name="edit-1" />
|
||||
<t-icon v-else name="braces" />
|
||||
</t-button>
|
||||
</div>
|
||||
<input ref="textColorRef" class="hidden" type="color" :value="textColor" @input="onTextColorChange" />
|
||||
<input ref="bgColorRef" class="hidden" type="color" :value="bgColor" @input="onBgColorChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { AlignType, UploadResult, TiptapEditorInstance } from "timi-web/editor";
|
||||
import { buildHeadingOptions } from "timi-web/editor";
|
||||
import TiptapCodeBlockButton from "./TiptapCodeBlockButton.vue";
|
||||
import TiptapIframeButton from "./TiptapIframeButton.vue";
|
||||
import TiptapLinkButton from "./TiptapLinkButton.vue";
|
||||
import TiptapMediaButton from "./TiptapMediaButton.vue";
|
||||
import TiptapPaddingButton from "./TiptapPaddingButton.vue";
|
||||
import TiptapTableButton from "./TiptapTableButton.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapToolbar"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
editor?: TiptapEditorInstance;
|
||||
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||
paddingTB: number;
|
||||
paddingLR: number;
|
||||
showPaddingTool?: boolean;
|
||||
sourceCodeMode?: boolean;
|
||||
}>(), {
|
||||
showPaddingTool: true,
|
||||
sourceCodeMode: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:paddingTB": [value: number];
|
||||
"update:paddingLR": [value: number];
|
||||
"toggle-source-code": [];
|
||||
}>();
|
||||
|
||||
type RunType =
|
||||
| "undo"
|
||||
| "redo"
|
||||
| "bold"
|
||||
| "italic"
|
||||
| "underline"
|
||||
| "strike"
|
||||
| "orderedList"
|
||||
| "bulletList"
|
||||
| "code"
|
||||
| "subscript"
|
||||
| "superscript"
|
||||
| "horizontalRule"
|
||||
| "clear";
|
||||
|
||||
const textColorRef = ref<HTMLInputElement>();
|
||||
const bgColorRef = ref<HTMLInputElement>();
|
||||
const textColor = ref("#000000");
|
||||
const bgColor = ref("#ffff00");
|
||||
|
||||
const currentHeadingValue = computed(() => {
|
||||
if (!props.editor) {
|
||||
return "p";
|
||||
}
|
||||
for (let level = 1; level < 7; level += 1) {
|
||||
if (props.editor.isActive("heading", { level })) {
|
||||
return `h${level}`;
|
||||
}
|
||||
}
|
||||
return "p";
|
||||
});
|
||||
|
||||
const headingOptions = computed(() => {
|
||||
return buildHeadingOptions(currentHeadingValue.value);
|
||||
});
|
||||
|
||||
const isHeadingActive = computed(() => {
|
||||
return currentHeadingValue.value !== "p";
|
||||
});
|
||||
|
||||
function canExec(type: "undo" | "redo") {
|
||||
if (!props.editor) {
|
||||
return false;
|
||||
}
|
||||
switch (type) {
|
||||
case "undo":
|
||||
return props.editor.can().undo();
|
||||
case "redo":
|
||||
return props.editor.can().redo();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isActive(type: string) {
|
||||
if (!props.editor) {
|
||||
return false;
|
||||
}
|
||||
return props.editor.isActive(type);
|
||||
}
|
||||
|
||||
function isAlignActive(align: AlignType) {
|
||||
if (!props.editor) {
|
||||
return false;
|
||||
}
|
||||
return props.editor.isActive({ textAlign: align });
|
||||
}
|
||||
|
||||
function run(type: RunType) {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const chain = props.editor.chain().focus();
|
||||
switch (type) {
|
||||
case "undo":
|
||||
chain.undo().run();
|
||||
return;
|
||||
case "redo":
|
||||
chain.redo().run();
|
||||
return;
|
||||
case "bold":
|
||||
chain.toggleBold().run();
|
||||
return;
|
||||
case "italic":
|
||||
chain.toggleItalic().run();
|
||||
return;
|
||||
case "underline":
|
||||
chain.toggleUnderline().run();
|
||||
return;
|
||||
case "strike":
|
||||
chain.toggleStrike().run();
|
||||
return;
|
||||
case "orderedList":
|
||||
chain.toggleOrderedList().run();
|
||||
return;
|
||||
case "bulletList":
|
||||
chain.toggleBulletList().run();
|
||||
return;
|
||||
case "code":
|
||||
chain.toggleCode().run();
|
||||
return;
|
||||
case "subscript":
|
||||
chain.toggleSubscript().run();
|
||||
return;
|
||||
case "superscript":
|
||||
chain.toggleSuperscript().run();
|
||||
return;
|
||||
case "horizontalRule":
|
||||
chain.setHorizontalRule().run();
|
||||
return;
|
||||
case "clear":
|
||||
chain.clearNodes().unsetAllMarks().run();
|
||||
return;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
function setAlign(align: AlignType) {
|
||||
props.editor?.chain().focus().setTextAlign(align).run();
|
||||
}
|
||||
|
||||
function onHeadingSelect(option: { value?: string | number | Record<string, unknown> }) {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const value = `${option?.value || "p"}`;
|
||||
const chain = props.editor.chain().focus();
|
||||
if (value === "p") {
|
||||
chain.setParagraph().run();
|
||||
return;
|
||||
}
|
||||
const level = Number(value.replace("h", ""));
|
||||
if (0 < level && level < 7) {
|
||||
chain.setHeading({ level: level as 1 | 2 | 3 | 4 | 5 | 6 }).run();
|
||||
}
|
||||
}
|
||||
|
||||
function pickColor(type: "text" | "bg") {
|
||||
if (type === "text") {
|
||||
textColorRef.value?.click();
|
||||
return;
|
||||
}
|
||||
bgColorRef.value?.click();
|
||||
}
|
||||
|
||||
function onTextColorChange(event: Event) {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLInputElement;
|
||||
textColor.value = target.value;
|
||||
props.editor.chain().focus().setColor(target.value).run();
|
||||
}
|
||||
|
||||
function onBgColorChange(event: Event) {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLInputElement;
|
||||
bgColor.value = target.value;
|
||||
props.editor.chain().focus().setMark("textStyle", { backgroundColor: target.value }).run();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin-top: -1px;
|
||||
justify-content: space-between;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
background: var(--td-component-border);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
gap: 0;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
:deep(.t-button) {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
margin-left: -1px;
|
||||
border-radius: 0;
|
||||
--td-comp-paddingLR-l: 8px;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible,
|
||||
&.t-is-active,
|
||||
&.t-button--theme-primary {
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.fixed,
|
||||
.scroll {
|
||||
gap: 0;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.fixed {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
> * {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
:deep(.t-button:last-child) {
|
||||
margin-right: 3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: none;
|
||||
margin-right: -1px;
|
||||
|
||||
.right__group {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-disabled {
|
||||
opacity: .55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.source-btn {
|
||||
margin-left: -1px;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 48rem) {
|
||||
.toolbar {
|
||||
justify-content: flex-start;
|
||||
|
||||
.left {
|
||||
.scroll {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.tp-heading-option) {
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.heading-size(@name, @size, @weight) {
|
||||
:global(.tp-heading-option.@{name}) {
|
||||
font-size: @size;
|
||||
font-weight: @weight;
|
||||
}
|
||||
}
|
||||
|
||||
.heading-size(p, 1rem, 400);
|
||||
.heading-size(h1, 2rem, 700);
|
||||
.heading-size(h2, 1.5rem, 700);
|
||||
.heading-size(h3, 1.17rem, 700);
|
||||
.heading-size(h4, 1rem, 700);
|
||||
.heading-size(h5, .83rem, 700);
|
||||
.heading-size(h6, .67rem, 700);
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
import view from "./index.vue";
|
||||
import { Toolkit } from "timi-web";
|
||||
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||
|
||||
export * from "timi-web/editor";
|
||||
export type TiptapEditorExpose = {
|
||||
submit: () => Promise<string>;
|
||||
getPendingMediaCount: () => number;
|
||||
openSearch: (showReplace: boolean) => void;
|
||||
closeSearch: () => void;
|
||||
findNext: () => boolean;
|
||||
findPrevious: () => boolean;
|
||||
replaceCurrent: () => boolean;
|
||||
replaceAll: () => number;
|
||||
editor?: TiptapEditorInstance;
|
||||
};
|
||||
|
||||
export const TiptapEditor = Toolkit.withInstall(view);
|
||||
export default TiptapEditor;
|
||||
@@ -0,0 +1,624 @@
|
||||
<template>
|
||||
<div class="tp-editor">
|
||||
<tiptap-toolbar
|
||||
:editor="editor || undefined"
|
||||
:padding-t-b="articlePadding.paddingTB"
|
||||
:padding-l-r="articlePadding.paddingLR"
|
||||
:show-padding-tool="showPaddingTool"
|
||||
:source-code-mode="sourceCodeMode"
|
||||
:upload-image="handleInsertImage"
|
||||
:upload-video="handleInsertVideo"
|
||||
@update:padding-t-b="articlePadding.paddingTB = $event"
|
||||
@update:padding-l-r="articlePadding.paddingLR = $event"
|
||||
@toggle-source-code="toggleSourceCodeMode"
|
||||
/>
|
||||
<div class="content">
|
||||
<code-editor
|
||||
v-if="sourceCodeMode"
|
||||
ref="sourceEditorRef"
|
||||
v-model="sourceCode"
|
||||
class="source-editor"
|
||||
language="html"
|
||||
placeholder="请输入 HTML 源码"
|
||||
/>
|
||||
<div v-else-if="editor" class="value-wrap">
|
||||
<editor-search-bar
|
||||
ref="searchBarRef"
|
||||
:visible="searchVisible"
|
||||
:expanded="replaceExpanded"
|
||||
:keyword="searchKeyword"
|
||||
:replace-value="replaceKeyword"
|
||||
:status-error="searchError"
|
||||
:match-index="searchMatchIndex"
|
||||
:match-count="searchMatchList.length"
|
||||
:case-sensitive="searchOptions.caseSensitive"
|
||||
:use-regex="searchOptions.useRegex"
|
||||
@update:expanded="replaceExpanded = $event"
|
||||
@update:keyword="searchKeyword = $event"
|
||||
@update:replace-value="replaceKeyword = $event"
|
||||
@update:case-sensitive="searchOptions.caseSensitive = $event"
|
||||
@update:use-regex="searchOptions.useRegex = $event"
|
||||
@previous="findPrevious"
|
||||
@next="onSearchNext"
|
||||
@replace="replaceCurrent"
|
||||
@replace-all="replaceAll"
|
||||
@close="closeSearch"
|
||||
/>
|
||||
<editor-content
|
||||
class="value"
|
||||
:style="editorBodyStyle"
|
||||
:editor="editor"
|
||||
:spellcheck="false"
|
||||
@keydown.capture="onRichEditorKeydown"
|
||||
/>
|
||||
</div>
|
||||
<t-empty v-else description="编辑器初始化中" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import CodeEditor from "../code-editor/index.vue";
|
||||
import EditorSearchBar from "../editor-search/index.vue";
|
||||
import {
|
||||
buildSearchRegex,
|
||||
replaceMatchText,
|
||||
type TextSearchOptions
|
||||
} from "timi-web/editor";
|
||||
import { Toolkit } from "timi-web";
|
||||
import type { PendingMediaItem, UploadMediaType, UploadResult, TiptapEditorInstance } from "timi-web/editor";
|
||||
import TiptapToolbar from "./TiptapToolbar.vue";
|
||||
import { useTiptapEditor } from "timi-web/editor";
|
||||
import {
|
||||
ATTACHMENT_ID_ATTR,
|
||||
LOCAL_MEDIA_ID_PREFIX,
|
||||
TEMP_FILE_ID_ATTR,
|
||||
isLocalMediaId,
|
||||
normalizeContent
|
||||
} from "timi-web/editor";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapEditor"
|
||||
});
|
||||
|
||||
type EditorSearchBarExpose = {
|
||||
focusKeyword: (selectAll?: boolean) => void;
|
||||
focusReplace: (selectAll?: boolean) => void;
|
||||
};
|
||||
|
||||
type CodeEditorExpose = {
|
||||
openSearch: (showReplace: boolean) => void;
|
||||
};
|
||||
|
||||
type RichTextMatch = {
|
||||
from: number;
|
||||
to: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string;
|
||||
placeholder?: string;
|
||||
paddingTB?: number;
|
||||
paddingLR?: number;
|
||||
showPaddingTool?: boolean;
|
||||
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||
}>(), {
|
||||
modelValue: "",
|
||||
placeholder: "请输入内容",
|
||||
paddingTB: 1,
|
||||
paddingLR: 1.25,
|
||||
showPaddingTool: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
"change": [value: string];
|
||||
"update:paddingTB": [value: number];
|
||||
"update:paddingLR": [value: number];
|
||||
}>();
|
||||
|
||||
const { EditorContent, articlePadding, editor, editorBodyStyle } = useTiptapEditor(
|
||||
props,
|
||||
emit,
|
||||
(event, value) => {
|
||||
if (event === "update:paddingTB") {
|
||||
emit("update:paddingTB", value);
|
||||
return;
|
||||
}
|
||||
emit("update:paddingLR", value);
|
||||
}
|
||||
);
|
||||
const pendingMediaMap = reactive(new Map<string, PendingMediaItem>());
|
||||
const sourceCodeMode = ref(false);
|
||||
const sourceCode = ref("");
|
||||
const sourceEditorRef = ref<CodeEditorExpose>();
|
||||
const searchBarRef = ref<EditorSearchBarExpose>();
|
||||
const searchVisible = ref(false);
|
||||
const replaceExpanded = ref(false);
|
||||
const searchKeyword = ref("");
|
||||
const replaceKeyword = ref("");
|
||||
const searchError = ref("");
|
||||
const searchMatchIndex = ref(-1);
|
||||
const searchMatchList = ref<RichTextMatch[]>([]);
|
||||
const searchOptions = reactive<TextSearchOptions>({
|
||||
caseSensitive: false,
|
||||
useRegex: false
|
||||
});
|
||||
|
||||
|
||||
function buildLocalMediaId() {
|
||||
return `${LOCAL_MEDIA_ID_PREFIX}${Toolkit.uuid()}`;
|
||||
}
|
||||
|
||||
function revokeMediaUrl(url?: string) {
|
||||
if (url?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function removePendingMedia(id: string) {
|
||||
const item = pendingMediaMap.get(id);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
revokeMediaUrl(item.url);
|
||||
pendingMediaMap.delete(id);
|
||||
}
|
||||
|
||||
function clearPendingMedia() {
|
||||
Array.from(pendingMediaMap.keys()).forEach(removePendingMedia);
|
||||
}
|
||||
|
||||
function collectMediaIds(content?: string) {
|
||||
const rawContent = normalizeContent(content);
|
||||
if (!rawContent) {
|
||||
return new Set<string>();
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawContent, "text/html");
|
||||
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||
return new Set(
|
||||
mediaList
|
||||
.map(item => item.getAttribute(ATTACHMENT_ID_ATTR) || item.getAttribute(TEMP_FILE_ID_ATTR) || "")
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function syncPendingMedia(content?: string) {
|
||||
const mediaIdSet = collectMediaIds(content);
|
||||
Array.from(pendingMediaMap.keys()).forEach((id) => {
|
||||
if (!mediaIdSet.has(id)) {
|
||||
removePendingMedia(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleInsertMedia(file: File, type: UploadMediaType): Promise<UploadResult> {
|
||||
const id = buildLocalMediaId();
|
||||
const url = URL.createObjectURL(file);
|
||||
pendingMediaMap.set(id, {
|
||||
id,
|
||||
type,
|
||||
name: file.name,
|
||||
file,
|
||||
url
|
||||
});
|
||||
return {
|
||||
id,
|
||||
url,
|
||||
name: file.name
|
||||
};
|
||||
}
|
||||
|
||||
async function handleInsertImage(file: File) {
|
||||
return handleInsertMedia(file, "image");
|
||||
}
|
||||
|
||||
async function handleInsertVideo(file: File) {
|
||||
return handleInsertMedia(file, "video");
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const instance = editor.value;
|
||||
if (!instance) {
|
||||
return normalizeContent(props.modelValue);
|
||||
}
|
||||
const rawContent = normalizeContent(instance.getHTML());
|
||||
if (!rawContent) {
|
||||
clearPendingMedia();
|
||||
return "";
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawContent, "text/html");
|
||||
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||
const uploadedMediaIdList: string[] = [];
|
||||
|
||||
for (const media of mediaList) {
|
||||
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || media.getAttribute(TEMP_FILE_ID_ATTR) || "";
|
||||
if (!isLocalMediaId(attachmentId)) {
|
||||
continue;
|
||||
}
|
||||
const pendingMedia = pendingMediaMap.get(attachmentId);
|
||||
if (!pendingMedia) {
|
||||
continue;
|
||||
}
|
||||
const uploadFn = pendingMedia.type === "image" ? props.uploadImage : props.uploadVideo;
|
||||
if (!uploadFn) {
|
||||
throw new Error(`未配置${pendingMedia.type === "image" ? "图片" : "视频"}上传方法`);
|
||||
}
|
||||
const result = await uploadFn(pendingMedia.file);
|
||||
media.setAttribute("src", result.url);
|
||||
media.setAttribute(ATTACHMENT_ID_ATTR, result.id);
|
||||
media.setAttribute(TEMP_FILE_ID_ATTR, result.id);
|
||||
if (media.tagName.toLowerCase() === "img") {
|
||||
media.setAttribute("alt", result.name || pendingMedia.name);
|
||||
media.setAttribute("title", result.name || pendingMedia.name);
|
||||
} else {
|
||||
media.setAttribute("title", result.name || pendingMedia.name);
|
||||
}
|
||||
uploadedMediaIdList.push(attachmentId);
|
||||
}
|
||||
|
||||
const nextContent = normalizeContent(doc.body.innerHTML);
|
||||
if (nextContent !== rawContent) {
|
||||
instance.commands.setContent(nextContent || "<p></p>");
|
||||
}
|
||||
uploadedMediaIdList.forEach(removePendingMedia);
|
||||
syncPendingMedia(nextContent);
|
||||
return nextContent;
|
||||
}
|
||||
|
||||
function syncSourceCode(value?: string) {
|
||||
sourceCode.value = value || "";
|
||||
}
|
||||
|
||||
function onSourceCodeChange(value: string) {
|
||||
sourceCode.value = value;
|
||||
emit("change", value);
|
||||
emit("update:modelValue", value);
|
||||
}
|
||||
|
||||
function getSelectedEditorText() {
|
||||
const instance = editor.value;
|
||||
if (!instance) {
|
||||
return "";
|
||||
}
|
||||
const { from, to } = instance.state.selection;
|
||||
if (from === to) {
|
||||
return "";
|
||||
}
|
||||
return instance.state.doc.textBetween(from, to, "\n", "\n");
|
||||
}
|
||||
|
||||
function refreshSearchMatchState() {
|
||||
const instance = editor.value;
|
||||
if (!instance || sourceCodeMode.value) {
|
||||
searchError.value = "";
|
||||
searchMatchList.value = [];
|
||||
searchMatchIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
const { regex, error } = buildSearchRegex(searchKeyword.value, searchOptions, true);
|
||||
searchError.value = error;
|
||||
if (!regex || error || !searchKeyword.value) {
|
||||
searchMatchList.value = [];
|
||||
searchMatchIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
const matchList: RichTextMatch[] = [];
|
||||
instance.state.doc.descendants((node, pos) => {
|
||||
if (!node.isText || !node.text) {
|
||||
return;
|
||||
}
|
||||
regex.lastIndex = 0;
|
||||
let match = regex.exec(node.text);
|
||||
while (match) {
|
||||
if (!match[0]) {
|
||||
searchError.value = "正则不能匹配空文本";
|
||||
searchMatchList.value = [];
|
||||
searchMatchIndex.value = -1;
|
||||
return false;
|
||||
}
|
||||
matchList.push({
|
||||
from: pos + match.index,
|
||||
to: pos + match.index + match[0].length,
|
||||
text: match[0]
|
||||
});
|
||||
match = regex.exec(node.text);
|
||||
}
|
||||
});
|
||||
searchMatchList.value = matchList;
|
||||
const { from, to } = instance.state.selection;
|
||||
searchMatchIndex.value = matchList.findIndex((match) => match.from === from && match.to === to);
|
||||
if (0 > searchMatchIndex.value && 0 < matchList.length) {
|
||||
searchMatchIndex.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function openSearch(showReplace: boolean) {
|
||||
if (sourceCodeMode.value) {
|
||||
sourceEditorRef.value?.openSearch(showReplace);
|
||||
return;
|
||||
}
|
||||
searchVisible.value = true;
|
||||
replaceExpanded.value = showReplace;
|
||||
const selectedText = getSelectedEditorText();
|
||||
if (selectedText) {
|
||||
searchKeyword.value = selectedText;
|
||||
}
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
searchBarRef.value?.focusKeyword(true);
|
||||
});
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
searchVisible.value = false;
|
||||
replaceExpanded.value = false;
|
||||
editor.value?.commands.focus();
|
||||
}
|
||||
|
||||
function selectMatch(match: RichTextMatch, matchIndex: number) {
|
||||
const instance = editor.value;
|
||||
if (!instance) {
|
||||
return false;
|
||||
}
|
||||
instance.chain().focus().setTextSelection({ from: match.from, to: match.to }).scrollIntoView().run();
|
||||
searchMatchIndex.value = matchIndex;
|
||||
return true;
|
||||
}
|
||||
|
||||
function findNext() {
|
||||
const instance = editor.value;
|
||||
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
|
||||
return false;
|
||||
}
|
||||
const currentTo = instance.state.selection.to;
|
||||
const nextIndex = searchMatchList.value.findIndex((match) => match.from >= currentTo);
|
||||
const targetIndex = 0 <= nextIndex ? nextIndex : 0;
|
||||
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||
}
|
||||
|
||||
function findPrevious() {
|
||||
const instance = editor.value;
|
||||
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
|
||||
return false;
|
||||
}
|
||||
const currentFrom = instance.state.selection.from;
|
||||
let targetIndex = searchMatchList.value.length - 1;
|
||||
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
|
||||
if (searchMatchList.value[index].from < currentFrom) {
|
||||
targetIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||
}
|
||||
|
||||
function replaceCurrent() {
|
||||
const instance = editor.value;
|
||||
if (!instance || !searchKeyword.value) {
|
||||
return false;
|
||||
}
|
||||
let currentMatchIndex = searchMatchList.value.findIndex((match) => (
|
||||
match.from === instance.state.selection.from && match.to === instance.state.selection.to
|
||||
));
|
||||
if (0 > currentMatchIndex) {
|
||||
if (!findNext()) {
|
||||
return false;
|
||||
}
|
||||
currentMatchIndex = searchMatchIndex.value;
|
||||
}
|
||||
const currentMatch = searchMatchList.value[currentMatchIndex];
|
||||
if (!currentMatch) {
|
||||
return false;
|
||||
}
|
||||
const nextText = replaceMatchText(currentMatch.text, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||
const transaction = instance.state.tr.insertText(nextText, currentMatch.from, currentMatch.to);
|
||||
instance.view.dispatch(transaction);
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
const nextMatch = searchMatchList.value[currentMatchIndex] || searchMatchList.value[currentMatchIndex - 1];
|
||||
if (!nextMatch) {
|
||||
instance.commands.focus();
|
||||
return;
|
||||
}
|
||||
selectMatch(nextMatch, Math.min(currentMatchIndex, searchMatchList.value.length - 1));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function replaceAll() {
|
||||
const instance = editor.value;
|
||||
if (!instance || !searchKeyword.value) {
|
||||
return 0;
|
||||
}
|
||||
if (searchError.value || 1 > searchMatchList.value.length) {
|
||||
refreshSearchMatchState();
|
||||
return 0;
|
||||
}
|
||||
const replaceCount = searchMatchList.value.length;
|
||||
const transaction = instance.state.tr;
|
||||
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
|
||||
const match = searchMatchList.value[index];
|
||||
const nextText = replaceMatchText(match.text, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||
transaction.insertText(nextText, match.from, match.to);
|
||||
}
|
||||
instance.view.dispatch(transaction);
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
if (searchMatchList.value[0]) {
|
||||
selectMatch(searchMatchList.value[0], 0);
|
||||
return;
|
||||
}
|
||||
instance.commands.focus();
|
||||
});
|
||||
return replaceCount;
|
||||
}
|
||||
|
||||
function onSearchNext(shiftKey: boolean) {
|
||||
if (shiftKey) {
|
||||
findPrevious();
|
||||
return;
|
||||
}
|
||||
findNext();
|
||||
}
|
||||
|
||||
function onRichEditorKeydown(event: KeyboardEvent) {
|
||||
if (sourceCodeMode.value || event.altKey || !(event.ctrlKey || event.metaKey)) {
|
||||
return;
|
||||
}
|
||||
const key = event.key.toLowerCase();
|
||||
if ("f" === key) {
|
||||
event.preventDefault();
|
||||
openSearch(false);
|
||||
return;
|
||||
}
|
||||
if ("h" === key) {
|
||||
event.preventDefault();
|
||||
openSearch(true);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSourceCodeMode() {
|
||||
const nextMode = !sourceCodeMode.value;
|
||||
closeSearch();
|
||||
if (nextMode) {
|
||||
syncSourceCode(normalizeContent(editor.value?.getHTML()) || props.modelValue);
|
||||
sourceCodeMode.value = true;
|
||||
return;
|
||||
}
|
||||
sourceCodeMode.value = false;
|
||||
const nextContent = normalizeContent(sourceCode.value);
|
||||
if (editor.value) {
|
||||
editor.value.commands.setContent(nextContent || "<p></p>");
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
syncPendingMedia(value);
|
||||
syncSourceCode(value);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(sourceCode, (value) => {
|
||||
if (!sourceCodeMode.value) {
|
||||
return;
|
||||
}
|
||||
onSourceCodeChange(value);
|
||||
});
|
||||
|
||||
watch([searchKeyword, () => searchOptions.caseSensitive, () => searchOptions.useRegex, sourceCodeMode], () => {
|
||||
refreshSearchMatchState();
|
||||
});
|
||||
|
||||
watch(() => editor.value?.state.selection.from, () => {
|
||||
if (!searchVisible.value || sourceCodeMode.value) {
|
||||
return;
|
||||
}
|
||||
refreshSearchMatchState();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPendingMedia();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
editor,
|
||||
submit,
|
||||
getPendingMediaCount: () => pendingMediaMap.size,
|
||||
openSearch,
|
||||
closeSearch,
|
||||
findNext,
|
||||
findPrevious,
|
||||
replaceCurrent,
|
||||
replaceAll
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tp-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--td-component-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: text;
|
||||
padding: 2rem 10rem;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
background: var(--td-bg-color-page);
|
||||
|
||||
.value-wrap {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
background: var(--td-bg-color-container);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: var(--td-radius-default);
|
||||
|
||||
:deep(.editor-body) {
|
||||
outline: none;
|
||||
padding: var(--tp-editor-padding-tb) var(--tp-editor-padding-lr);
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
pre[class*='language-'] > code {
|
||||
padding: .25rem .5rem;
|
||||
}
|
||||
|
||||
p.is-editor-empty:first-child:before {
|
||||
height: 0;
|
||||
float: left;
|
||||
color: var(--td-text-color-placeholder);
|
||||
content: attr(data-placeholder);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
iframe.tp-iframe {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.source-editor {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
background: var(--td-bg-color-container);
|
||||
border-radius: var(--td-radius-default);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 48rem) {
|
||||
.tp-editor {
|
||||
.content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user