diff --git a/.vscode/settings.json b/.vscode/settings.json index d7aea92c2..11a5c2a58 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -181,6 +181,7 @@ "stylelint.customSyntax": "postcss-html", "stylelint.snippet": ["css", "less", "postcss", "scss", "vue"], + "js/ts.tsdk.path": "node_modules/typescript/lib", "js/ts.inlayHints.enumMemberValues.enabled": true, "js/ts.preferences.preferTypeOnlyAutoImports": true, "js/ts.preferences.includePackageJsonAutoImports": "on", @@ -240,7 +241,6 @@ "commentTranslate.multiLineMerge": true, "vue.server.hybridMode": true, "vitest.disableWorkspaceWarning": true, - "js/ts.tsdk.path": "node_modules/typescript/lib", "editor.linkedEditing": true, // 自动同步更改html标签, "vscodeCustomCodeColor.highlightValue": "v-access", // v-access显示的颜色 "vscodeCustomCodeColor.highlightValueColor": "#CCFFFF", diff --git a/apps/web-antd/src/adapter/component/index.ts b/apps/web-antd/src/adapter/component/index.ts index 11edf6b53..9dd9ad0ba 100644 --- a/apps/web-antd/src/adapter/component/index.ts +++ b/apps/web-antd/src/adapter/component/index.ts @@ -3,9 +3,37 @@ * 可用于 vben-form、vben-modal、vben-drawer 等组件使用, */ +import type { + AutoCompleteProps, + ButtonProps, + CascaderProps, + CheckboxGroupProps, + CheckboxProps, + DatePickerProps, + DividerProps, + InputNumberProps, + InputProps, + MentionsProps, + RadioGroupProps, + RadioProps, + RangePickerProps, + RateProps, + SelectProps, + SpaceProps, + SwitchProps, + TextAreaProps, + TimePickerProps, + TreeSelectProps, + UploadProps, +} from 'antdv-next'; + import type { Component } from 'vue'; -import type { BaseFormComponentType } from '@vben/common-ui'; +import type { + ApiComponentSharedProps, + BaseFormComponentType, + IconPickerProps, +} from '@vben/common-ui'; import type { Recordable } from '@vben/types'; import { computed, defineAsyncComponent, defineComponent, h, ref } from 'vue'; @@ -167,6 +195,39 @@ export type ComponentType = | 'Upload' | BaseFormComponentType; +/** + * 与 {@link ComponentType} 中注册的组件名一一对应,便于 Schema 上 `component` + `componentProps` 联动提示 + */ +export interface ComponentPropsMap { + ApiCascader: ApiComponentSharedProps & CascaderProps; + ApiSelect: ApiComponentSharedProps & SelectProps; + ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps; + AutoComplete: AutoCompleteProps; + Cascader: CascaderProps; + Checkbox: CheckboxProps; + CheckboxGroup: CheckboxGroupProps; + DatePicker: DatePickerProps; + DefaultButton: ButtonProps; + Divider: DividerProps; + IconPicker: IconPickerProps; + Input: InputProps; + InputNumber: InputNumberProps; + InputPassword: InputProps; + Mentions: MentionsProps; + PrimaryButton: ButtonProps; + Radio: RadioProps; + RadioGroup: RadioGroupProps; + RangePicker: RangePickerProps; + Rate: RateProps; + Select: SelectProps; + Space: SpaceProps; + Switch: SwitchProps; + Textarea: TextAreaProps; + TimePicker: TimePickerProps; + TreeSelect: TreeSelectProps; + Upload: UploadProps; +} + async function initComponentAdapter() { const components: Partial> = { // 如果你的组件体积比较大,可以使用异步加载 diff --git a/apps/web-antd/src/adapter/form.ts b/apps/web-antd/src/adapter/form.ts index 93b626210..73e9952d1 100644 --- a/apps/web-antd/src/adapter/form.ts +++ b/apps/web-antd/src/adapter/form.ts @@ -1,9 +1,9 @@ import type { + VbenFormProps as FormProps, VbenFormSchema as FormSchema, - VbenFormProps, } from '@vben/common-ui'; -import type { ComponentType } from './component'; +import type { ComponentPropsMap, ComponentType } from './component'; import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui'; import { $t } from '@vben/locales'; @@ -47,10 +47,10 @@ async function initSetupVbenForm() { }); } -const useVbenForm = useForm; +const useVbenForm = useForm; export { initSetupVbenForm, useVbenForm, z }; export type VbenFormSchema = FormSchema; -export type { VbenFormProps }; export type FormSchemaGetter = () => VbenFormSchema[]; +export type VbenFormProps = FormProps; diff --git a/apps/web-antd/src/adapter/vxe-table.ts b/apps/web-antd/src/adapter/vxe-table.ts index 3e759b8b5..b1798c30d 100644 --- a/apps/web-antd/src/adapter/vxe-table.ts +++ b/apps/web-antd/src/adapter/vxe-table.ts @@ -1,8 +1,13 @@ import type { VxeGridPropTypes } from '@vben/plugins/vxe-table'; +import type { ComponentPropsMap, ComponentType } from './component'; + import { h } from 'vue'; -import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table'; +import { + setupVbenVxeTable, + useVbenVxeGrid as useGrid, +} from '@vben/plugins/vxe-table'; import { Button, Image } from 'antdv-next'; @@ -103,7 +108,9 @@ setupVbenVxeTable({ useVbenForm, }); -export { useVbenVxeGrid }; +export const useVbenVxeGrid = >( + ...rest: Parameters> +) => useGrid(...rest); export type * from '@vben/plugins/vxe-table'; diff --git a/apps/web-antd/src/views/演示使用自行删除/form/index.vue b/apps/web-antd/src/views/演示使用自行删除/form/index.vue index 3cf2aebc6..1953de11c 100644 --- a/apps/web-antd/src/views/演示使用自行删除/form/index.vue +++ b/apps/web-antd/src/views/演示使用自行删除/form/index.vue @@ -381,6 +381,12 @@ function handleSetFormValue() { timePicker: dayjs('2022-01-01 12:00:00'), treeSelect: 'leaf1', username: '1', + richEditor: ` +

Vben Tiptap

+

这个编辑器已经被封装在 packages/effects/plugins/src/tiptap 中。

+

你可以直接在各个 app 里通过 @vben/plugins/tiptap 引入。

+
默认内置 StarterKit、Underline、TextAlign、Placeholder。
+ `, }); // 设置单个表单值 diff --git a/internal/lint-configs/eslint-config/src/configs/javascript.ts b/internal/lint-configs/eslint-config/src/configs/javascript.ts index 2019ecdd2..bd9bd852b 100644 --- a/internal/lint-configs/eslint-config/src/configs/javascript.ts +++ b/internal/lint-configs/eslint-config/src/configs/javascript.ts @@ -104,6 +104,8 @@ export async function javascript(): Promise { 'keyword-spacing': 'off', 'no-control-regex': 'error', 'no-empty-function': 'off', + 'no-octal': 'error', + 'no-octal-escape': 'error', 'no-restricted-properties': [ 'error', { @@ -136,8 +138,32 @@ export async function javascript(): Promise { 'TSEnumDeclaration[const=true]', 'TSExportAssignment', ], + 'no-undef-init': 'error', 'no-undef': 'off', 'no-unreachable-loop': 'error', + 'object-shorthand': [ + 'error', + 'always', + { + avoidQuotes: true, + ignoreConstructors: false, + }, + ], + 'one-var': ['error', { initialized: 'never' }], + 'prefer-arrow-callback': [ + 'error', + { + allowNamedFunctions: false, + allowUnboundThis: true, + }, + ], + 'prefer-regex-literals': [ + 'error', + { + disallowRedundantWrapping: true, + }, + ], + 'spaced-comment': 'error', 'space-before-function-paren': 'off', 'unused-imports/no-unused-imports': 'error', diff --git a/internal/lint-configs/oxlint-config/src/configs/javascript.ts b/internal/lint-configs/oxlint-config/src/configs/javascript.ts index 4ea37891c..4352c8017 100644 --- a/internal/lint-configs/oxlint-config/src/configs/javascript.ts +++ b/internal/lint-configs/oxlint-config/src/configs/javascript.ts @@ -46,13 +46,12 @@ const javascript: OxlintConfig = { 'no-empty': ['error', { allowEmptyCatch: true }], 'no-fallthrough': 'error', 'no-new-func': 'error', - 'no-new-object': 'error', - 'no-new-symbol': 'error', + 'no-object-constructor': 'error', + 'no-new-native-nonconstructor': 'error', 'no-labels': ['error', { allowLoop: false, allowSwitch: false }], 'no-lone-blocks': 'error', 'no-multi-str': 'error', - 'no-octal': 'error', - 'no-octal-escape': 'error', + 'no-nonoctal-decimal-escape': 'error', 'no-proto': 'error', 'no-prototype-builtins': 'error', 'no-redeclare': ['error', { builtinGlobals: false }], @@ -69,7 +68,6 @@ const javascript: OxlintConfig = { ], 'no-template-curly-in-string': 'error', 'no-throw-literal': 'error', - 'no-undef-init': 'error', 'no-unused-expressions': [ 'error', { @@ -98,15 +96,6 @@ const javascript: OxlintConfig = { 'no-useless-computed-key': 'error', 'no-useless-constructor': 'error', 'no-useless-return': 'error', - 'object-shorthand': [ - 'error', - 'always', - { - avoidQuotes: true, - ignoreConstructors: false, - }, - ], - 'one-var': ['error', { initialized: 'never' }], 'prefer-const': [ 'error', { @@ -114,25 +103,11 @@ const javascript: OxlintConfig = { ignoreReadBeforeAssign: true, }, ], - 'eslint/prefer-arrow-callback': [ - 'error', - { - allowNamedFunctions: false, - allowUnboundThis: true, - }, - ], 'prefer-exponentiation-operator': 'error', 'prefer-promise-reject-errors': 'error', - 'eslint/prefer-regex-literals': [ - 'error', - { - disallowRedundantWrapping: true, - }, - ], 'prefer-rest-params': 'error', 'prefer-spread': 'error', 'prefer-template': 'error', - 'spaced-comment': 'error', 'symbol-description': 'error', 'unicode-bom': ['error', 'never'], 'use-isnan': [ diff --git a/internal/lint-configs/oxlint-config/src/configs/test.ts b/internal/lint-configs/oxlint-config/src/configs/test.ts index a7470eb6a..9a5441a4c 100644 --- a/internal/lint-configs/oxlint-config/src/configs/test.ts +++ b/internal/lint-configs/oxlint-config/src/configs/test.ts @@ -17,6 +17,7 @@ const test: OxlintConfig = { 'vitest/no-import-node-test': 'error', 'vitest/prefer-hooks-in-order': 'error', 'vitest/prefer-lowercase-title': 'error', + 'vitest/require-mock-type-parameters': 'off', }, }; diff --git a/package.json b/package.json index 428dd0fe1..b7a212d5b 100644 --- a/package.json +++ b/package.json @@ -98,5 +98,5 @@ "node": "^20.19.0 || ^22.18.0 || ^24.0.0", "pnpm": ">=10.0.0" }, - "packageManager": "pnpm@10.32.1" + "packageManager": "pnpm@10.33.0" } diff --git a/packages/@core/base/icons/src/lucide.ts b/packages/@core/base/icons/src/lucide.ts index 973251317..ce7e930e6 100644 --- a/packages/@core/base/icons/src/lucide.ts +++ b/packages/@core/base/icons/src/lucide.ts @@ -1,4 +1,7 @@ export { + TextAlignCenter as AlignCenter, + TextAlignStart as AlignLeft, + TextAlignEnd as AlignRight, ArrowDown, ArrowLeft, ArrowLeftToLine, @@ -7,6 +10,7 @@ export { ArrowUp, ArrowUpToLine, Bell, + Bold, BookOpenText, Check, ChevronDown, @@ -22,6 +26,7 @@ export { Copy, CornerDownLeft, Ellipsis, + Eraser, Expand, ExternalLink, Eye, @@ -32,12 +37,20 @@ export { Grid, Grip, GripVertical, + Heading1, + Heading2, + Highlighter, Menu as IconDefault, + ImagePlus, Inbox, Info, InspectionPanel, + Italic, Languages, LayoutGrid, + Link2, + List, + ListOrdered, LoaderCircle, LockKeyhole, LogOut, @@ -46,15 +59,19 @@ export { ArrowRightFromLine as MdiMenuClose, ArrowLeftFromLine as MdiMenuOpen, Menu, + MessageSquareCode, Minimize, Minimize2, MoonStar, + Paintbrush, Palette, PanelLeft, PanelRight, Pin, PinOff, Plus, + Redo2, + RemoveFormatting, RotateCw, Search, SearchX, @@ -62,10 +79,16 @@ export { Shrink, Square, SquareCheckBig, + SquareCode, SquareMinus, + Strikethrough, Sun, SunMoon, SwatchBook, + TextQuote, + Underline, + Undo2, + Unlink2, UserRoundPen, X, } from 'lucide-vue-next'; diff --git a/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue b/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue index 3934377a8..004a5ee81 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue +++ b/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue @@ -1,7 +1,11 @@ + + diff --git a/packages/effects/plugins/src/tiptap/style.css b/packages/effects/plugins/src/tiptap/style.css new file mode 100644 index 000000000..875556cec --- /dev/null +++ b/packages/effects/plugins/src/tiptap/style.css @@ -0,0 +1,56 @@ +@reference "@vben/tailwind-config/theme"; + +.vben-tiptap-content > * + * { + @apply mt-3; +} + +.vben-tiptap-content h1 { + @apply text-2xl font-bold leading-[1.4]; +} + +.vben-tiptap-content h2 { + @apply text-xl font-bold leading-[1.45]; +} + +.vben-tiptap-content h3 { + @apply text-lg font-semibold leading-[1.5]; +} + +.vben-tiptap-content h4 { + @apply text-base font-semibold leading-[1.55]; +} + +.vben-tiptap-content ul { + @apply list-disc pl-6; +} + +.vben-tiptap-content ol { + @apply list-decimal pl-6; +} + +.vben-tiptap-content blockquote { + @apply border-l-4 border-primary pl-4 text-muted-foreground; +} + +.vben-tiptap-content a { + @apply text-primary underline decoration-1 underline-offset-[3px]; +} + +.vben-tiptap-content code { + @apply rounded-[0.45rem] border border-border bg-secondary px-[0.35rem] py-[0.15rem] text-[0.9em] text-primary; +} + +.vben-tiptap-content pre { + @apply overflow-x-auto rounded-[0.9rem] border border-border bg-popover p-4 text-popover-foreground; +} + +.vben-tiptap-content pre code { + @apply border-none bg-transparent p-0 text-inherit; +} + +.vben-tiptap-content img, +.vben-tiptap-content .vben-tiptap__image { + @apply my-4 block h-auto rounded-2xl border border-border; + + max-width: min(100%, 640px); +} diff --git a/packages/effects/plugins/src/tiptap/tiptap.vue b/packages/effects/plugins/src/tiptap/tiptap.vue new file mode 100644 index 000000000..6f92f79ef --- /dev/null +++ b/packages/effects/plugins/src/tiptap/tiptap.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/packages/effects/plugins/src/tiptap/toolbar.ts b/packages/effects/plugins/src/tiptap/toolbar.ts new file mode 100644 index 000000000..c5a15967f --- /dev/null +++ b/packages/effects/plugins/src/tiptap/toolbar.ts @@ -0,0 +1,345 @@ +import type { Editor } from '@tiptap/vue-3'; + +import type { ToolbarAction, ToolbarMenuItem } from './types'; + +import { + AlignCenter, + AlignLeft, + AlignRight, + Bold, + Highlighter, + ImagePlus, + Italic, + Link2, + List, + ListOrdered, + MessageSquareCode, + Paintbrush, + Redo2, + RemoveFormatting, + SquareCode, + Strikethrough, + TextQuote, + Underline, + Undo2, + Unlink2, +} from '@vben/icons'; +import { $t } from '@vben/locales'; +import { COLOR_PRESETS } from '@vben/preferences'; + +import { prompt } from '@vben-core/popup-ui'; + +const headingLevels = [1, 2, 3, 4] as const; +const editorColorPresets = [ + 'hsl(var(--foreground))', + 'hsl(var(--warning))', + 'hsl(var(--success))', + 'hsl(var(--destructive))', + ...COLOR_PRESETS.map((item) => item.color), +]; +const editorHighlightPresets = [ + withAlpha('hsl(var(--warning))', 0.45), + withAlpha('hsl(var(--success))', 0.35), + withAlpha('hsl(var(--primary))', 0.3), + withAlpha('hsl(var(--destructive))', 0.3), + ...COLOR_PRESETS.map((item) => withAlpha(item.color, 0.4)), +]; + +function createHeadingMenuItems(): ToolbarMenuItem[] { + return [ + { + action: (editor) => editor.chain().focus().setParagraph().run(), + can: (editor) => editor.can().chain().focus().setParagraph().run(), + isActive: (editor) => editor.isActive('paragraph'), + label: $t('ui.tiptap.toolbar.paragraph'), + shortLabel: 'P', + }, + ...headingLevels.map((level) => ({ + action: (editor: Editor) => + editor.chain().focus().toggleHeading({ level }).run(), + can: (editor: Editor) => + editor.can().chain().focus().toggleHeading({ level }).run(), + isActive: (editor: Editor) => editor.isActive('heading', { level }), + label: $t(`ui.tiptap.toolbar.heading${level}`), + shortLabel: `H${level}`, + })), + ]; +} + +function getHeadingTriggerText(editor?: Editor) { + if (editor?.isActive('paragraph')) { + return 'P'; + } + + const level = headingLevels.find((headingLevel) => + editor?.isActive('heading', { level: headingLevel }), + ); + + return level ? `H${level}` : 'H'; +} + +function normalizeLinkUrl(url: string) { + if (/^(https?:|mailto:|tel:)/i.test(url)) { + return url; + } + + return `https://${url}`; +} + +function withAlpha(color: string, alpha: number) { + const normalizedAlpha = Math.min(Math.max(alpha, 0), 1); + const hslMatch = color.match(/^hsl\((.+)\)$/); + + if (!hslMatch) { + return color; + } + + return `hsl(${hslMatch[1]} / ${normalizedAlpha})`; +} + +async function handleLinkAction(editor: Editor) { + const currentHref = editor.getAttributes('link').href as string | undefined; + + let url: string | undefined; + + try { + url = await prompt({ + componentProps: { + placeholder: 'https://example.com', + }, + content: $t('ui.tiptap.prompts.link'), + defaultValue: currentHref ?? '', + }); + } catch { + return; + } + + const nextUrl = (url ?? '').trim(); + + if (!nextUrl) { + editor.chain().focus().extendMarkRange('link').unsetLink().run(); + return; + } + + editor + .chain() + .focus() + .extendMarkRange('link') + .setLink({ + href: normalizeLinkUrl(nextUrl), + }) + .run(); +} + +async function handleImageAction(editor: Editor) { + let url: string | undefined; + + try { + url = await prompt({ + componentProps: { + placeholder: 'https://example.com/image.png', + }, + content: $t('ui.tiptap.prompts.image'), + defaultValue: '', + }); + } catch { + return; + } + + const nextUrl = (url ?? '').trim(); + + if (!nextUrl) { + return; + } + + editor.chain().focus().setImage({ src: nextUrl }).run(); +} + +export function createToolbarGroups(): ToolbarAction[][] { + const headingMenuItems = createHeadingMenuItems(); + + return [ + [ + { + action: (editor) => editor.chain().focus().undo().run(), + can: (editor) => editor.can().chain().focus().undo().run(), + icon: Undo2, + label: $t('ui.tiptap.toolbar.undo'), + }, + { + action: (editor) => editor.chain().focus().redo().run(), + can: (editor) => editor.can().chain().focus().redo().run(), + icon: Redo2, + label: $t('ui.tiptap.toolbar.redo'), + }, + { + action: (editor) => + editor.chain().focus().clearNodes().unsetAllMarks().run(), + icon: RemoveFormatting, + label: $t('ui.tiptap.toolbar.clear'), + }, + ], + [ + { + action: (editor) => editor.chain().focus().toggleBold().run(), + active: { name: 'bold' }, + can: (editor) => editor.can().chain().focus().toggleBold().run(), + icon: Bold, + label: $t('ui.tiptap.toolbar.bold'), + }, + { + action: (editor) => editor.chain().focus().toggleItalic().run(), + active: { name: 'italic' }, + can: (editor) => editor.can().chain().focus().toggleItalic().run(), + icon: Italic, + label: $t('ui.tiptap.toolbar.italic'), + }, + { + action: (editor) => editor.chain().focus().toggleUnderline().run(), + active: { name: 'underline' }, + can: (editor) => editor.can().chain().focus().toggleUnderline().run(), + icon: Underline, + label: $t('ui.tiptap.toolbar.underline'), + }, + { + action: (editor) => editor.chain().focus().toggleStrike().run(), + active: { name: 'strike' }, + can: (editor) => editor.can().chain().focus().toggleStrike().run(), + icon: Strikethrough, + label: $t('ui.tiptap.toolbar.strike'), + }, + { + action: (editor) => editor.chain().focus().toggleCode().run(), + active: { name: 'code' }, + can: (editor) => editor.can().chain().focus().toggleCode().run(), + icon: SquareCode, + label: $t('ui.tiptap.toolbar.code'), + }, + ], + [ + { + action: () => {}, + can: (editor) => + headingMenuItems.some((item) => (item.can ? item.can(editor) : true)), + isActive: (editor) => + headingMenuItems.some((item) => item.isActive?.(editor)), + label: $t('ui.tiptap.toolbar.heading'), + menu: { + items: headingMenuItems, + }, + triggerText: (editor) => getHeadingTriggerText(editor), + }, + { + action: (editor) => editor.chain().focus().toggleBulletList().run(), + active: { name: 'bulletList' }, + can: (editor) => editor.can().chain().focus().toggleBulletList().run(), + icon: List, + label: $t('ui.tiptap.toolbar.bulletList'), + }, + { + action: (editor) => editor.chain().focus().toggleOrderedList().run(), + active: { name: 'orderedList' }, + can: (editor) => editor.can().chain().focus().toggleOrderedList().run(), + icon: ListOrdered, + label: $t('ui.tiptap.toolbar.orderedList'), + }, + { + action: (editor) => editor.chain().focus().toggleBlockquote().run(), + active: { name: 'blockquote' }, + can: (editor) => editor.can().chain().focus().toggleBlockquote().run(), + icon: TextQuote, + label: $t('ui.tiptap.toolbar.blockquote'), + }, + { + action: (editor) => editor.chain().focus().toggleCodeBlock().run(), + active: { name: 'codeBlock' }, + can: (editor) => editor.can().chain().focus().toggleCodeBlock().run(), + icon: MessageSquareCode, + label: $t('ui.tiptap.toolbar.codeBlock'), + }, + ], + [ + { + action: (editor) => handleLinkAction(editor), + active: { name: 'link' }, + can: (editor) => + editor.can().chain().focus().extendMarkRange('link').run(), + icon: Link2, + label: $t('ui.tiptap.toolbar.link'), + }, + { + action: (editor) => editor.chain().focus().unsetLink().run(), + can: (editor) => editor.can().chain().focus().unsetLink().run(), + icon: Unlink2, + isActive: (editor) => editor.isActive('link'), + label: $t('ui.tiptap.toolbar.unlink'), + }, + { + action: (editor) => handleImageAction(editor), + icon: ImagePlus, + label: $t('ui.tiptap.toolbar.image'), + }, + ], + [ + { + action: () => {}, + icon: Paintbrush, + indicatorColor: (editor) => + editor.getAttributes('textStyle').color as string | undefined, + isActive: (editor) => Boolean(editor.getAttributes('textStyle').color), + label: $t('ui.tiptap.toolbar.textColor'), + palette: { + apply: (editor, color) => + editor.chain().focus().setColor(color).run(), + clear: (editor) => editor.chain().focus().unsetColor().run(), + colors: editorColorPresets, + currentColor: (editor) => + editor.getAttributes('textStyle').color as string | undefined, + }, + }, + { + action: () => {}, + icon: Highlighter, + indicatorColor: (editor) => + (editor.getAttributes('highlight').color as string | undefined) ?? + '#fef08a', + isActive: (editor) => editor.isActive('highlight'), + label: $t('ui.tiptap.toolbar.highlightColor'), + palette: { + apply: (editor, color) => + editor.chain().focus().setHighlight({ color }).run(), + clear: (editor) => editor.chain().focus().unsetHighlight().run(), + colors: editorHighlightPresets, + currentColor: (editor) => + editor.getAttributes('highlight').color as string | undefined, + }, + }, + ], + [ + { + action: (editor) => editor.chain().focus().setTextAlign('left').run(), + can: (editor) => + editor.can().chain().focus().setTextAlign('left').run(), + icon: AlignLeft, + isActive: (editor) => editor.isActive({ textAlign: 'left' }), + label: $t('ui.tiptap.toolbar.alignLeft'), + }, + { + action: (editor) => editor.chain().focus().setTextAlign('center').run(), + can: (editor) => + editor.can().chain().focus().setTextAlign('center').run(), + icon: AlignCenter, + isActive: (editor) => editor.isActive({ textAlign: 'center' }), + label: $t('ui.tiptap.toolbar.alignCenter'), + }, + { + action: (editor) => editor.chain().focus().setTextAlign('right').run(), + can: (editor) => + editor.can().chain().focus().setTextAlign('right').run(), + icon: AlignRight, + isActive: (editor) => editor.isActive({ textAlign: 'right' }), + label: $t('ui.tiptap.toolbar.alignRight'), + }, + ], + ]; +} diff --git a/packages/effects/plugins/src/tiptap/types.ts b/packages/effects/plugins/src/tiptap/types.ts new file mode 100644 index 000000000..eda32a21e --- /dev/null +++ b/packages/effects/plugins/src/tiptap/types.ts @@ -0,0 +1,60 @@ +import type { Extensions, JSONContent } from '@tiptap/core'; +import type { Editor } from '@tiptap/vue-3'; + +import type { Component } from 'vue'; + +export interface TipTapProps { + editable?: boolean; + extensions?: Extensions; + minHeight?: number | string; + placeholder?: string; + previewable?: boolean; + toolbar?: boolean; +} + +export interface TipTapPreviewProps { + class?: any; + content?: string; + minHeight?: number | string; +} + +export interface VbenTiptapChangeEvent { + html: string; + json: JSONContent; + text: string; +} + +export interface VbenTiptapExtensionOptions { + placeholder?: string; +} + +export interface ToolbarAction { + action: (editor: Editor) => void; + active?: { + attrs?: Record; + name: string; + }; + can?: (editor: Editor) => boolean; + icon?: Component; + indicatorColor?: (editor: Editor) => string | undefined; + isActive?: (editor: Editor) => boolean; + label: string; + menu?: { + items: ToolbarMenuItem[]; + }; + palette?: { + apply: (editor: Editor, color: string) => void; + clear?: (editor: Editor) => void; + colors: string[]; + currentColor?: (editor: Editor) => string | undefined; + }; + triggerText?: ((editor?: Editor) => string) | string; +} + +export interface ToolbarMenuItem { + action: (editor: Editor) => void; + can?: (editor: Editor) => boolean; + isActive?: (editor: Editor) => boolean; + label: string; + shortLabel: string; +} diff --git a/packages/effects/plugins/src/tiptap/use-tiptap-toolbar.ts b/packages/effects/plugins/src/tiptap/use-tiptap-toolbar.ts new file mode 100644 index 000000000..3b2375f22 --- /dev/null +++ b/packages/effects/plugins/src/tiptap/use-tiptap-toolbar.ts @@ -0,0 +1,176 @@ +import type { Editor } from '@tiptap/vue-3'; + +import type { ShallowRef } from 'vue'; + +import type { ToolbarAction, ToolbarMenuItem } from './types'; + +import { cn } from '@vben-core/shared/utils'; + +interface UseTiptapToolbarOptions { + editable: () => boolean; + editor: Readonly>; +} + +export function useTiptapToolbar(options: UseTiptapToolbarOptions) { + const getEditor = () => options.editor.value; + + function getActionIndicatorColor(action: ToolbarAction) { + const currentEditor = getEditor(); + + if (!currentEditor || !action.indicatorColor) { + return undefined; + } + + return action.indicatorColor(currentEditor); + } + + function getPaletteCurrentColor(action: ToolbarAction) { + const currentEditor = getEditor(); + + if (!currentEditor || !action.palette?.currentColor) { + return undefined; + } + + return action.palette.currentColor(currentEditor); + } + + function canRunAction(action: ToolbarAction) { + const currentEditor = getEditor(); + + if (!currentEditor || !options.editable()) { + return false; + } + + return action.can ? action.can(currentEditor) : true; + } + + function canRunMenuItem(item: ToolbarMenuItem) { + const currentEditor = getEditor(); + + if (!currentEditor || !options.editable()) { + return false; + } + + return item.can ? item.can(currentEditor) : true; + } + + function isActionActive(action: ToolbarAction) { + const currentEditor = getEditor(); + + if (!currentEditor) { + return false; + } + + if (action.isActive) { + return action.isActive(currentEditor); + } + + if (!action.active) { + return false; + } + + return currentEditor.isActive(action.active.name, action.active.attrs); + } + + function isMenuItemActive(item: ToolbarMenuItem, currentEditor?: Editor) { + const targetEditor = currentEditor ?? getEditor(); + + if (!targetEditor || !item.isActive) { + return false; + } + + return item.isActive(targetEditor); + } + + function runAction(action: ToolbarAction) { + const currentEditor = getEditor(); + + if (!currentEditor || !options.editable()) { + return; + } + + if (action.menu || action.palette) { + return; + } + + action.action(currentEditor); + } + + function runMenuItem(item: ToolbarMenuItem) { + const currentEditor = getEditor(); + + if (!currentEditor || !options.editable()) { + return; + } + + item.action(currentEditor); + } + + function applyPaletteColor(action: ToolbarAction, color: string) { + const currentEditor = getEditor(); + + if (!currentEditor || !action.palette) { + return; + } + + action.palette.apply(currentEditor, color); + } + + function clearPaletteColor(action: ToolbarAction) { + const currentEditor = getEditor(); + + if (!currentEditor || !action.palette?.clear) { + return; + } + + action.palette.clear(currentEditor); + } + + function getToolbarButtonClass(action: ToolbarAction) { + return cn( + 'relative rounded-[10px] border border-transparent bg-transparent text-muted-foreground shadow-none', + 'transition-[transform,color,background-color,border-color,box-shadow] duration-200 ease-out', + 'enabled:hover:-translate-y-px enabled:hover:border-border disabled:opacity-45', + 'enabled:hover:bg-accent enabled:hover:text-foreground', + isActionActive(action) && + 'border-primary/30 bg-accent text-primary shadow-primary', + ); + } + + function getPaletteSwatchClass(action: ToolbarAction, color: string) { + return cn( + 'inline-flex size-8 items-center justify-center rounded-full border border-border', + 'shadow-accent', + 'transition-[transform,box-shadow,border-color] duration-200 ease-out', + 'hover:-translate-y-px hover:scale-[1.04]', + getPaletteCurrentColor(action) === color && + 'border-primary shadow-primary', + ); + } + + function getMenuItemClass(item: ToolbarMenuItem) { + return cn( + 'flex items-center gap-2 rounded-lg p-2 text-left text-sm transition-colors', + 'disabled:cursor-not-allowed disabled:opacity-45', + isMenuItemActive(item) + ? 'bg-accent text-foreground' + : 'text-muted-foreground hover:bg-accent hover:text-foreground', + ); + } + + return { + applyPaletteColor, + canRunAction, + canRunMenuItem, + clearPaletteColor, + getActionIndicatorColor, + getMenuItemClass, + getPaletteCurrentColor, + getPaletteSwatchClass, + getToolbarButtonClass, + isActionActive, + isMenuItemActive, + runAction, + runMenuItem, + }; +} diff --git a/packages/effects/plugins/src/vxe-table/api.ts b/packages/effects/plugins/src/vxe-table/api.ts index ca896ae19..a2a6870ed 100644 --- a/packages/effects/plugins/src/vxe-table/api.ts +++ b/packages/effects/plugins/src/vxe-table/api.ts @@ -1,6 +1,9 @@ import type { VxeGridInstance } from 'vxe-table'; -import type { ExtendedFormApi } from '@vben-core/form-ui'; +import type { + BaseFormComponentType, + ExtendedFormApi, +} from '@vben-core/form-ui'; import type { VxeGridProps } from './types'; @@ -26,25 +29,29 @@ function getDefaultState(): VxeGridProps { }; } -export class VxeGridApi = any> { +export class VxeGridApi< + T extends Record = any, + D extends BaseFormComponentType = BaseFormComponentType, + P extends Record = Record, +> { public formApi = {} as ExtendedFormApi; // private prevState: null | VxeGridProps = null; public grid = {} as VxeGridInstance; - public state: null | VxeGridProps = null; + public state: null | VxeGridProps = null; - public store: Store>; + public store: Store>; private isMounted = false; private stateHandler: StateHandler; - constructor(options: VxeGridProps = {}) { + constructor(options: VxeGridProps = {} as VxeGridProps) { const storeState = { ...options }; const defaultState = getDefaultState(); - this.store = new Store( - mergeWithArrayOverride(storeState, defaultState), + this.store = new Store>( + mergeWithArrayOverride(storeState, defaultState) as VxeGridProps, ); this.store.subscribe((state) => { @@ -82,7 +89,7 @@ export class VxeGridApi = any> { } } - setGridOptions(options: Partial) { + setGridOptions(options: Partial['gridOptions']>) { this.setState({ gridOptions: options, }); @@ -98,8 +105,8 @@ export class VxeGridApi = any> { setState( stateOrFn: - | ((prev: VxeGridProps) => Partial>) - | Partial>, + | ((prev: VxeGridProps) => Partial>) + | Partial>, ) { if (isFunction(stateOrFn)) { this.store.setState((prev) => { diff --git a/packages/effects/plugins/src/vxe-table/types.ts b/packages/effects/plugins/src/vxe-table/types.ts index 7ed82932d..f6f141fc7 100644 --- a/packages/effects/plugins/src/vxe-table/types.ts +++ b/packages/effects/plugins/src/vxe-table/types.ts @@ -41,6 +41,7 @@ export interface SeparatorOptions { export interface VxeGridProps< T extends Record = any, D extends BaseFormComponentType = BaseFormComponentType, + P extends Record = Record, > { /** * 数据 @@ -73,7 +74,7 @@ export interface VxeGridProps< /** * 表单配置 */ - formOptions?: VbenFormProps; + formOptions?: VbenFormProps; /** * 显示搜索表单 */ @@ -87,10 +88,11 @@ export interface VxeGridProps< export type ExtendedVxeGridApi< D extends Record = any, F extends BaseFormComponentType = BaseFormComponentType, -> = VxeGridApi & { - useStore: >>( - selector?: (state: NoInfer>) => T, - ) => Readonly>; + P extends Record = Record, +> = VxeGridApi & { + useStore: >>( + selector?: (state: NoInfer>) => S, + ) => Readonly>; }; export interface SetupVxeTable { diff --git a/packages/effects/plugins/src/vxe-table/use-vxe-grid.ts b/packages/effects/plugins/src/vxe-table/use-vxe-grid.ts index 6ab876934..219c00270 100644 --- a/packages/effects/plugins/src/vxe-table/use-vxe-grid.ts +++ b/packages/effects/plugins/src/vxe-table/use-vxe-grid.ts @@ -22,21 +22,35 @@ type FilteredSlots = { export function useVbenVxeGrid< T extends Record = any, D extends BaseFormComponentType = BaseFormComponentType, ->(options: VxeGridProps) { + P extends Record = Record, +>(options: VxeGridProps) { // const IS_REACTIVE = isReactive(options); - const api = new VxeGridApi(options); - const extendedApi: ExtendedVxeGridApi = api as ExtendedVxeGridApi; + const api = new VxeGridApi(options); + const extendedApi: ExtendedVxeGridApi = api as ExtendedVxeGridApi< + T, + D, + P + >; extendedApi.useStore = (selector) => { return useStore(api.store, selector); }; const Grid = defineComponent( - (props: VxeGridProps, { attrs, slots }) => { + (props: VxeGridProps, { attrs, slots }) => { onBeforeUnmount(() => { api.unmount(); }); - api.setState({ ...props, ...attrs }); - return () => h(VxeGrid, { ...props, ...attrs, api: extendedApi }, slots); + api.setState({ ...props, ...attrs } as Partial>); + return () => + h( + VxeGrid, + { + ...props, + ...attrs, + api: extendedApi as ExtendedVxeGridApi, + }, + slots, + ); }, { name: 'VbenVxeGrid', diff --git a/packages/locales/src/langs/en-US/ui.json b/packages/locales/src/langs/en-US/ui.json index 4085b2622..e8059f579 100644 --- a/packages/locales/src/langs/en-US/ui.json +++ b/packages/locales/src/langs/en-US/ui.json @@ -61,6 +61,42 @@ "cancel": "Cancel cropping", "errorTip": "Cropping error" }, + "tiptap": { + "placeholder": "Please enter content...", + "prompts": { + "image": "Enter image URL", + "link": "Enter link URL" + }, + "toolbar": { + "bold": "Bold", + "italic": "Italic", + "underline": "Underline", + "strike": "Strike", + "code": "Code", + "codeBlock": "Code Block", + "heading": "Heading", + "paragraph": "Paragraph", + "heading1": "H1", + "heading2": "H2", + "heading3": "H3", + "heading4": "H4", + "bulletList": "Bullets", + "orderedList": "Numbering", + "blockquote": "Quote", + "link": "Link", + "unlink": "Unlink", + "image": "Image", + "textColor": "Text Color", + "highlightColor": "Highlight Color", + "alignLeft": "Left", + "alignCenter": "Center", + "alignRight": "Right", + "preview": "Preview", + "undo": "Undo", + "redo": "Redo", + "clear": "Clear" + } + }, "fallback": { "pageNotFound": "Oops! Page Not Found", "pageNotFoundDesc": "Sorry, we couldn't find the page you were looking for.", diff --git a/packages/locales/src/langs/zh-CN/ui.json b/packages/locales/src/langs/zh-CN/ui.json index f324182fe..3bbccb897 100644 --- a/packages/locales/src/langs/zh-CN/ui.json +++ b/packages/locales/src/langs/zh-CN/ui.json @@ -61,6 +61,42 @@ "cancel": "取消裁剪", "errorTip": "裁剪错误" }, + "tiptap": { + "placeholder": "请输入内容...", + "prompts": { + "image": "请输入图片地址", + "link": "请输入链接地址" + }, + "toolbar": { + "bold": "加粗", + "italic": "斜体", + "underline": "下划线", + "strike": "删除线", + "code": "行内代码", + "codeBlock": "代码块", + "heading": "标题", + "paragraph": "正文", + "heading1": "标题1", + "heading2": "标题2", + "heading3": "标题3", + "heading4": "标题4", + "bulletList": "无序列表", + "orderedList": "有序列表", + "blockquote": "引用", + "link": "链接", + "unlink": "移除链接", + "image": "图片", + "textColor": "文字颜色", + "highlightColor": "高亮颜色", + "alignLeft": "左对齐", + "alignCenter": "居中", + "alignRight": "右对齐", + "preview": "预览", + "undo": "撤销", + "redo": "重做", + "clear": "清除" + } + }, "fallback": { "pageNotFound": "哎呀!未找到页面", "pageNotFoundDesc": "抱歉,我们无法找到您要找的页面。", diff --git a/playground/src/views/examples/tiptap/index.vue b/playground/src/views/examples/tiptap/index.vue new file mode 100644 index 000000000..009e18c7f --- /dev/null +++ b/playground/src/views/examples/tiptap/index.vue @@ -0,0 +1,39 @@ + + + diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c1b4ab2d4..11b3022ff 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,7 +40,7 @@ catalog: '@ctrl/tinycolor': ^4.2.0 '@eslint-community/eslint-plugin-eslint-comments': ^4.7.1 '@eslint/js': ^10.0.1 - '@iconify/json': ^2.2.454 + '@iconify/json': ^2.2.456 '@iconify/tailwind4': ^1.2.3 '@iconify/vue': ^5.0.0 '@intlify/core-base': ^11.3.0 @@ -48,13 +48,13 @@ catalog: '@jspm/generator': ^2.12.0 '@manypkg/get-packages': ^3.1.0 '@playwright/test': ^1.58.2 - '@pnpm/workspace.read-manifest': ^1000.3.0 - '@stylistic/stylelint-plugin': ^5.0.1 + '@pnpm/workspace.read-manifest': ^1000.3.1 + '@stylistic/stylelint-plugin': ^5.1.0 '@tailwindcss/typography': ^0.5.19 '@tailwindcss/vite': ^4.2.2 - '@tanstack/vue-store': ^0.9.2 + '@tanstack/vue-store': ^0.9.3 '@tinymce/tinymce-vue': ^6.0.1 - '@tsdown/css': ^0.21.4 + '@tsdown/css': ^0.21.7 '@types/archiver': ^7.0.0 '@types/html-minifier-terser': ^7.0.2 '@types/json-bigint': ^1.0.4 @@ -65,12 +65,12 @@ catalog: '@types/qrcode': ^1.5.6 '@types/qs': ^6.15.0 '@types/sortablejs': ^1.15.9 - '@typescript-eslint/eslint-plugin': ^8.57.1 - '@typescript-eslint/parser': ^8.57.1 + '@typescript-eslint/eslint-plugin': ^8.57.2 + '@typescript-eslint/parser': ^8.57.2 '@vee-validate/zod': ^4.15.1 '@vitejs/plugin-vue': ^6.0.5 '@vitejs/plugin-vue-jsx': ^5.1.5 - '@vue/shared': ^3.5.30 + '@vue/shared': ^3.5.31 '@vue/test-utils': ^2.4.6 '@vueuse/core': ^14.2.1 '@vueuse/integrations': ^14.2.1 @@ -78,7 +78,7 @@ catalog: alova: ^3.4.1 antdv-next: ^1.1.7 archiver: ^7.0.1 - axios: ^1.13.6 + axios: ^1.14.0 axios-mock-adapter: ^2.1.0 cac: ^7.0.0 chalk: ^5.6.2 @@ -107,7 +107,7 @@ catalog: eslint-plugin-n: ^17.24.0 eslint-plugin-perfectionist: ^5.7.0 eslint-plugin-pnpm: ^1.6.0 - eslint-plugin-unicorn: ^63.0.0 + eslint-plugin-unicorn: ^64.0.0 eslint-plugin-unused-imports: ^4.4.1 eslint-plugin-vue: ^10.8.0 eslint-plugin-yml: ^3.3.1 @@ -115,7 +115,7 @@ catalog: find-up: ^8.0.0 get-port: ^7.2.0 globals: ^17.4.0 - happy-dom: ^20.8.4 + happy-dom: ^20.8.9 html-minifier-terser: ^7.2.0 is-ci: ^4.1.0 json-bigint: ^1.0.0 @@ -127,9 +127,9 @@ catalog: nitropack: ^2.13.1 nprogress: ^0.2.0 ora: ^9.3.0 - oxfmt: ^0.41.0 - oxlint: ^1.56.0 - oxlint-tsgolint: ^0.17.1 + oxfmt: ^0.42.0 + oxlint: ^1.58.0 + oxlint-tsgolint: ^0.18.1 pinia: ^3.0.4 pinia-plugin-persistedstate: ^4.7.1 pkg-types: ^2.3.0 @@ -143,13 +143,13 @@ catalog: reka-ui: ^2.9.2 resolve.exports: ^2.0.3 rimraf: ^6.1.3 - rolldown: ^1.0.0-rc.10 + rolldown: ^1.0.0-rc.12 rollup-plugin-visualizer: ^7.0.1 sass: ^1.98.0 sass-embedded: ^1.98.0 secure-ls: ^2.0.0 sortablejs: ^1.15.7 - stylelint: ^17.5.0 + stylelint: ^17.6.0 stylelint-config-recess-order: ^7.7.0 stylelint-config-recommended: ^18.0.0 stylelint-config-recommended-scss: ^17.0.0 @@ -162,8 +162,8 @@ catalog: theme-colors: ^0.1.0 tinymce: 7.9.1 tippy.js: ^6.3.7 - tsdown: ^0.21.4 - turbo: ^2.8.20 + tsdown: ^0.21.7 + turbo: ^2.9.3 tw-animate-css: ^1.4.0 typescript: ^5.9.3 unplugin-dts: ^1.0.0-beta.6 @@ -172,7 +172,7 @@ catalog: vditor: 3.10.9 vee-validate: ^4.15.1 version-polling: ^1.3.3 - vite: ^8.0.1 + vite: ^8.0.3 vite-plugin-compression: ^0.5.1 vite-plugin-html: ^3.2.2 vite-plugin-lazy-import: ^1.0.7 @@ -188,8 +188,8 @@ catalog: vue-router: ^5.0.4 vue-tippy: ^6.7.1 vue-tsc: ^3.2.6 - vxe-pc-ui: ^4.13.13 - vxe-table: ^4.18.8 + vxe-pc-ui: ^4.13.20 + vxe-table: ^4.18.10 watermark-js-plus: ^1.6.3 yaml-eslint-parser: ^2.0.0 zod: ^3.25.76