add tiptap

This commit is contained in:
Timi
2026-08-21 01:11:23 +08:00
parent 763783b984
commit 68d9946b70
41 changed files with 5845 additions and 35 deletions
+271
View File
@@ -0,0 +1,271 @@
<template>
<div v-if="visible" class="tui-editor-search" @mousedown.stop>
<div class="row">
<t-button
class="toggle-btn"
shape="square"
size="small"
type="button"
variant="outline"
:title="expanded ? '收起替换' : '展开替换'"
@click="emit('update:expanded', !expanded)"
>
<t-icon :name="expanded ? 'chevron-down-s' : 'chevron-right-s'" />
</t-button>
<t-input
ref="keywordInputRef"
:value="keyword"
class="text-input"
placeholder="查找"
size="small"
@change="onKeywordChange"
@enter="onKeywordEnter"
@keydown="onKeywordKeydown"
>
<template #suffix-icon>
<t-button
shape="square"
size="small"
type="button"
variant="text"
:theme="caseSensitive ? 'primary' : 'default'"
title="大小写匹配"
@click="emit('update:case-sensitive', !caseSensitive)"
>
Aa
</t-button>
<t-button
shape="square"
size="small"
type="button"
variant="text"
:theme="useRegex ? 'primary' : 'default'"
title="正则搜索"
@click="emit('update:use-regex', !useRegex)"
>
.*
</t-button>
</template>
</t-input>
<span class="status" v-text="resolvedStatusText" />
<t-button shape="square" size="small" type="button" variant="text" title="上一个" @click="emit('previous')">
<t-icon name="arrow-up" />
</t-button>
<t-button shape="square" size="small" type="button" variant="text" title="下一个" @click="emit('next', false)">
<t-icon name="arrow-down" />
</t-button>
<t-button shape="square" size="small" type="button" variant="text" title="关闭" @click="emit('close')">
<t-icon name="close" />
</t-button>
</div>
<div v-if="expanded" class="row replace-row">
<div class="toggle-holder" />
<t-input
ref="replaceInputRef"
:value="replaceValue"
class="text-input"
placeholder="替换"
size="small"
@change="onReplaceChange"
@enter="emit('replace')"
@keydown="onReplaceKeydown"
/>
<div class="replace-actions">
<t-button size="small" type="button" theme="primary" @click="emit('replace')">替换</t-button>
<t-button size="small" type="button" variant="outline" @click="emit('replace-all')">全部替换</t-button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { ComponentPublicInstance } from "vue";
defineOptions({
name: "EditorSearchBar"
});
const props = withDefaults(defineProps<{
visible?: boolean;
expanded?: boolean;
keyword?: string;
replaceValue?: string;
statusText?: string;
statusError?: string;
matchIndex?: number;
matchCount?: number;
caseSensitive?: boolean;
useRegex?: boolean;
}>(), {
visible: false,
expanded: false,
keyword: "",
replaceValue: "",
statusText: undefined,
statusError: "",
matchIndex: -1,
matchCount: 0,
caseSensitive: false,
useRegex: false
});
const emit = defineEmits<{
(event: "update:expanded", value: boolean): void;
(event: "update:keyword", value: string): void;
(event: "update:replace-value", value: string): void;
(event: "update:case-sensitive", value: boolean): void;
(event: "update:use-regex", value: boolean): void;
(event: "previous"): void;
(event: "next", shiftKey: boolean): void;
(event: "replace"): void;
(event: "replace-all"): void;
(event: "close"): void;
}>();
type InputInstance = ComponentPublicInstance & {
$el: HTMLElement;
};
type InputKeyboardContext = {
e: KeyboardEvent;
};
const keywordInputRef = ref<InputInstance>();
const replaceInputRef = ref<InputInstance>();
const resolvedStatusText = computed(() => {
if (undefined !== props.statusText) {
return props.statusText;
}
if (props.statusError) {
return props.statusError;
}
if (!props.keyword) {
return "无结果";
}
if (1 > props.matchCount) {
return "未找到";
}
return `${props.matchIndex + 1} / ${props.matchCount}`;
});
const onKeywordChange = (value: string | number) => {
emit("update:keyword", `${value}`);
};
const onReplaceChange = (value: string | number) => {
emit("update:replace-value", `${value}`);
};
const onKeywordEnter = (_value: string | number, context: InputKeyboardContext) => {
emit("next", context.e.shiftKey);
};
const onKeywordKeydown = (_value: string | number, context: InputKeyboardContext) => {
if ("Escape" !== context.e.key) {
return;
}
context.e.preventDefault();
emit("close");
};
const onReplaceKeydown = (_value: string | number, context: InputKeyboardContext) => {
if ("Escape" !== context.e.key) {
return;
}
context.e.preventDefault();
emit("close");
};
const getNativeInput = (instance?: InputInstance) => instance?.$el.querySelector("input") || undefined;
const focusKeyword = (selectAll = false) => {
const input = getNativeInput(keywordInputRef.value);
input?.focus();
if (selectAll && input) {
input.selectionStart = 0;
input.selectionEnd = input.value.length;
}
};
const focusReplace = (selectAll = false) => {
const input = getNativeInput(replaceInputRef.value);
input?.focus();
if (selectAll && input) {
input.selectionStart = 0;
input.selectionEnd = input.value.length;
}
};
defineExpose({
focusKeyword,
focusReplace,
keywordInputRef,
replaceInputRef
});
</script>
<style lang="less" scoped>
.tui-editor-search {
top: .5rem;
right: .5rem;
gap: .375rem;
z-index: 3;
display: flex;
padding: .5rem;
position: absolute;
min-width: 20rem;
max-width: calc(100% - 1rem);
box-sizing: border-box;
border-radius: var(--td-radius-default, .375rem);
background: var(--td-bg-color-container, #fff);
border: 1px solid var(--td-component-stroke, #B8BBC9);
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .12);
flex-direction: column;
.row {
gap: .375rem;
display: flex;
min-width: 0;
align-items: center;
.toggle-holder {
flex: none;
width: 24px;
}
.toggle-btn {
flex: none;
}
.text-input {
width: 12rem;
:deep(.t-input) {
padding-right: 0;
}
}
.status {
width: 3rem;
color: var(--td-text-color-secondary, #666);
font-size: .875rem;
white-space: nowrap;
}
}
.replace-actions {
gap: .375rem;
display: flex;
align-items: center;
}
}
@media (prefers-color-scheme: dark) {
.tui-editor-search {
background: var(--td-bg-color-container, #1f1f1f);
border-color: var(--td-component-stroke, #434343);
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .28);
}
}
</style>