refactor editor

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