@lark-apaas/coding-template-nestjs-react-fullstack 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +41 -0
  3. package/hooks/after-install.mjs +3 -0
  4. package/hooks/after-render.mjs +3 -0
  5. package/hooks/before-render.mjs +3 -0
  6. package/package.json +36 -0
  7. package/template/.codebase/pipelines/ci.yaml +34 -0
  8. package/template/.codebase/pipelines/coco-code-review.yaml +21 -0
  9. package/template/.prettierrc +8 -0
  10. package/template/.spark_project +16 -0
  11. package/template/.stylelintrc.js +12 -0
  12. package/template/_env.local +38 -0
  13. package/template/_gitignore +64 -0
  14. package/template/_npmrc +1 -0
  15. package/template/client/index.html +23 -0
  16. package/template/client/public/favicon.svg +40 -0
  17. package/template/client/src/api/index.ts +19 -0
  18. package/template/client/src/app.tsx +22 -0
  19. package/template/client/src/components/Layout.tsx +11 -0
  20. package/template/client/src/components/business-ui/README.md +519 -0
  21. package/template/client/src/components/business-ui/api/chats/queries.ts +39 -0
  22. package/template/client/src/components/business-ui/api/chats/service.ts +29 -0
  23. package/template/client/src/components/business-ui/api/departments/queries.ts +42 -0
  24. package/template/client/src/components/business-ui/api/departments/service.ts +18 -0
  25. package/template/client/src/components/business-ui/api/files/service.ts +28 -0
  26. package/template/client/src/components/business-ui/api/user-profiles/queries.ts +42 -0
  27. package/template/client/src/components/business-ui/api/user-profiles/service.ts +23 -0
  28. package/template/client/src/components/business-ui/api/users/queries.ts +34 -0
  29. package/template/client/src/components/business-ui/api/users/service.ts +40 -0
  30. package/template/client/src/components/business-ui/chat-select/README.md +128 -0
  31. package/template/client/src/components/business-ui/chat-select/chat-item.tsx +85 -0
  32. package/template/client/src/components/business-ui/chat-select/chat-select-tag.tsx +83 -0
  33. package/template/client/src/components/business-ui/chat-select/chat-select.tsx +197 -0
  34. package/template/client/src/components/business-ui/chat-select/index.tsx +12 -0
  35. package/template/client/src/components/business-ui/chat-select/types.ts +102 -0
  36. package/template/client/src/components/business-ui/chat-select/use-chat-value.ts +180 -0
  37. package/template/client/src/components/business-ui/chat-select/utils.ts +81 -0
  38. package/template/client/src/components/business-ui/department-select/department-item.tsx +55 -0
  39. package/template/client/src/components/business-ui/department-select/department-select-field.tsx +86 -0
  40. package/template/client/src/components/business-ui/department-select/department-select-tag.tsx +56 -0
  41. package/template/client/src/components/business-ui/department-select/department-select.tsx +168 -0
  42. package/template/client/src/components/business-ui/department-select/icon-department.tsx +22 -0
  43. package/template/client/src/components/business-ui/department-select/index.tsx +7 -0
  44. package/template/client/src/components/business-ui/department-select/types.ts +15 -0
  45. package/template/client/src/components/business-ui/department-select/utils.ts +17 -0
  46. package/template/client/src/components/business-ui/entity-combobox/base-combobox-content.tsx +35 -0
  47. package/template/client/src/components/business-ui/entity-combobox/base-combobox-empty.tsx +28 -0
  48. package/template/client/src/components/business-ui/entity-combobox/base-combobox-error.tsx +35 -0
  49. package/template/client/src/components/business-ui/entity-combobox/base-combobox-item.tsx +55 -0
  50. package/template/client/src/components/business-ui/entity-combobox/base-combobox-list.tsx +174 -0
  51. package/template/client/src/components/business-ui/entity-combobox/base-combobox-loading.tsx +19 -0
  52. package/template/client/src/components/business-ui/entity-combobox/base-combobox-search.tsx +31 -0
  53. package/template/client/src/components/business-ui/entity-combobox/base-combobox-trigger.tsx +281 -0
  54. package/template/client/src/components/business-ui/entity-combobox/base-combobox.tsx +132 -0
  55. package/template/client/src/components/business-ui/entity-combobox/context.tsx +19 -0
  56. package/template/client/src/components/business-ui/entity-combobox/entity-combobox.tsx +145 -0
  57. package/template/client/src/components/business-ui/entity-combobox/highlight-text.tsx +52 -0
  58. package/template/client/src/components/business-ui/entity-combobox/hooks.tsx +21 -0
  59. package/template/client/src/components/business-ui/entity-combobox/index.tsx +31 -0
  60. package/template/client/src/components/business-ui/entity-combobox/item-pill.tsx +179 -0
  61. package/template/client/src/components/business-ui/entity-combobox/popover-wrapper.tsx +149 -0
  62. package/template/client/src/components/business-ui/entity-combobox/search-trigger.tsx +262 -0
  63. package/template/client/src/components/business-ui/entity-combobox/shared-types.ts +296 -0
  64. package/template/client/src/components/business-ui/entity-combobox/size-variants.tsx +322 -0
  65. package/template/client/src/components/business-ui/entity-combobox/types.ts +246 -0
  66. package/template/client/src/components/business-ui/entity-combobox/use-fetch-data.tsx +81 -0
  67. package/template/client/src/components/business-ui/entity-combobox/use-infinite-scroll.tsx +99 -0
  68. package/template/client/src/components/business-ui/entity-combobox/use-popover-outside-click.tsx +32 -0
  69. package/template/client/src/components/business-ui/form/checkbox-field.tsx +83 -0
  70. package/template/client/src/components/business-ui/form/context.tsx +38 -0
  71. package/template/client/src/components/business-ui/form/field-layout.tsx +91 -0
  72. package/template/client/src/components/business-ui/form/form.tsx +54 -0
  73. package/template/client/src/components/business-ui/form/hooks/form-context.tsx +4 -0
  74. package/template/client/src/components/business-ui/form/hooks/form-utils.ts +23 -0
  75. package/template/client/src/components/business-ui/form/hooks/form.tsx +40 -0
  76. package/template/client/src/components/business-ui/form/index.tsx +4 -0
  77. package/template/client/src/components/business-ui/form/input-field.tsx +74 -0
  78. package/template/client/src/components/business-ui/form/radio-group-field.tsx +138 -0
  79. package/template/client/src/components/business-ui/form/select-field.tsx +155 -0
  80. package/template/client/src/components/business-ui/form/switch-field.tsx +66 -0
  81. package/template/client/src/components/business-ui/form/textarea-field.tsx +72 -0
  82. package/template/client/src/components/business-ui/form/types.ts +32 -0
  83. package/template/client/src/components/business-ui/tiptap-editor/README.md +324 -0
  84. package/template/client/src/components/business-ui/tiptap-editor/components/attachment-toolbar-button.tsx +74 -0
  85. package/template/client/src/components/business-ui/tiptap-editor/components/blockquote-toolbar-button.tsx +41 -0
  86. package/template/client/src/components/business-ui/tiptap-editor/components/code-block-toolbar-button.tsx +41 -0
  87. package/template/client/src/components/business-ui/tiptap-editor/components/color-highlight-toolbar-button.tsx +141 -0
  88. package/template/client/src/components/business-ui/tiptap-editor/components/heading-toolbar-button.tsx +99 -0
  89. package/template/client/src/components/business-ui/tiptap-editor/components/horizontal-rule-toolbar-button.tsx +39 -0
  90. package/template/client/src/components/business-ui/tiptap-editor/components/image-upload-toolbar-button.tsx +65 -0
  91. package/template/client/src/components/business-ui/tiptap-editor/components/link-edit-form.tsx +127 -0
  92. package/template/client/src/components/business-ui/tiptap-editor/components/link-hover-toolbar.tsx +380 -0
  93. package/template/client/src/components/business-ui/tiptap-editor/components/link-toolbar-button.tsx +96 -0
  94. package/template/client/src/components/business-ui/tiptap-editor/components/list-toolbar-button.tsx +75 -0
  95. package/template/client/src/components/business-ui/tiptap-editor/components/mark-toolbar-button.tsx +118 -0
  96. package/template/client/src/components/business-ui/tiptap-editor/components/text-align-toolbar-button.tsx +84 -0
  97. package/template/client/src/components/business-ui/tiptap-editor/components/undo-redo-toolbar-button.tsx +54 -0
  98. package/template/client/src/components/business-ui/tiptap-editor/extensions/attachment.tsx +539 -0
  99. package/template/client/src/components/business-ui/tiptap-editor/extensions/code-block-shiki.tsx +236 -0
  100. package/template/client/src/components/business-ui/tiptap-editor/extensions/complete-kit.ts +260 -0
  101. package/template/client/src/components/business-ui/tiptap-editor/extensions/image.tsx +456 -0
  102. package/template/client/src/components/business-ui/tiptap-editor/hooks/use-tiptap-editor.ts +47 -0
  103. package/template/client/src/components/business-ui/tiptap-editor/index.ts +38 -0
  104. package/template/client/src/components/business-ui/tiptap-editor/tiptap-editor-complete.tsx +112 -0
  105. package/template/client/src/components/business-ui/tiptap-editor/tiptap-editor.tsx +169 -0
  106. package/template/client/src/components/business-ui/types/user.ts +74 -0
  107. package/template/client/src/components/business-ui/user-display/index.tsx +2 -0
  108. package/template/client/src/components/business-ui/user-display/overflow-tooltip-text.tsx +90 -0
  109. package/template/client/src/components/business-ui/user-display/type.ts +27 -0
  110. package/template/client/src/components/business-ui/user-display/user-display.tsx +99 -0
  111. package/template/client/src/components/business-ui/user-display/user-with-avatar.tsx +158 -0
  112. package/template/client/src/components/business-ui/user-display/utils.ts +37 -0
  113. package/template/client/src/components/business-ui/user-profile/error-image.tsx +302 -0
  114. package/template/client/src/components/business-ui/user-profile/user-external-script.ts +73 -0
  115. package/template/client/src/components/business-ui/user-profile/user-profile.tsx +370 -0
  116. package/template/client/src/components/business-ui/user-select/index.tsx +28 -0
  117. package/template/client/src/components/business-ui/user-select/types.ts +109 -0
  118. package/template/client/src/components/business-ui/user-select/use-user-value.ts +160 -0
  119. package/template/client/src/components/business-ui/user-select/user-item.tsx +98 -0
  120. package/template/client/src/components/business-ui/user-select/user-pill.tsx +140 -0
  121. package/template/client/src/components/business-ui/user-select/user-select-tag.tsx +82 -0
  122. package/template/client/src/components/business-ui/user-select/user-select.tsx +359 -0
  123. package/template/client/src/components/business-ui/user-select/utils.tsx +188 -0
  124. package/template/client/src/components/business-ui/utils/user.ts +72 -0
  125. package/template/client/src/components/ui/README.md +134 -0
  126. package/template/client/src/components/ui/accordion.tsx +66 -0
  127. package/template/client/src/components/ui/alert-dialog.tsx +157 -0
  128. package/template/client/src/components/ui/alert.tsx +71 -0
  129. package/template/client/src/components/ui/aspect-ratio.tsx +11 -0
  130. package/template/client/src/components/ui/avatar.tsx +53 -0
  131. package/template/client/src/components/ui/badge.tsx +42 -0
  132. package/template/client/src/components/ui/breadcrumb.tsx +109 -0
  133. package/template/client/src/components/ui/button-group.tsx +83 -0
  134. package/template/client/src/components/ui/button.tsx +69 -0
  135. package/template/client/src/components/ui/calendar.tsx +213 -0
  136. package/template/client/src/components/ui/card.tsx +82 -0
  137. package/template/client/src/components/ui/carousel.tsx +241 -0
  138. package/template/client/src/components/ui/chart.tsx +357 -0
  139. package/template/client/src/components/ui/checkbox.tsx +32 -0
  140. package/template/client/src/components/ui/collapsible.tsx +33 -0
  141. package/template/client/src/components/ui/command.tsx +208 -0
  142. package/template/client/src/components/ui/context-menu.tsx +324 -0
  143. package/template/client/src/components/ui/dialog.tsx +143 -0
  144. package/template/client/src/components/ui/drawer.tsx +135 -0
  145. package/template/client/src/components/ui/dropdown-menu.tsx +329 -0
  146. package/template/client/src/components/ui/empty.tsx +104 -0
  147. package/template/client/src/components/ui/field.tsx +248 -0
  148. package/template/client/src/components/ui/form.tsx +167 -0
  149. package/template/client/src/components/ui/hover-card.tsx +44 -0
  150. package/template/client/src/components/ui/icons/file-ae-colorful-icon.tsx +21 -0
  151. package/template/client/src/components/ui/icons/file-ai-colorful-icon.tsx +36 -0
  152. package/template/client/src/components/ui/icons/file-android-colorful-icon.tsx +33 -0
  153. package/template/client/src/components/ui/icons/file-audio-colorful-icon.tsx +21 -0
  154. package/template/client/src/components/ui/icons/file-code-colorful-icon.tsx +28 -0
  155. package/template/client/src/components/ui/icons/file-csv-colorful-icon.tsx +21 -0
  156. package/template/client/src/components/ui/icons/file-eml-colorful-icon.tsx +29 -0
  157. package/template/client/src/components/ui/icons/file-ios-colorful-icon.tsx +25 -0
  158. package/template/client/src/components/ui/icons/file-keynote-colorful-icon.tsx +29 -0
  159. package/template/client/src/components/ui/icons/file-pages-colorful-icon.tsx +29 -0
  160. package/template/client/src/components/ui/icons/file-ps-colorful-icon.tsx +21 -0
  161. package/template/client/src/components/ui/icons/file-sketch-colorful-icon.tsx +21 -0
  162. package/template/client/src/components/ui/icons/file-slide-colorful-icon.tsx +21 -0
  163. package/template/client/src/components/ui/icons/file-vcf-colorful-icon.tsx +29 -0
  164. package/template/client/src/components/ui/icons/file-wiki-excel-colorful-icon.tsx +23 -0
  165. package/template/client/src/components/ui/icons/file-wiki-image-colorful-icon.tsx +27 -0
  166. package/template/client/src/components/ui/icons/file-wiki-pdf-colorful-icon.tsx +20 -0
  167. package/template/client/src/components/ui/icons/file-wiki-ppt-colorful-icon.tsx +21 -0
  168. package/template/client/src/components/ui/icons/file-wiki-text-colorful-icon.tsx +12 -0
  169. package/template/client/src/components/ui/icons/file-wiki-unknown-colorful-icon.tsx +14 -0
  170. package/template/client/src/components/ui/icons/file-wiki-video-colorful-icon.tsx +23 -0
  171. package/template/client/src/components/ui/icons/file-wiki-word-colorful-icon.tsx +38 -0
  172. package/template/client/src/components/ui/icons/file-wiki-zip-colorful-icon.tsx +21 -0
  173. package/template/client/src/components/ui/image.tsx +183 -0
  174. package/template/client/src/components/ui/input-group.tsx +166 -0
  175. package/template/client/src/components/ui/input-otp.tsx +77 -0
  176. package/template/client/src/components/ui/input.tsx +21 -0
  177. package/template/client/src/components/ui/item.tsx +193 -0
  178. package/template/client/src/components/ui/kbd.tsx +28 -0
  179. package/template/client/src/components/ui/label.tsx +24 -0
  180. package/template/client/src/components/ui/menubar.tsx +348 -0
  181. package/template/client/src/components/ui/native-select.tsx +48 -0
  182. package/template/client/src/components/ui/navigation-menu.tsx +168 -0
  183. package/template/client/src/components/ui/pagination.tsx +127 -0
  184. package/template/client/src/components/ui/popover.tsx +48 -0
  185. package/template/client/src/components/ui/progress.tsx +31 -0
  186. package/template/client/src/components/ui/radio-group.tsx +45 -0
  187. package/template/client/src/components/ui/resizable.tsx +56 -0
  188. package/template/client/src/components/ui/scroll-area.tsx +58 -0
  189. package/template/client/src/components/ui/select.tsx +243 -0
  190. package/template/client/src/components/ui/separator.tsx +28 -0
  191. package/template/client/src/components/ui/sheet.tsx +139 -0
  192. package/template/client/src/components/ui/sidebar.tsx +727 -0
  193. package/template/client/src/components/ui/skeleton.tsx +13 -0
  194. package/template/client/src/components/ui/slider.tsx +87 -0
  195. package/template/client/src/components/ui/sonner.tsx +67 -0
  196. package/template/client/src/components/ui/spinner.tsx +16 -0
  197. package/template/client/src/components/ui/streamdown.tsx +186 -0
  198. package/template/client/src/components/ui/switch.tsx +31 -0
  199. package/template/client/src/components/ui/table.tsx +116 -0
  200. package/template/client/src/components/ui/tabs.tsx +66 -0
  201. package/template/client/src/components/ui/textarea.tsx +18 -0
  202. package/template/client/src/components/ui/toggle-group.tsx +83 -0
  203. package/template/client/src/components/ui/toggle.tsx +47 -0
  204. package/template/client/src/components/ui/tooltip.tsx +61 -0
  205. package/template/client/src/hooks/use-example.ts +52 -0
  206. package/template/client/src/hooks/use-mobile.ts +19 -0
  207. package/template/client/src/index.css +7 -0
  208. package/template/client/src/index.tsx +36 -0
  209. package/template/client/src/lib/shiki.ts +92 -0
  210. package/template/client/src/lib/utils.ts +6 -0
  211. package/template/client/src/pages/ExamplePage/ExamplePage.tsx +37 -0
  212. package/template/client/src/pages/NotFound/NotFound.tsx +11 -0
  213. package/template/client/src/tailwind-theme.css +257 -0
  214. package/template/client/src/types/common.ts +34 -0
  215. package/template/client/src/types/global.d.ts +53 -0
  216. package/template/client/src/types/index.ts +4 -0
  217. package/template/client/src/typography.css +41 -0
  218. package/template/client/src/utils/img-resources/avatar-placeholders.ts +12 -0
  219. package/template/client/src/utils/img-resources/banner-placeholders.ts +29 -0
  220. package/template/client/src/utils/img-resources/cover-placeholders.ts +47 -0
  221. package/template/components.json +21 -0
  222. package/template/eslint.config.js +50 -0
  223. package/template/nest-cli.json +25 -0
  224. package/template/package-lock.json +29195 -0
  225. package/template/package.json +202 -0
  226. package/template/postcss.config.js +10 -0
  227. package/template/scripts/build.sh +120 -0
  228. package/template/scripts/dev.js +295 -0
  229. package/template/scripts/dev.sh +2 -0
  230. package/template/scripts/lint.js +150 -0
  231. package/template/scripts/prune-smart.js +281 -0
  232. package/template/scripts/run.sh +5 -0
  233. package/template/server/app.module.ts +27 -0
  234. package/template/server/common/constants/api_response_code.ts +54 -0
  235. package/template/server/common/filters/exception.filter.ts +78 -0
  236. package/template/server/common/interfaces/api_response.interface.ts +21 -0
  237. package/template/server/common/interfaces/exception.interface.ts +19 -0
  238. package/template/server/database/schema.ts +90 -0
  239. package/template/server/main.ts +31 -0
  240. package/template/server/modules/hello/hello.controller.ts +16 -0
  241. package/template/server/modules/hello/hello.module.ts +10 -0
  242. package/template/server/modules/hello/hello.service.ts +28 -0
  243. package/template/server/modules/view/view.controller.ts +17 -0
  244. package/template/server/modules/view/view.module.ts +8 -0
  245. package/template/shared/api.interface.ts +1 -0
  246. package/template/tailwind.config.ts +9 -0
  247. package/template/tsconfig.app.json +23 -0
  248. package/template/tsconfig.json +8 -0
  249. package/template/tsconfig.node.json +16 -0
  250. package/template/vite.config.ts +10 -0
@@ -0,0 +1,456 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { mergeAttributes, type CommandProps } from '@tiptap/core';
5
+ import TiptapImage from '@tiptap/extension-image';
6
+ import { type Node as ProseMirrorNode } from '@tiptap/pm/model';
7
+ import {
8
+ NodeViewWrapper,
9
+ ReactNodeViewRenderer,
10
+ type NodeViewProps,
11
+ } from '@tiptap/react';
12
+ import { nanoid } from 'nanoid';
13
+
14
+ import { uploadFile } from '@/components/business-ui/api/files/service';
15
+ import { cn } from '@/lib/utils';
16
+
17
+ export interface ImageAttrs {
18
+ src: string;
19
+ alt?: string;
20
+ title?: string;
21
+ width?: number | string | null;
22
+ height?: number | null;
23
+ uploadId?: string | null;
24
+ }
25
+
26
+ export type ImageUploadFn = (file: File) => Promise<string>;
27
+
28
+ export interface ImageOptions {
29
+ upload?: ImageUploadFn;
30
+ HTMLAttributes?: Record<string, unknown>;
31
+ }
32
+
33
+ declare module '@tiptap/core' {
34
+ interface Commands<ReturnType> {
35
+ imageUpload: {
36
+ insertImages: (files: File[]) => ReturnType;
37
+ };
38
+ }
39
+ }
40
+
41
+ function findImageByUploadId(doc: ProseMirrorNode, uploadId: string) {
42
+ let foundPos: number | null = null;
43
+ doc.descendants((node: ProseMirrorNode, pos: number) => {
44
+ if (foundPos != null) return false;
45
+ if (node.type?.name === 'image' && node.attrs?.uploadId === uploadId) {
46
+ foundPos = pos;
47
+ return false;
48
+ }
49
+ return true;
50
+ });
51
+ return foundPos;
52
+ }
53
+
54
+ function clampSize(value: number, { min, max }: { min: number; max: number }) {
55
+ if (!Number.isFinite(value)) return min;
56
+ return Math.max(min, Math.min(max, value));
57
+ }
58
+
59
+ function ImageNodeView(props: NodeViewProps) {
60
+ const { editor, selected } = props;
61
+ const attrs = props.node.attrs as ImageAttrs;
62
+ const wrapperRef = React.useRef<HTMLDivElement>(null);
63
+ const imgRef = React.useRef<HTMLImageElement>(null);
64
+ const [naturalRatio, setNaturalRatio] = React.useState<number | null>(null);
65
+
66
+ const isEditable = editor.isEditable;
67
+
68
+ // 计算宽高比:优先使用属性中的宽高,否则使用自然宽高比
69
+ const aspectRatio =
70
+ typeof attrs.width === 'number' && attrs.height
71
+ ? attrs.width / attrs.height
72
+ : naturalRatio;
73
+
74
+ const style = React.useMemo<React.CSSProperties>(() => {
75
+ if (attrs.width === '100%') {
76
+ return {
77
+ width: '100%',
78
+ height: 'auto',
79
+ };
80
+ }
81
+ if (typeof attrs.width === 'number' && attrs.height) {
82
+ return {
83
+ width: `${attrs.width}px`,
84
+ aspectRatio: `${attrs.width} / ${attrs.height}`,
85
+ };
86
+ }
87
+ return {};
88
+ }, [attrs.width, attrs.height]);
89
+
90
+ const startResize = React.useCallback(
91
+ (corner: 'nw' | 'ne' | 'sw' | 'se') =>
92
+ (e: React.PointerEvent<HTMLButtonElement>) => {
93
+ if (!isEditable) return;
94
+ e.preventDefault();
95
+ e.stopPropagation();
96
+
97
+ const img = imgRef.current;
98
+ const wrapper = wrapperRef.current;
99
+ if (!img || !wrapper) return;
100
+
101
+ const rect = img.getBoundingClientRect();
102
+ const startWidth = rect.width;
103
+ const startHeight = rect.height;
104
+
105
+ const ratio =
106
+ aspectRatio ?? (startHeight ? startWidth / startHeight : null);
107
+ if (!ratio) return;
108
+
109
+ const startX = e.clientX;
110
+ const startY = e.clientY;
111
+
112
+ const containerWidth = wrapper.parentElement?.clientWidth ?? rect.width;
113
+ const minWidth = 80;
114
+
115
+ const onMove = (ev: PointerEvent) => {
116
+ const dx = ev.clientX - startX;
117
+ const dy = ev.clientY - startY;
118
+
119
+ // 保持宽高比
120
+ // 根据角落方向调整宽度
121
+ let nextWidth = startWidth;
122
+ if (corner === 'ne' || corner === 'se') {
123
+ nextWidth = startWidth + dx;
124
+ } else {
125
+ nextWidth = startWidth - dx;
126
+ }
127
+
128
+ // 允许垂直拖拽调整大小,根据角落映射 dy 到宽度变化
129
+ const dyAsWidth = dy * ratio;
130
+ if (corner === 'se' || corner === 'sw') {
131
+ nextWidth =
132
+ Math.abs(dyAsWidth) > Math.abs(dx)
133
+ ? startWidth + dyAsWidth
134
+ : nextWidth;
135
+ } else {
136
+ nextWidth =
137
+ Math.abs(dyAsWidth) > Math.abs(dx)
138
+ ? startWidth - dyAsWidth
139
+ : nextWidth;
140
+ }
141
+
142
+ nextWidth = clampSize(nextWidth, {
143
+ min: minWidth,
144
+ max: containerWidth,
145
+ });
146
+
147
+ const nextHeight = nextWidth / ratio;
148
+
149
+ // 如果接近容器宽度,吸附到 100%
150
+ if (Math.abs(containerWidth - nextWidth) < 10) {
151
+ props.updateAttributes({
152
+ width: '100%',
153
+ height: null,
154
+ });
155
+ return;
156
+ }
157
+
158
+ props.updateAttributes({
159
+ width: Math.round(nextWidth),
160
+ height: Math.round(nextHeight),
161
+ });
162
+ };
163
+
164
+ const onUp = () => {
165
+ window.removeEventListener('pointermove', onMove);
166
+ window.removeEventListener('pointerup', onUp);
167
+ };
168
+
169
+ window.addEventListener('pointermove', onMove);
170
+ window.addEventListener('pointerup', onUp);
171
+ },
172
+ [aspectRatio, isEditable, props],
173
+ );
174
+
175
+ return (
176
+ <NodeViewWrapper
177
+ className={cn('not-prose my-3 flex justify-center')}
178
+ data-type="image-block"
179
+ >
180
+ <div
181
+ ref={wrapperRef}
182
+ className={cn(
183
+ 'relative inline-block max-w-full rounded-md',
184
+ selected && isEditable ? 'ring-2 ring-ring/50' : '',
185
+ )}
186
+ style={style}
187
+ contentEditable={false}
188
+ >
189
+ <img
190
+ ref={imgRef}
191
+ src={attrs.src}
192
+ alt={attrs.alt ?? ''}
193
+ title={attrs.title}
194
+ className={cn('block size-full rounded-md object-cover')}
195
+ draggable={false}
196
+ onLoad={(e) => {
197
+ const img = e.currentTarget;
198
+ if (img.naturalWidth && img.naturalHeight) {
199
+ setNaturalRatio(img.naturalWidth / img.naturalHeight);
200
+ }
201
+ }}
202
+ />
203
+
204
+ {attrs.uploadId ? (
205
+ <>
206
+ <div className="pointer-events-none absolute inset-0 rounded-md bg-black/25" />
207
+ <div className="pointer-events-none absolute top-2 right-2 flex items-center gap-1 rounded-md bg-primary-foreground/20 px-2 py-1 text-sm/5 text-primary-foreground">
208
+ <span
209
+ className="size-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
210
+ aria-hidden="true"
211
+ />
212
+ <span className="sr-only">图片上传中</span>
213
+ </div>
214
+ </>
215
+ ) : null}
216
+
217
+ {selected && isEditable && (
218
+ <>
219
+ <ResizeHandle corner="nw" onPointerDown={startResize('nw')} />
220
+ <ResizeHandle corner="ne" onPointerDown={startResize('ne')} />
221
+ <ResizeHandle corner="sw" onPointerDown={startResize('sw')} />
222
+ <ResizeHandle corner="se" onPointerDown={startResize('se')} />
223
+ </>
224
+ )}
225
+ </div>
226
+ </NodeViewWrapper>
227
+ );
228
+ }
229
+
230
+ function ResizeHandle({
231
+ corner,
232
+ onPointerDown,
233
+ }: {
234
+ corner: 'nw' | 'ne' | 'sw' | 'se';
235
+ onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => void;
236
+ }) {
237
+ const positionClass =
238
+ corner === 'nw'
239
+ ? '-left-1.5 -top-1.5'
240
+ : corner === 'ne'
241
+ ? '-right-1.5 -top-1.5'
242
+ : corner === 'sw'
243
+ ? '-left-1.5 -bottom-1.5'
244
+ : '-right-1.5 -bottom-1.5';
245
+
246
+ const cursorClass =
247
+ corner === 'nw' || corner === 'se'
248
+ ? 'cursor-nwse-resize'
249
+ : 'cursor-nesw-resize';
250
+
251
+ const label =
252
+ corner === 'nw'
253
+ ? 'Resize image from top-left'
254
+ : corner === 'ne'
255
+ ? 'Resize image from top-right'
256
+ : corner === 'sw'
257
+ ? 'Resize image from bottom-left'
258
+ : 'Resize image from bottom-right';
259
+
260
+ return (
261
+ <button
262
+ type="button"
263
+ aria-label={label}
264
+ className={cn(
265
+ 'absolute z-10 flex size-2.5 items-center justify-center rounded-full border border-primary bg-background shadow-sm',
266
+ cursorClass,
267
+ positionClass,
268
+ )}
269
+ onPointerDown={onPointerDown}
270
+ />
271
+ );
272
+ }
273
+
274
+ export const Image = TiptapImage.extend<ImageOptions>({
275
+ addOptions() {
276
+ return {
277
+ upload: async (file: File) => {
278
+ const data = await uploadFile(file);
279
+ return data.url;
280
+ },
281
+ };
282
+ },
283
+
284
+ addAttributes() {
285
+ return {
286
+ ...this.parent?.(),
287
+ width: {
288
+ default: null,
289
+ parseHTML: (element) => {
290
+ const styleWidth = element.style?.width;
291
+ if (styleWidth && styleWidth.trim().endsWith('%')) {
292
+ return styleWidth.trim();
293
+ }
294
+
295
+ const rawWidth = element.getAttribute('width');
296
+ if (!rawWidth) return null;
297
+ if (rawWidth.trim().endsWith('%')) return rawWidth.trim();
298
+
299
+ const parsed = Number.parseInt(rawWidth, 10);
300
+ return Number.isFinite(parsed) ? parsed : null;
301
+ },
302
+ renderHTML: (attributes) => {
303
+ const width = attributes.width;
304
+ if (!width) return {};
305
+ if (width === '100%') {
306
+ return {
307
+ style: 'width: 100%; height: auto;',
308
+ };
309
+ }
310
+ if (typeof width === 'number') {
311
+ return { width: String(width) };
312
+ }
313
+ return {
314
+ style: `width: ${String(width)}; height: auto;`,
315
+ };
316
+ },
317
+ },
318
+ height: {
319
+ default: null,
320
+ parseHTML: (element) => {
321
+ const rawHeight = element.getAttribute('height');
322
+ if (!rawHeight) return null;
323
+ const parsed = Number.parseInt(rawHeight, 10);
324
+ return Number.isFinite(parsed) ? parsed : null;
325
+ },
326
+ renderHTML: (attributes) => {
327
+ const height = attributes.height;
328
+ if (!height) return {};
329
+ return { height: String(height) };
330
+ },
331
+ },
332
+ uploadId: {
333
+ default: null,
334
+ rendered: false,
335
+ },
336
+ };
337
+ },
338
+
339
+ renderHTML({ HTMLAttributes }) {
340
+ // Width/height serialization happens via addAttributes().
341
+ // We intentionally do not put layout classes on the wrapper; consumers can style by data-type.
342
+ return [
343
+ 'div',
344
+ {
345
+ 'data-type': 'image-block',
346
+ },
347
+ [
348
+ 'img',
349
+ mergeAttributes(
350
+ this.options.HTMLAttributes ?? {},
351
+ Object.fromEntries(Object.entries(HTMLAttributes)),
352
+ ),
353
+ ],
354
+ ];
355
+ },
356
+
357
+ parseHTML() {
358
+ return [
359
+ {
360
+ tag: 'div[data-type="image-block"] img[src]',
361
+ },
362
+ {
363
+ tag: 'img[src]:not([src^="data:"])',
364
+ },
365
+ ];
366
+ },
367
+
368
+ addCommands() {
369
+ return {
370
+ ...this.parent?.(),
371
+ insertImages:
372
+ (files: File[]) =>
373
+ ({ editor, commands }: CommandProps) => {
374
+ if (!files || files.length === 0) return false;
375
+
376
+ const upload = this.options.upload;
377
+ if (!upload) return false;
378
+
379
+ const uploads = files.map((file) => {
380
+ const uploadId = nanoid();
381
+ const previewUrl = URL.createObjectURL(file);
382
+
383
+ return {
384
+ file,
385
+ uploadId,
386
+ previewUrl,
387
+ };
388
+ });
389
+
390
+ // 一次性插入多个节点,避免 NodeSelection 导致后续插入替换掉前一个节点
391
+ const inserted = commands.insertContent([
392
+ ...uploads.map(({ uploadId, previewUrl }) => ({
393
+ type: this.name,
394
+ attrs: {
395
+ src: previewUrl,
396
+ uploadId,
397
+ },
398
+ })),
399
+ { type: 'paragraph' },
400
+ ]);
401
+
402
+ // 并发上传:不阻塞命令返回
403
+ void Promise.all(
404
+ uploads.map(async ({ file, uploadId, previewUrl }) => {
405
+ try {
406
+ const url = await upload(file);
407
+
408
+ // Ensure state is settled
409
+ await new Promise((resolve) => requestAnimationFrame(resolve));
410
+
411
+ const pos = findImageByUploadId(editor.state.doc, uploadId);
412
+ if (pos == null) return;
413
+
414
+ const node = editor.state.doc.nodeAt(pos);
415
+ if (!node || node.type.name !== this.name) return;
416
+
417
+ editor
418
+ .chain()
419
+ .command(({ tr }: CommandProps) => {
420
+ tr.setNodeMarkup(pos, undefined, {
421
+ ...node.attrs,
422
+ src: url,
423
+ uploadId: null,
424
+ });
425
+ return true;
426
+ })
427
+ .run();
428
+ } catch {
429
+ const pos = findImageByUploadId(editor.state.doc, uploadId);
430
+ if (pos != null) {
431
+ editor
432
+ .chain()
433
+ .deleteRange({ from: pos, to: pos + 1 })
434
+ .run();
435
+ }
436
+ } finally {
437
+ if (previewUrl.startsWith('blob:')) {
438
+ try {
439
+ URL.revokeObjectURL(previewUrl);
440
+ } catch {
441
+ // ignore
442
+ }
443
+ }
444
+ }
445
+ }),
446
+ );
447
+
448
+ return inserted;
449
+ },
450
+ };
451
+ },
452
+
453
+ addNodeView() {
454
+ return ReactNodeViewRenderer(ImageNodeView);
455
+ },
456
+ });
@@ -0,0 +1,47 @@
1
+ import * as React from 'react';
2
+ import { useCurrentEditor, useEditorState, type Editor } from '@tiptap/react';
3
+
4
+ /**
5
+ * Hook that provides access to a Tiptap editor instance.
6
+ *
7
+ * Accepts an optional editor instance directly, or falls back to retrieving
8
+ * the editor from the Tiptap context if available. This allows components
9
+ * to work both when given an editor directly and when used within a Tiptap
10
+ * editor context.
11
+ *
12
+ * @param providedEditor - Optional editor instance to use instead of the context editor
13
+ * @returns The provided editor or the editor from context, whichever is available
14
+ */
15
+ export function useTiptapEditor(providedEditor?: Editor | null): {
16
+ editor: Editor | null;
17
+ editorState?: Editor['state'];
18
+ canCommand?: Editor['can'];
19
+ } {
20
+ const { editor: coreEditor } = useCurrentEditor();
21
+
22
+ const mainEditor = React.useMemo(
23
+ () => providedEditor || coreEditor,
24
+ [providedEditor, coreEditor],
25
+ );
26
+
27
+ const editorState = useEditorState({
28
+ editor: mainEditor,
29
+ selector(context) {
30
+ if (!context.editor) {
31
+ return {
32
+ editor: null,
33
+ editorState: undefined,
34
+ canCommand: undefined,
35
+ };
36
+ }
37
+
38
+ return {
39
+ editor: context.editor,
40
+ editorState: context.editor.state,
41
+ canCommand: context.editor.can.bind(context.editor),
42
+ };
43
+ },
44
+ });
45
+
46
+ return editorState || { editor: null };
47
+ }
@@ -0,0 +1,38 @@
1
+ // #region 完整版组件
2
+ export {
3
+ TiptapEditorComplete,
4
+ type TiptapEditorCompleteProps,
5
+ } from '@/components/business-ui/tiptap-editor/tiptap-editor-complete';
6
+ // #endregion
7
+
8
+ // #region 组合式 API
9
+ export {
10
+ TiptapEditor,
11
+ TiptapEditorContent,
12
+ TiptapEditorToolbar,
13
+ TiptapEditorToolbarSeparator,
14
+ type TiptapEditorProps,
15
+ } from '@/components/business-ui/tiptap-editor/tiptap-editor';
16
+ // #endregion
17
+
18
+ // #region 扩展
19
+ export {
20
+ CompleteKit,
21
+ type CompleteKitOptions,
22
+ } from '@/components/business-ui/tiptap-editor/extensions/complete-kit';
23
+ // #endregion
24
+
25
+ // #region 工具栏可用工具按钮
26
+ export { AttachmentToolbarButton } from '@/components/business-ui/tiptap-editor/components/attachment-toolbar-button';
27
+ export { BlockquoteToolbarButton } from '@/components/business-ui/tiptap-editor/components/blockquote-toolbar-button';
28
+ export { CodeBlockToolbarButton } from '@/components/business-ui/tiptap-editor/components/code-block-toolbar-button';
29
+ export { ColorHighlightToolbarButton } from '@/components/business-ui/tiptap-editor/components/color-highlight-toolbar-button';
30
+ export { HeadingToolbarButton } from '@/components/business-ui/tiptap-editor/components/heading-toolbar-button';
31
+ export { HorizontalRuleToolbarButton } from '@/components/business-ui/tiptap-editor/components/horizontal-rule-toolbar-button';
32
+ export { ImageUploadToolbarButton } from '@/components/business-ui/tiptap-editor/components/image-upload-toolbar-button';
33
+ export { LinkToolbarButton } from '@/components/business-ui/tiptap-editor/components/link-toolbar-button';
34
+ export { ListToolbarButton } from '@/components/business-ui/tiptap-editor/components/list-toolbar-button';
35
+ export { MarkToolbarButton } from '@/components/business-ui/tiptap-editor/components/mark-toolbar-button';
36
+ export { TextAlignToolbarButton } from '@/components/business-ui/tiptap-editor/components/text-align-toolbar-button';
37
+ export { UndoRedoToolbarButton } from '@/components/business-ui/tiptap-editor/components/undo-redo-toolbar-button';
38
+ // #endregion
@@ -0,0 +1,112 @@
1
+ 'use client';
2
+
3
+ import { AttachmentToolbarButton } from '@/components/business-ui/tiptap-editor/components/attachment-toolbar-button';
4
+ import { BlockquoteToolbarButton } from '@/components/business-ui/tiptap-editor/components/blockquote-toolbar-button';
5
+ import { CodeBlockToolbarButton } from '@/components/business-ui/tiptap-editor/components/code-block-toolbar-button';
6
+ import { ColorHighlightToolbarButton } from '@/components/business-ui/tiptap-editor/components/color-highlight-toolbar-button';
7
+ import { HeadingToolbarButton } from '@/components/business-ui/tiptap-editor/components/heading-toolbar-button';
8
+ import { HorizontalRuleToolbarButton } from '@/components/business-ui/tiptap-editor/components/horizontal-rule-toolbar-button';
9
+ import { ImageUploadToolbarButton } from '@/components/business-ui/tiptap-editor/components/image-upload-toolbar-button';
10
+ import { LinkToolbarButton } from '@/components/business-ui/tiptap-editor/components/link-toolbar-button';
11
+ import { ListToolbarButton } from '@/components/business-ui/tiptap-editor/components/list-toolbar-button';
12
+ import { MarkToolbarButton } from '@/components/business-ui/tiptap-editor/components/mark-toolbar-button';
13
+ import { TextAlignToolbarButton } from '@/components/business-ui/tiptap-editor/components/text-align-toolbar-button';
14
+ import { UndoRedoToolbarButton } from '@/components/business-ui/tiptap-editor/components/undo-redo-toolbar-button';
15
+ import { CompleteKit } from '@/components/business-ui/tiptap-editor/extensions/complete-kit';
16
+ import {
17
+ TiptapEditor,
18
+ TiptapEditorContent,
19
+ TiptapEditorToolbar,
20
+ TiptapEditorToolbarSeparator,
21
+ type TiptapEditorProps,
22
+ } from '@/components/business-ui/tiptap-editor/tiptap-editor';
23
+ import { cn } from '@/lib/utils';
24
+
25
+ export interface TiptapEditorCompleteProps extends Omit<
26
+ TiptapEditorProps,
27
+ 'extensions'
28
+ > {
29
+ /** 占位符文本 */
30
+ placeholder?: string;
31
+ }
32
+
33
+ /**
34
+ * 一个完整、开箱即用的富文本编辑器组件,包含预配置的工具栏。
35
+ * 这是大多数使用场景下的推荐组件。
36
+ *
37
+ * 功能特性:
38
+ * - 预配置的工具栏,包含常用的格式化选项
39
+ * - 已内置完整样式,包括边框、焦点态等,你无需在此编辑器外包裹 div 实现样式
40
+ * - 支持撤销/重做
41
+ * - 标题样式 (H1-H6)
42
+ * - 文本对齐
43
+ * - 列表 (无序、有序、任务)
44
+ * - 文本格式化 (加粗、斜体、下划线、删除线、代码)
45
+ *
46
+ * @example
47
+ * ```tsx
48
+ * <TiptapEditorComplete
49
+ * value={value}
50
+ * onValueChange={setValue}
51
+ * placeholder="在此输入消息..."
52
+ * aria-invalid={invalid}
53
+ * aria-disabled={disabled}
54
+ * />
55
+ * ```
56
+ */
57
+ export function TiptapEditorComplete({
58
+ className,
59
+ placeholder,
60
+ ...props
61
+ }: TiptapEditorCompleteProps) {
62
+ const extensions = [CompleteKit.configure({ placeholder: { placeholder } })];
63
+
64
+ return (
65
+ <TiptapEditor
66
+ className={cn('max-h-140 min-h-80', className)}
67
+ extensions={extensions}
68
+ {...props}
69
+ >
70
+ <DefaultToolbar />
71
+ <TiptapEditorContent />
72
+ </TiptapEditor>
73
+ );
74
+ }
75
+
76
+ function DefaultToolbar() {
77
+ return (
78
+ <TiptapEditorToolbar>
79
+ {/* 撤销/重做 */}
80
+ <UndoRedoToolbarButton action="undo" />
81
+ <UndoRedoToolbarButton action="redo" />
82
+ <TiptapEditorToolbarSeparator />
83
+
84
+ {/* 标题/正文 */}
85
+ <HeadingToolbarButton />
86
+ <TiptapEditorToolbarSeparator />
87
+
88
+ {/* 文本对齐 & 列表 */}
89
+ <TextAlignToolbarButton />
90
+ <ListToolbarButton />
91
+ <TiptapEditorToolbarSeparator />
92
+
93
+ {/* 文本格式化: 加粗、删除线、斜体、下划线、颜色 */}
94
+ <MarkToolbarButton format="bold" />
95
+ <MarkToolbarButton format="strike" />
96
+ <MarkToolbarButton format="italic" />
97
+ <MarkToolbarButton format="underline" />
98
+ <ColorHighlightToolbarButton />
99
+ <TiptapEditorToolbarSeparator />
100
+
101
+ {/* 块级元素: 引用、分割线、代码块 */}
102
+ <BlockquoteToolbarButton />
103
+ <HorizontalRuleToolbarButton />
104
+ <CodeBlockToolbarButton />
105
+
106
+ {/* 链接 & 媒体: 图片、附件 */}
107
+ <LinkToolbarButton />
108
+ <ImageUploadToolbarButton />
109
+ <AttachmentToolbarButton />
110
+ </TiptapEditorToolbar>
111
+ );
112
+ }