@rxdrag/website-lib-core 0.0.12 → 0.0.14

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rxdrag/website-lib-core",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.ts"
@@ -24,10 +24,10 @@
24
24
  "@types/react-dom": "^18.2.7",
25
25
  "eslint": "^7.32.0",
26
26
  "typescript": "^5",
27
- "@rxdrag/entify-hooks": "0.2.42",
27
+ "@rxdrag/entify-hooks": "0.2.43",
28
+ "@rxdrag/slate-preview": "1.2.56",
28
29
  "@rxdrag/eslint-config-custom": "0.2.12",
29
- "@rxdrag/tsconfig": "0.2.0",
30
- "@rxdrag/slate-preview": "1.2.55"
30
+ "@rxdrag/tsconfig": "0.2.0"
31
31
  },
32
32
  "dependencies": {
33
33
  "clsx": "^2.1.0",
@@ -35,7 +35,7 @@
35
35
  "lodash-es": "^4.17.21",
36
36
  "react": "^18.2.0",
37
37
  "react-dom": "^18.2.0",
38
- "@rxdrag/rxcms-models": "0.3.48"
38
+ "@rxdrag/rxcms-models": "0.3.49"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "astro": "^4.0.0 || ^5.0.0"
@@ -1,4 +1,9 @@
1
- import { DATA_OPENABLE, DATA_OPENABLE_ROLE, OpenAble } from "../controller";
1
+ import {
2
+ DATA_OPENABLE,
3
+ DATA_OPENABLE_ROLE,
4
+ DATA_POPUP_CTA,
5
+ OpenAble,
6
+ } from "../controller";
2
7
  import { HTMLElementsWithChildren, IMotionProps } from "./motion";
3
8
 
4
9
  export type ModalTriggerProps = IMotionProps & {
@@ -28,9 +33,13 @@ export function getModalPanelDataAttrs(openableKey?: string) {
28
33
  };
29
34
  }
30
35
 
31
- export function getModalTriggerDataAttrs(openableKey?: string) {
36
+ export function getModalTriggerDataAttrs(
37
+ openableKey: string | undefined,
38
+ callToAction?: string
39
+ ) {
32
40
  return {
33
41
  [DATA_OPENABLE]: openableKey,
34
42
  [DATA_OPENABLE_ROLE]: OpenAble.ModalTrigger,
43
+ [DATA_POPUP_CTA]: callToAction,
35
44
  };
36
45
  }
@@ -9,6 +9,7 @@ import {
9
9
  OpenAble,
10
10
  EVENT_SELECT,
11
11
  EVENT_UNSELECT,
12
+ DATA_POPUP_CTA,
12
13
  } from "./consts";
13
14
  import { applyInitialState } from "./applyInitialState";
14
15
 
@@ -130,6 +131,7 @@ export class OpenableController {
130
131
  constructor(protected doc: Document | undefined) {}
131
132
  protected unmountHandlers: (() => void)[] = [];
132
133
  protected eventBus = new EventBus();
134
+ public lastCta?: string | null;
133
135
 
134
136
  mount() {
135
137
  this.unmount();
@@ -152,6 +154,9 @@ export class OpenableController {
152
154
  // 查找 Modal 容器
153
155
  const modalContainer = this.getOpenableContainer(openableKey);
154
156
  if (modalContainer) {
157
+ if (target.getAttribute(DATA_POPUP_CTA)) {
158
+ this.lastCta = target.getAttribute(DATA_POPUP_CTA);
159
+ }
155
160
  modalContainer.classList.add("open");
156
161
  this.eventBus.emit(EVENT_OPEN, { key: openableKey, target });
157
162
  } else {
@@ -136,7 +136,7 @@ export class PageLoader implements IPageLoader {
136
136
  // 移除所有事件监听器
137
137
  const noop = () => {};
138
138
  this.doc.removeEventListener("astro:after-swap", noop);
139
- this.doc.removeEventListener("DOMContentLoaded", noop);
139
+ this.doc.removeEventListener("astro:page-load", noop);
140
140
 
141
141
  // 清空回调集合
142
142
  this.callbacks.clear();
@@ -52,6 +52,10 @@ export class Entify implements IEntify {
52
52
  );
53
53
  }
54
54
 
55
+ public getLang() {
56
+ return this.envVariables.language || "en-US";
57
+ }
58
+
55
59
  public async getWebsite() {
56
60
  return queryWebsite(this.envVariables);
57
61
  }
@@ -23,6 +23,7 @@ export type PostPatinateOptions = {
23
23
  };
24
24
 
25
25
  export interface IEntify {
26
+ getLang(): string;
26
27
  queryEntityList<
27
28
  T,
28
29
  WhereExp = unknown,
@@ -59,9 +59,14 @@ export async function queryOneProductBySlug(
59
59
  envVariables
60
60
  );
61
61
 
62
- tProduct.related = result?.items?.map((pro) => productToViewModel(pro)) as
63
- | TProduct[]
64
- | undefined;
62
+ const relatedProducts = result?.items?.map((pro) =>
63
+ productToViewModel(pro)
64
+ ) as TProduct[] | undefined;
65
+
66
+ //重新排序后赋值
67
+ tProduct.related = product?.relatedSlugs.map((slug) =>
68
+ relatedProducts?.find((p) => p.slug === slug)
69
+ ) as TProduct[] | undefined;
65
70
  }
66
71
 
67
72
  return tProduct;
@@ -30,6 +30,13 @@ export async function queryPostCategories(envVariables: EnvVariables) {
30
30
  PostCategoryFields.description,
31
31
  ],
32
32
  {
33
+ where: {
34
+ lang: {
35
+ abbr: {
36
+ _eq: envVariables.language,
37
+ },
38
+ },
39
+ },
33
40
  orderBy: [{ [PostCategoryFields.seqValue]: "asc" }],
34
41
  }
35
42
  ).posts(
@@ -15,6 +15,13 @@ export async function queryProductCategories(envVariables: EnvVariables) {
15
15
  ProductCategoryFields.description,
16
16
  ],
17
17
  {
18
+ where: {
19
+ lang: {
20
+ abbr: {
21
+ _eq: envVariables.language,
22
+ },
23
+ },
24
+ },
18
25
  orderBy: [
19
26
  { [ProductCategoryFields.seqValue]: "asc" }
20
27
  ]
@@ -17,11 +17,10 @@ export async function queryWebSiteSettings(envVariables: EnvVariables) {
17
17
  WebsiteSettingsDistinctExp
18
18
  >(
19
19
  new WebsiteSettingsQueryOptions([
20
- WebsiteSettingsFields.footerCode,
21
- WebsiteSettingsFields.headerCode,
22
- WebsiteSettingsFields.gaTrackingId,
23
20
  WebsiteSettingsFields.noticeEmail,
24
21
  WebsiteSettingsFields.smtpConfig,
22
+ WebsiteSettingsFields.replyToEmail,
23
+ WebsiteSettingsFields.useCustomizedSmtp,
25
24
  ]),
26
25
  envVariables
27
26
  );
@@ -110,9 +110,7 @@ export type TWebsiteSettings = {
110
110
  facebook?: string;
111
111
  twitter?: string;
112
112
  instagram?: string;
113
- footerCode?: string;
114
113
  websiteName?: string;
115
- map301?: string;
116
114
  };
117
115
 
118
116
  export type TBreadcrumbItem = {
@@ -1,10 +1,9 @@
1
- import { forwardRef, useEffect, useState } from "react";
1
+ import { forwardRef, useState } from "react";
2
2
  import { Input } from "./Input";
3
3
  import { Submit } from "./Submit";
4
4
  import { Textarea } from "./Textarea";
5
- import { DATA_POPUP_CTA } from "../../../controller/consts";
6
5
  import clsx from "clsx";
7
- import { popover } from "../../../controller";
6
+ import { modal } from "../../../controller";
8
7
 
9
8
  /**
10
9
  * 简单的加密函数,用于生成防机器人的加密字段
@@ -114,7 +113,6 @@ export const ContactForm = forwardRef<HTMLDivElement, ContactFormProps>(
114
113
  email: "",
115
114
  company: "",
116
115
  message: "",
117
- fromCta: "",
118
116
  phone: "", // 初始化蜜罐字段
119
117
  });
120
118
  // 错误状态
@@ -124,26 +122,6 @@ export const ContactForm = forwardRef<HTMLDivElement, ContactFormProps>(
124
122
  success?: boolean;
125
123
  message?: string;
126
124
  }>({});
127
- //最近被点击过的cta
128
-
129
- useEffect(() => {
130
- const unsub = popover.onOpenAll((event) => {
131
- setFormData((prev) => ({
132
- ...prev,
133
- fromCta: event.target?.getAttribute(DATA_POPUP_CTA) || undefined,
134
- }));
135
- });
136
- const unsub2 = popover.onCloseAll(() => {
137
- setFormData((prev) => ({
138
- ...prev,
139
- fromCta: "",
140
- }));
141
- });
142
- return () => {
143
- unsub();
144
- unsub2();
145
- };
146
- }, []);
147
125
 
148
126
  // 处理输入变化
149
127
  const handleChange = (
@@ -205,11 +183,11 @@ export const ContactForm = forwardRef<HTMLDivElement, ContactFormProps>(
205
183
  try {
206
184
  setSubmitting(true);
207
185
  setSubmitStatus({}); // 重置提交状态
208
-
209
186
  const response = await fetch(actionUrl, {
210
187
  method: "POST",
211
188
  body: JSON.stringify({
212
189
  ...formData,
190
+ fromCta: modal.lastCta,
213
191
  encryptedField: encrypt(formData.phone, formSalt),
214
192
  }),
215
193
  headers: {
@@ -236,7 +214,6 @@ export const ContactForm = forwardRef<HTMLDivElement, ContactFormProps>(
236
214
  email: "",
237
215
  company: "",
238
216
  message: "",
239
- fromCta: "产品页面",
240
217
  phone: "",
241
218
  });
242
219
  window.location.href = "/thanks";
@@ -8,146 +8,227 @@ export type MediasProps = {
8
8
  children?: React.ReactNode;
9
9
  // Aspect ratio, format is `aspect-[width/height]`
10
10
  aspect?: string;
11
+ thumbnailAspect?: string;
11
12
  };
12
13
 
13
- export const Medias = forwardRef<HTMLDivElement, MediasProps>(
14
- (props, ref) => {
15
- const {
16
- value,
17
- className,
18
- children,
19
- aspect = "aspect-[5/4]",
20
- ...rest
21
- } = props;
22
- const [selectedId, setSelectedId] = useState<string | undefined | null>(
14
+ export const Medias = forwardRef<HTMLDivElement, MediasProps>((props, ref) => {
15
+ const {
16
+ value,
17
+ className,
18
+ children,
19
+ aspect = "aspect-[1/1]",
20
+ thumbnailAspect = "aspect-[5/4]",
21
+ ...rest
22
+ } = props;
23
+ const [selectedId, setSelectedId] = useState<string | undefined | null>(
24
+ value?.externalVideoUrl ? "video" : value?.medias?.[0]?.id || ""
25
+ );
26
+ const [videoUrl, setVideoUrl] = useState<string>("");
27
+ const [thumbnailUrl, setThumbnailUrl] = useState<string>("");
28
+
29
+ useEffect(() => {
30
+ setSelectedId(
23
31
  value?.externalVideoUrl ? "video" : value?.medias?.[0]?.id || ""
24
32
  );
25
- const [videoUrl, setVideoUrl] = useState<string>("");
26
- const [thumbnailUrl, setThumbnailUrl] = useState<string>("");
33
+ }, [value]);
27
34
 
28
- useEffect(() => {
29
- setSelectedId(
30
- value?.externalVideoUrl ? "video" : value?.medias?.[0]?.id || ""
35
+ useEffect(() => {
36
+ if (value?.externalVideoUrl) {
37
+ const baseUrl = value.externalVideoUrl.replace(
38
+ "https://youtu.be/",
39
+ "https://www.youtube.com/embed/"
31
40
  );
32
- }, [value]);
33
-
34
- useEffect(() => {
35
- if (value?.externalVideoUrl) {
36
- const baseUrl = value.externalVideoUrl.replace(
37
- "https://youtu.be/",
38
- "https://www.youtube.com/embed/"
39
- );
40
- const separator = baseUrl.includes("?") ? "&" : "?";
41
- setVideoUrl(
42
- `${baseUrl}${separator}autoplay=1&muted=1&modestbranding=1&rel=0&controls=1&playsinline=1&enablejsapi=1&origin=${encodeURIComponent(
43
- window.location.origin
44
- )}`
45
- );
46
- }
47
- }, [value?.externalVideoUrl]);
41
+ const separator = baseUrl.includes("?") ? "&" : "?";
42
+ setVideoUrl(
43
+ `${baseUrl}${separator}autoplay=1&muted=1&modestbranding=1&rel=0&controls=1&playsinline=1&enablejsapi=1&origin=${encodeURIComponent(
44
+ window.location.origin
45
+ )}`
46
+ );
47
+ }
48
+ }, [value?.externalVideoUrl]);
48
49
 
49
- useEffect(() => {
50
- if (value?.externalVideoUrl) {
51
- const videoId = value.externalVideoUrl.includes("youtu.be/")
52
- ? value.externalVideoUrl.split("youtu.be/")[1].split("?")[0]
53
- : value.externalVideoUrl.split("v=")[1]?.split("&")[0];
50
+ useEffect(() => {
51
+ if (value?.externalVideoUrl) {
52
+ const videoId = value.externalVideoUrl.includes("youtu.be/")
53
+ ? value.externalVideoUrl.split("youtu.be/")[1].split("?")[0]
54
+ : value.externalVideoUrl.split("v=")[1]?.split("&")[0];
54
55
 
55
- if (videoId) {
56
- setThumbnailUrl(
57
- `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`
58
- );
59
- }
56
+ if (videoId) {
57
+ setThumbnailUrl(`https://img.youtube.com/vi/${videoId}/hqdefault.jpg`);
60
58
  }
61
- }, [value?.externalVideoUrl]);
59
+ }
60
+ }, [value?.externalVideoUrl]);
62
61
 
63
- const selectedIndex = value?.externalVideoUrl
64
- ? selectedId === "video"
65
- ? 0
66
- : (value?.medias?.findIndex((media) => media.id === selectedId) || 0) +
67
- 1
68
- : value?.medias?.findIndex((media) => media.id === selectedId) || 0;
62
+ const selectedIndex = value?.externalVideoUrl
63
+ ? selectedId === "video"
64
+ ? 0
65
+ : (value?.medias?.findIndex((media) => media.id === selectedId) || 0) + 1
66
+ : value?.medias?.findIndex((media) => media.id === selectedId) || 0;
69
67
 
70
- const totalItems =
71
- (value?.externalVideoUrl ? 1 : 0) + (value?.medias?.length || 0);
68
+ const totalItems =
69
+ (value?.externalVideoUrl ? 1 : 0) + (value?.medias?.length || 0);
72
70
 
73
- // Calculate visible thumbnails (show 6 items)
74
- const visibleCount = 6;
75
- const halfVisible = Math.floor(visibleCount / 2);
76
- const startIndex = Math.max(
77
- 0,
78
- Math.min(selectedIndex - halfVisible, totalItems - visibleCount)
79
- );
80
- const endIndex = Math.min(startIndex + visibleCount, totalItems);
71
+ // Calculate visible thumbnails (show 6 items)
72
+ const visibleCount = 6;
73
+ const halfVisible = Math.floor(visibleCount / 2);
74
+ const startIndex = Math.max(
75
+ 0,
76
+ Math.min(selectedIndex - halfVisible, totalItems - visibleCount)
77
+ );
78
+ const endIndex = Math.min(startIndex + visibleCount, totalItems);
81
79
 
82
- const handlePrevious = useCallback(() => {
83
- if (selectedIndex > 0) {
84
- if (value?.externalVideoUrl) {
85
- if (selectedIndex === 1) {
86
- setSelectedId("video");
87
- } else {
88
- const prevIndex = selectedIndex - 2;
89
- setSelectedId(value.medias?.[prevIndex]?.id || "");
90
- }
80
+ const handlePrevious = useCallback(() => {
81
+ if (selectedIndex > 0) {
82
+ if (value?.externalVideoUrl) {
83
+ if (selectedIndex === 1) {
84
+ setSelectedId("video");
91
85
  } else {
92
- const prevIndex = selectedIndex - 1;
93
- setSelectedId(value?.medias?.[prevIndex]?.id || "");
86
+ const prevIndex = selectedIndex - 2;
87
+ setSelectedId(value.medias?.[prevIndex]?.id || "");
94
88
  }
89
+ } else {
90
+ const prevIndex = selectedIndex - 1;
91
+ setSelectedId(value?.medias?.[prevIndex]?.id || "");
95
92
  }
96
- }, [selectedIndex, value]);
93
+ }
94
+ }, [selectedIndex, value]);
97
95
 
98
- const handleNext = useCallback(() => {
99
- if (selectedIndex < totalItems - 1) {
100
- if (value?.externalVideoUrl) {
101
- if (selectedId === "video") {
102
- setSelectedId(value.medias?.[0]?.id || "");
103
- } else {
104
- const currentMediaIndex =
105
- value.medias?.findIndex((media) => media.id === selectedId) || 0;
106
- const nextIndex = currentMediaIndex + 1;
107
- setSelectedId(value.medias?.[nextIndex]?.id || "");
108
- }
96
+ const handleNext = useCallback(() => {
97
+ if (selectedIndex < totalItems - 1) {
98
+ if (value?.externalVideoUrl) {
99
+ if (selectedId === "video") {
100
+ setSelectedId(value.medias?.[0]?.id || "");
109
101
  } else {
110
102
  const currentMediaIndex =
111
- value?.medias?.findIndex((media) => media.id === selectedId) || 0;
103
+ value.medias?.findIndex((media) => media.id === selectedId) || 0;
112
104
  const nextIndex = currentMediaIndex + 1;
113
- setSelectedId(value?.medias?.[nextIndex]?.id || "");
105
+ setSelectedId(value.medias?.[nextIndex]?.id || "");
114
106
  }
107
+ } else {
108
+ const currentMediaIndex =
109
+ value?.medias?.findIndex((media) => media.id === selectedId) || 0;
110
+ const nextIndex = currentMediaIndex + 1;
111
+ setSelectedId(value?.medias?.[nextIndex]?.id || "");
115
112
  }
116
- }, [selectedIndex, totalItems, value, selectedId]);
113
+ }
114
+ }, [selectedIndex, totalItems, value, selectedId]);
117
115
 
118
- const handleKeyDown = useCallback(
119
- (e: KeyboardEvent) => {
120
- if (e.key === "ArrowLeft") {
121
- handlePrevious();
122
- } else if (e.key === "ArrowRight") {
123
- handleNext();
124
- }
125
- },
126
- [handleNext, handlePrevious]
127
- );
116
+ const handleKeyDown = useCallback(
117
+ (e: KeyboardEvent) => {
118
+ if (e.key === "ArrowLeft") {
119
+ handlePrevious();
120
+ } else if (e.key === "ArrowRight") {
121
+ handleNext();
122
+ }
123
+ },
124
+ [handleNext, handlePrevious]
125
+ );
128
126
 
129
- useEffect(() => {
130
- window.addEventListener("keydown", handleKeyDown);
131
- return () => window.removeEventListener("keydown", handleKeyDown);
132
- }, [handleKeyDown]);
127
+ useEffect(() => {
128
+ window.addEventListener("keydown", handleKeyDown);
129
+ return () => window.removeEventListener("keydown", handleKeyDown);
130
+ }, [handleKeyDown]);
133
131
 
134
- const canPrevious = selectedIndex > 0;
135
- const canNext = selectedIndex < totalItems - 1;
132
+ const canPrevious = selectedIndex > 0;
133
+ const canNext = selectedIndex < totalItems - 1;
134
+
135
+ return (
136
+ <div ref={ref} className={clsx("flex flex-col", className)} {...rest}>
137
+ {children}
138
+ {/* Main display area */}
139
+ <div className={clsx("relative group")}>
140
+ {canPrevious && (
141
+ <button
142
+ onClick={handlePrevious}
143
+ className="absolute left-4 top-1/2 transform -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
144
+ aria-label="Previous slide"
145
+ >
146
+ <svg
147
+ xmlns="http://www.w3.org/2000/svg"
148
+ className="h-6 w-6"
149
+ fill="none"
150
+ viewBox="0 0 24 24"
151
+ stroke="currentColor"
152
+ >
153
+ <path
154
+ strokeLinecap="round"
155
+ strokeLinejoin="round"
156
+ strokeWidth={2}
157
+ d="M15 19l-7-7 7-7"
158
+ />
159
+ </svg>
160
+ </button>
161
+ )}
162
+ {canNext && (
163
+ <button
164
+ onClick={handleNext}
165
+ className="absolute right-4 top-1/2 transform -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
166
+ aria-label="Next slide"
167
+ >
168
+ <svg
169
+ xmlns="http://www.w3.org/2000/svg"
170
+ className="h-6 w-6"
171
+ fill="none"
172
+ viewBox="0 0 24 24"
173
+ stroke="currentColor"
174
+ >
175
+ <path
176
+ strokeLinecap="round"
177
+ strokeLinejoin="round"
178
+ strokeWidth={2}
179
+ d="M9 5l7 7-7 7"
180
+ />
181
+ </svg>
182
+ </button>
183
+ )}
184
+ {value?.externalVideoUrl && selectedId === "video" && (
185
+ <div className={clsx("w-full rounded-md overflow-hidden", aspect)}>
186
+ <iframe
187
+ src={videoUrl}
188
+ className="w-full h-full"
189
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; autoplay"
190
+ referrerPolicy="strict-origin-when-cross-origin"
191
+ allowFullScreen
192
+ />
193
+ </div>
194
+ )}
195
+ {value?.medias?.map((media) => (
196
+ <div
197
+ key={media.id}
198
+ className={clsx(
199
+ "transition-opacity duration-300 overflow-hidden rounded-md",
200
+ media.id === selectedId ? "opacity-100" : "opacity-0 hidden",
201
+ aspect
202
+ )}
203
+ >
204
+ <img
205
+ src={media?.resize || media?.url}
206
+ alt={media?.alt}
207
+ className="w-full h-full object-cover object-center"
208
+ />
209
+ </div>
210
+ ))}
211
+ </div>
136
212
 
137
- return (
138
- <div ref={ref} className={clsx("flex flex-col", className)} {...rest}>
139
- {children}
140
- {/* Main display area */}
141
- <div className={clsx("relative group")}>
142
- {canPrevious && (
213
+ {/* Thumbnail navigation */}
214
+ <div className="relative mt-4">
215
+ <div className="flex items-stretch gap-2">
216
+ {totalItems > 6 && (
143
217
  <button
144
218
  onClick={handlePrevious}
145
- className="absolute left-4 top-1/2 transform -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
146
- aria-label="Previous slide"
219
+ disabled={!canPrevious}
220
+ className={clsx(
221
+ "flex items-center justify-center w-6",
222
+ "transition-colors duration-200 rounded-l-md",
223
+ !canPrevious
224
+ ? "bg-gray-100 text-gray-400 cursor-not-allowed"
225
+ : "bg-gray-200 hover:bg-gray-300 text-gray-700"
226
+ )}
227
+ aria-label="Previous"
147
228
  >
148
229
  <svg
149
230
  xmlns="http://www.w3.org/2000/svg"
150
- className="h-6 w-6"
231
+ className="h-4 w-4 md:h-5 md:w-5"
151
232
  fill="none"
152
233
  viewBox="0 0 24 24"
153
234
  stroke="currentColor"
@@ -161,15 +242,88 @@ export const Medias = forwardRef<HTMLDivElement, MediasProps>(
161
242
  </svg>
162
243
  </button>
163
244
  )}
164
- {canNext && (
245
+ <div className="flex-1">
246
+ <div className="grid grid-cols-6 gap-2">
247
+ {value?.externalVideoUrl && startIndex === 0 && (
248
+ <div
249
+ className={clsx(
250
+ "relative cursor-pointer overflow-hidden rounded-sm border-2",
251
+ thumbnailAspect,
252
+ selectedId === "video"
253
+ ? "border-primary-500"
254
+ : "border-transparent hover:border-primary-300"
255
+ )}
256
+ onClick={() => setSelectedId("video")}
257
+ >
258
+ <img
259
+ src={thumbnailUrl}
260
+ alt="Video thumbnail"
261
+ className="w-full h-full object-cover"
262
+ />
263
+ <div className="absolute inset-0 flex items-center justify-center bg-black/30">
264
+ <svg
265
+ xmlns="http://www.w3.org/2000/svg"
266
+ className="h-8 w-8 text-white"
267
+ viewBox="0 0 24 24"
268
+ >
269
+ <path
270
+ fill="currentColor"
271
+ fill-rule="evenodd"
272
+ d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10"
273
+ clipRule="evenodd"
274
+ opacity="0.5"
275
+ />
276
+ <path
277
+ fill="currentColor"
278
+ d="m15.414 13.059l-4.72 2.787C9.934 16.294 9 15.71 9 14.786V9.214c0-.924.934-1.507 1.694-1.059l4.72 2.787c.781.462.781 1.656 0 2.118"
279
+ />
280
+ </svg>
281
+ </div>
282
+ </div>
283
+ )}
284
+ {value?.medias?.map((media, index) => {
285
+ const adjustedIndex = value?.externalVideoUrl
286
+ ? index + 1
287
+ : index;
288
+ return adjustedIndex >= startIndex &&
289
+ adjustedIndex < endIndex ? (
290
+ <div
291
+ key={media.id}
292
+ className={clsx(
293
+ "relative cursor-pointer overflow-hidden rounded-sm border-2",
294
+ thumbnailAspect,
295
+ selectedId === media.id
296
+ ? "border-primary-500"
297
+ : "border-transparent hover:border-primary-300"
298
+ )}
299
+ onClick={() => setSelectedId(media.id)}
300
+ >
301
+ <img
302
+ src={media?.resize || media?.url}
303
+ alt={media?.alt}
304
+ className="w-full h-full object-cover"
305
+ />
306
+ </div>
307
+ ) : null;
308
+ })}
309
+ </div>
310
+ </div>
311
+ {totalItems > 6 && (
165
312
  <button
166
313
  onClick={handleNext}
167
- className="absolute right-4 top-1/2 transform -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
168
- aria-label="Next slide"
314
+ disabled={!canNext}
315
+ className={clsx(
316
+ "flex items-center justify-center w-6",
317
+ "transition-colors duration-200 rounded-r-md",
318
+ !canNext
319
+ ? "bg-gray-100 text-gray-400 cursor-not-allowed"
320
+ : "bg-gray-200 hover:bg-gray-300 text-gray-700"
321
+ )}
322
+ aria-label="Next"
169
323
  >
170
324
  <svg
171
325
  xmlns="http://www.w3.org/2000/svg"
172
- className="h-6 w-6"
326
+ className="h-4 w-4 md:h-5 md:w-5"
173
327
  fill="none"
174
328
  viewBox="0 0 24 24"
175
329
  stroke="currentColor"
@@ -183,165 +337,8 @@ export const Medias = forwardRef<HTMLDivElement, MediasProps>(
183
337
  </svg>
184
338
  </button>
185
339
  )}
186
- {value?.externalVideoUrl && selectedId === "video" && (
187
- <div className={clsx("w-full rounded-md overflow-hidden", aspect)}>
188
- <iframe
189
- src={videoUrl}
190
- className="w-full h-full"
191
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; autoplay"
192
- referrerPolicy="strict-origin-when-cross-origin"
193
- allowFullScreen
194
- />
195
- </div>
196
- )}
197
- {value?.medias?.map((media) => (
198
- <div
199
- key={media.id}
200
- className={clsx(
201
- "transition-opacity duration-300 overflow-hidden rounded-md",
202
- media.id === selectedId ? "opacity-100" : "opacity-0 hidden",
203
- aspect
204
- )}
205
- >
206
- <img
207
- src={media?.resize || media?.url}
208
- alt={media?.alt}
209
- className="w-full h-full object-cover object-center"
210
- />
211
- </div>
212
- ))}
213
- </div>
214
-
215
- {/* Thumbnail navigation */}
216
- <div className="relative mt-4">
217
- <div className="flex items-stretch gap-2">
218
- {totalItems > 6 && (
219
- <button
220
- onClick={handlePrevious}
221
- disabled={!canPrevious}
222
- className={clsx(
223
- "flex items-center justify-center w-6",
224
- "transition-colors duration-200 rounded-l-md",
225
- !canPrevious
226
- ? "bg-gray-100 text-gray-400 cursor-not-allowed"
227
- : "bg-gray-200 hover:bg-gray-300 text-gray-700"
228
- )}
229
- aria-label="Previous"
230
- >
231
- <svg
232
- xmlns="http://www.w3.org/2000/svg"
233
- className="h-4 w-4 md:h-5 md:w-5"
234
- fill="none"
235
- viewBox="0 0 24 24"
236
- stroke="currentColor"
237
- >
238
- <path
239
- strokeLinecap="round"
240
- strokeLinejoin="round"
241
- strokeWidth={2}
242
- d="M15 19l-7-7 7-7"
243
- />
244
- </svg>
245
- </button>
246
- )}
247
- <div className="flex-1">
248
- <div className="grid grid-cols-6 gap-2">
249
- {value?.externalVideoUrl && startIndex === 0 && (
250
- <div
251
- className={clsx(
252
- "relative cursor-pointer overflow-hidden rounded-sm border-2",
253
- aspect,
254
- selectedId === "video"
255
- ? "border-primary-500"
256
- : "border-transparent hover:border-primary-300"
257
- )}
258
- onClick={() => setSelectedId("video")}
259
- >
260
- <img
261
- src={thumbnailUrl}
262
- alt="Video thumbnail"
263
- className="w-full h-full object-cover"
264
- />
265
- <div className="absolute inset-0 flex items-center justify-center bg-black/30">
266
- <svg
267
- xmlns="http://www.w3.org/2000/svg"
268
- className="h-8 w-8 text-white"
269
- viewBox="0 0 24 24"
270
- >
271
- <path
272
- fill="currentColor"
273
- fill-rule="evenodd"
274
- d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10"
275
- clipRule="evenodd"
276
- opacity="0.5"
277
- />
278
- <path
279
- fill="currentColor"
280
- d="m15.414 13.059l-4.72 2.787C9.934 16.294 9 15.71 9 14.786V9.214c0-.924.934-1.507 1.694-1.059l4.72 2.787c.781.462.781 1.656 0 2.118"
281
- />
282
- </svg>
283
- </div>
284
- </div>
285
- )}
286
- {value?.medias?.map((media, index) => {
287
- const adjustedIndex = value?.externalVideoUrl
288
- ? index + 1
289
- : index;
290
- return adjustedIndex >= startIndex &&
291
- adjustedIndex < endIndex ? (
292
- <div
293
- key={media.id}
294
- className={clsx(
295
- "relative cursor-pointer overflow-hidden rounded-sm border-2",
296
- aspect,
297
- selectedId === media.id
298
- ? "border-primary-500"
299
- : "border-transparent hover:border-primary-300"
300
- )}
301
- onClick={() => setSelectedId(media.id)}
302
- >
303
- <img
304
- src={media?.resize || media?.url}
305
- alt={media?.alt}
306
- className="w-full h-full object-cover"
307
- />
308
- </div>
309
- ) : null;
310
- })}
311
- </div>
312
- </div>
313
- {totalItems > 6 && (
314
- <button
315
- onClick={handleNext}
316
- disabled={!canNext}
317
- className={clsx(
318
- "flex items-center justify-center w-6",
319
- "transition-colors duration-200 rounded-r-md",
320
- !canNext
321
- ? "bg-gray-100 text-gray-400 cursor-not-allowed"
322
- : "bg-gray-200 hover:bg-gray-300 text-gray-700"
323
- )}
324
- aria-label="Next"
325
- >
326
- <svg
327
- xmlns="http://www.w3.org/2000/svg"
328
- className="h-4 w-4 md:h-5 md:w-5"
329
- fill="none"
330
- viewBox="0 0 24 24"
331
- stroke="currentColor"
332
- >
333
- <path
334
- strokeLinecap="round"
335
- strokeLinejoin="round"
336
- strokeWidth={2}
337
- d="M9 5l7 7-7 7"
338
- />
339
- </svg>
340
- </button>
341
- )}
342
- </div>
343
340
  </div>
344
341
  </div>
345
- );
346
- }
347
- );
342
+ </div>
343
+ );
344
+ });
@@ -25,7 +25,7 @@ export function ProductCta(props: {
25
25
 
26
26
  const handleClick = () => {
27
27
  if (ref.current) {
28
- popover.open(roleProps[DATA_POPUP_CTA] || openableKey, ref.current);
28
+ popover.open(openableKey, ref.current);
29
29
  }
30
30
  };
31
31
 
@@ -1,5 +1,5 @@
1
1
  import { extractOutline, mdxToSlate } from "@rxdrag/slate-preview";
2
- import { forwardRef, ReactNode, useEffect } from "react";
2
+ import { forwardRef, useEffect } from "react";
3
3
  import { useAcitviedHeading } from "./useAcitviedHeading";
4
4
  import clsx from "clsx";
5
5
 
@@ -16,8 +16,8 @@ export const RichTextOutline = forwardRef<
16
16
  HTMLUListElement,
17
17
  RichTextOutlineProps
18
18
  >((props, ref) => {
19
- const { className, itemClassName, value, yOffset = 100, ...rest } = props;
20
- const activiedId = useAcitviedHeading();
19
+ const { className, itemClassName, value, yOffset = 200, ...rest } = props;
20
+ const activiedId = useAcitviedHeading(yOffset);
21
21
  const nodes = mdxToSlate(value ?? "");
22
22
  const outline = extractOutline(nodes ?? []);
23
23
 
@@ -38,7 +38,7 @@ export const RichTextOutline = forwardRef<
38
38
  return () => {
39
39
  window.removeEventListener("hashchange", handleHashChange);
40
40
  };
41
- }, []);
41
+ }, [yOffset]);
42
42
 
43
43
  return outline?.length ? (
44
44
  <ul ref={ref} className={className} {...rest}>
@@ -57,7 +57,6 @@ export const RichTextOutline = forwardRef<
57
57
  e.preventDefault();
58
58
  const element = document.getElementById(item?.key);
59
59
  if (element) {
60
- const yOffset = -100; // 偏移量
61
60
  const y =
62
61
  element.getBoundingClientRect().top +
63
62
  window.scrollY +
@@ -1,54 +1,81 @@
1
- import { useCallback, useEffect, useState } from 'react';
2
-
3
- export function useAcitviedHeading() {
4
- const [activeId, setActiveId] = useState<string | null>(null);
5
-
6
- const handleScroll = useCallback(() => {
7
- const anchorElements = document.querySelectorAll('a[href^="#"]');
8
- let closestId = null;
9
- let closestDistance = Infinity;
10
-
11
- anchorElements.forEach((element) => {
12
- const id = element.getAttribute('href')?.slice(1) || '';
13
- const targetElement = document.getElementById(id);
14
- if (targetElement) {
15
- const { top } = targetElement.getBoundingClientRect();
16
- const distance = Math.abs(top);
17
- // 寻找最接近视口顶部但不超过视口顶部的锚点
18
- if (top <= 160 && distance < closestDistance) {
19
- closestId = id;
20
- closestDistance = distance;
21
- }
22
- }
23
- });
24
-
25
- setActiveId(closestId);
26
- }, []);
27
-
28
- useEffect(() => {
29
- const handleHashChange = () => {
30
- const hash = window.location.hash.substring(1); // 获取哈希值(去掉#)
31
- setActiveId(hash);
32
-
33
- const element = document.getElementById(hash);
34
- if (element) {
35
- const yOffset = -100; // 偏移量
36
- const y = element.getBoundingClientRect().top + window.scrollY - yOffset;
37
- window.scrollTo({ top: y, behavior: 'smooth' });
38
- }
39
- };
40
-
41
- window.addEventListener('hashchange', handleHashChange);
42
- window.addEventListener('scroll', handleScroll);
43
-
44
- // 初始化时检查当前哈希值
45
- handleHashChange();
46
-
47
- return () => {
48
- window.removeEventListener('hashchange', handleHashChange);
49
- window.removeEventListener('scroll', handleScroll);
50
- };
51
- }, [handleScroll]);
52
-
53
- return activeId;
54
- }
1
+ import { useCallback, useEffect, useState, useRef, useMemo } from "react";
2
+ import { throttle } from "lodash-es";
3
+
4
+ export function useAcitviedHeading(yOffset = 200) {
5
+ const [activeId, setActiveId] = useState<string | null>(null);
6
+ const anchorElementsRef = useRef<HTMLAnchorElement[]>([]);
7
+ const lastScrollTopRef = useRef(0);
8
+ const activeIdRef = useRef<string | null>(null);
9
+
10
+ // 同步 activeId 到 ref
11
+ useEffect(() => {
12
+ activeIdRef.current = activeId;
13
+ }, [activeId]);
14
+
15
+ // 初始化时获取所有锚点元素
16
+ useEffect(() => {
17
+ anchorElementsRef.current = Array.from(
18
+ document.querySelectorAll('a[href^="#"]')
19
+ );
20
+ }, []);
21
+
22
+ const handleScroll = useCallback(() => {
23
+ const scrollTop = window.scrollY;
24
+ lastScrollTopRef.current = scrollTop;
25
+
26
+ let closestId = null;
27
+ let closestDistance = Infinity;
28
+
29
+ anchorElementsRef.current.forEach((element) => {
30
+ const id = element.getAttribute("href")?.slice(1) || "";
31
+ const targetElement = document.getElementById(id);
32
+
33
+ if (targetElement) {
34
+ const { top } = targetElement.getBoundingClientRect();
35
+ const distance = Math.abs(top);
36
+ if (top <= yOffset && distance < closestDistance) {
37
+ closestId = id;
38
+ closestDistance = distance;
39
+ }
40
+ }
41
+ });
42
+
43
+ if (activeIdRef.current !== closestId) {
44
+ setActiveId(closestId);
45
+ }
46
+ }, [yOffset]); // 移除 activeId 依赖,因为我们只需要比较当前值
47
+
48
+ // 使用节流函数包装handleScroll
49
+ const throttledHandleScroll = useMemo(
50
+ () => throttle(handleScroll, 100, { leading: true, trailing: true }),
51
+ [handleScroll]
52
+ );
53
+
54
+ useEffect(() => {
55
+ const handleHashChange = () => {
56
+ const hash = window.location.hash.substring(1);
57
+ setActiveId(hash);
58
+
59
+ const element = document.getElementById(hash);
60
+ if (element) {
61
+ const y =
62
+ element.getBoundingClientRect().top + window.scrollY - yOffset;
63
+ window.scrollTo({ top: y, behavior: "smooth" });
64
+ }
65
+ };
66
+
67
+ window.addEventListener("hashchange", handleHashChange);
68
+ window.addEventListener("scroll", throttledHandleScroll);
69
+
70
+ // 初始化时检查当前哈希值
71
+ handleHashChange();
72
+
73
+ return () => {
74
+ window.removeEventListener("hashchange", handleHashChange);
75
+ window.removeEventListener("scroll", throttledHandleScroll);
76
+ throttledHandleScroll.cancel(); // 清理节流函数
77
+ };
78
+ }, [handleScroll, throttledHandleScroll, yOffset]);
79
+
80
+ return activeId;
81
+ }