@arronqzy/react-view 1.0.22 → 1.0.23
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 +11 -11
- package/src/panel/ReactViewPanel.tsx +109 -965
- package/src/panel/components/PanelConfigSidebar.tsx +297 -3712
- package/src/panel/components/PanelHistoryPopover.tsx +163 -0
- package/src/panel/components/PanelLayerDock.tsx +412 -0
- package/src/panel/components/PanelMenubar.tsx +149 -0
- package/src/panel/components/PanelWorkspaceDialogs.tsx +271 -0
- package/src/panel/components/config/PanelConfigAudioSection.tsx +334 -0
- package/src/panel/components/config/PanelConfigChartSection.tsx +799 -0
- package/src/panel/components/config/PanelConfigGeometrySection.tsx +233 -0
- package/src/panel/components/config/PanelConfigGridChildSpan.tsx +70 -0
- package/src/panel/components/config/PanelConfigGridSection.tsx +118 -0
- package/src/panel/components/config/PanelConfigMultiSelect.tsx +1291 -0
- package/src/panel/components/config/PanelConfigNodeInfo.tsx +169 -0
- package/src/panel/components/config/PanelConfigReferenceSection.tsx +99 -0
- package/src/panel/components/config/PanelConfigScene3dSection.tsx +6 -2
- package/src/panel/components/config/PanelConfigStyleSections.tsx +249 -0
- package/src/panel/components/config/PanelConfigTextSection.tsx +268 -0
- package/src/panel/components/config/PanelConfigVideoSection.tsx +133 -0
- package/src/panel/components/config/PanelConfigViewportSection.tsx +139 -0
- package/src/panel/components/config/helpers.ts +85 -0
- package/src/panel/icons.tsx +160 -0
- package/src/panel/utils/panelSelection.ts +57 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
} from "react";
|
|
7
|
+
import { useI18n } from "@arronqzy/i18n/react";
|
|
8
|
+
import {
|
|
9
|
+
Checkbox,
|
|
10
|
+
Input,
|
|
11
|
+
Select,
|
|
12
|
+
SelectContent,
|
|
13
|
+
SelectItem,
|
|
14
|
+
SelectTrigger,
|
|
15
|
+
SelectValue,
|
|
16
|
+
} from "@arronqzy/ui";
|
|
17
|
+
import type { PanelElement } from "../../types";
|
|
18
|
+
import { ConfigHintIcon } from "../ConfigHintIcon";
|
|
19
|
+
import { readFileAsDataUrl, runBusyTask } from "../../utils/async-work";
|
|
20
|
+
import { getPanelMessages } from "../../constants/messages";
|
|
21
|
+
import { type ConfigSectionHelpers, type UpdateElement } from "./helpers";
|
|
22
|
+
|
|
23
|
+
export function PanelConfigAudioSection({
|
|
24
|
+
element,
|
|
25
|
+
helpers,
|
|
26
|
+
updateElement,
|
|
27
|
+
}: {
|
|
28
|
+
element: PanelElement;
|
|
29
|
+
helpers: ConfigSectionHelpers;
|
|
30
|
+
updateElement: UpdateElement;
|
|
31
|
+
}) {
|
|
32
|
+
const { t } = useI18n();
|
|
33
|
+
const messages = React.useMemo(() => getPanelMessages(t), [t]);
|
|
34
|
+
const { renderSection, renderFieldGroup, renderColorField } = helpers;
|
|
35
|
+
const selectedElement = element;
|
|
36
|
+
const [audioStatus, setAudioStatus] = useState("");
|
|
37
|
+
const [isRecordingAudio, setIsRecordingAudio] = useState(false);
|
|
38
|
+
const recorderRef = useRef<MediaRecorder | null>(null);
|
|
39
|
+
const recordStreamRef = useRef<MediaStream | null>(null);
|
|
40
|
+
const audioChunksRef = useRef<BlobPart[]>([]);
|
|
41
|
+
|
|
42
|
+
useEffect(
|
|
43
|
+
() => () => {
|
|
44
|
+
recordStreamRef.current?.getTracks().forEach((track) => track.stop());
|
|
45
|
+
},
|
|
46
|
+
[]
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const updateSelectedAudio = useCallback(
|
|
50
|
+
(patch: Partial<PanelElement>) => {
|
|
51
|
+
if (selectedElement.materialType !== "audio") return;
|
|
52
|
+
updateElement(selectedElement.id, patch);
|
|
53
|
+
},
|
|
54
|
+
[selectedElement, updateElement]
|
|
55
|
+
);
|
|
56
|
+
const handleUploadAudioFile = useCallback(
|
|
57
|
+
async (file: File) => {
|
|
58
|
+
if (selectedElement.materialType !== "audio") return;
|
|
59
|
+
try {
|
|
60
|
+
await runBusyTask(t("common.uploadingFile"), async () => {
|
|
61
|
+
const base64 = await readFileAsDataUrl(file, messages.readAudioFailed, "audio");
|
|
62
|
+
updateSelectedAudio({ audioSrc: base64 });
|
|
63
|
+
setAudioStatus(messages.audioLocalSaved);
|
|
64
|
+
try {
|
|
65
|
+
const form = new FormData();
|
|
66
|
+
form.append("file", file);
|
|
67
|
+
const resp = await fetch("/api/upload", { method: "POST", body: form });
|
|
68
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
69
|
+
const data = (await resp.json()) as { url?: string };
|
|
70
|
+
if (data.url) {
|
|
71
|
+
updateSelectedAudio({ audioRemoteUrl: data.url });
|
|
72
|
+
setAudioStatus(messages.audioRemoteUploaded);
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
setAudioStatus(messages.audioServerUploadFailed);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
setAudioStatus(
|
|
80
|
+
error instanceof Error ? error.message : messages.readAudioFailed
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
[messages, selectedElement, t, updateSelectedAudio]
|
|
85
|
+
);
|
|
86
|
+
const handleUploadAudioPoster = useCallback(
|
|
87
|
+
async (file: File) => {
|
|
88
|
+
if (selectedElement.materialType !== "audio") return;
|
|
89
|
+
try {
|
|
90
|
+
const base64 = await readFileAsDataUrl(file, messages.readImageFailed, "image");
|
|
91
|
+
updateSelectedAudio({ audioPosterImage: base64 });
|
|
92
|
+
setAudioStatus(messages.audioPosterSet);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
setAudioStatus(
|
|
95
|
+
error instanceof Error ? error.message : messages.readImageFailed
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
[messages, selectedElement, updateSelectedAudio]
|
|
100
|
+
);
|
|
101
|
+
const stopRecordingAudio = useCallback(() => {
|
|
102
|
+
recorderRef.current?.stop();
|
|
103
|
+
}, []);
|
|
104
|
+
const startRecordingAudio = useCallback(async () => {
|
|
105
|
+
if (selectedElement.materialType !== "audio") return;
|
|
106
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
107
|
+
setAudioStatus(messages.audioRecordUnsupported);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
112
|
+
recordStreamRef.current = stream;
|
|
113
|
+
const recorder = new MediaRecorder(stream);
|
|
114
|
+
recorderRef.current = recorder;
|
|
115
|
+
audioChunksRef.current = [];
|
|
116
|
+
recorder.ondataavailable = (event) => {
|
|
117
|
+
if (event.data.size > 0) audioChunksRef.current.push(event.data);
|
|
118
|
+
};
|
|
119
|
+
recorder.onstop = async () => {
|
|
120
|
+
const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
|
|
121
|
+
try {
|
|
122
|
+
const dataUrl = await readFileAsDataUrl(blob, messages.readRecordAudioFailed, "audio");
|
|
123
|
+
updateSelectedAudio({ audioSrc: dataUrl });
|
|
124
|
+
setAudioStatus(messages.audioRecordSaved);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
setAudioStatus(
|
|
127
|
+
error instanceof Error ? error.message : messages.readRecordAudioFailed
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
recordStreamRef.current?.getTracks().forEach((track) => track.stop());
|
|
131
|
+
recordStreamRef.current = null;
|
|
132
|
+
recorderRef.current = null;
|
|
133
|
+
setIsRecordingAudio(false);
|
|
134
|
+
};
|
|
135
|
+
recorder.start();
|
|
136
|
+
setIsRecordingAudio(true);
|
|
137
|
+
setAudioStatus(messages.audioRecording);
|
|
138
|
+
} catch {
|
|
139
|
+
setAudioStatus(messages.audioRecordStartFailed);
|
|
140
|
+
setIsRecordingAudio(false);
|
|
141
|
+
}
|
|
142
|
+
}, [messages, selectedElement, updateSelectedAudio]);
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<>
|
|
146
|
+
{
|
|
147
|
+
renderSection(
|
|
148
|
+
"audioConfig",
|
|
149
|
+
t("panel.config.sectionAudio"),
|
|
150
|
+
<>
|
|
151
|
+
{renderFieldGroup(
|
|
152
|
+
t("panel.config.groupAudioSource"),
|
|
153
|
+
<>
|
|
154
|
+
<label className="block space-y-1">
|
|
155
|
+
<div>{t("panel.config.audioUrl")}</div>
|
|
156
|
+
<Input
|
|
157
|
+
value={selectedElement.audioRemoteUrl ?? ""}
|
|
158
|
+
onChange={(e) =>
|
|
159
|
+
updateSelectedAudio({
|
|
160
|
+
audioRemoteUrl: e.target.value || undefined,
|
|
161
|
+
audioSrc: e.target.value || selectedElement.audioSrc,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
placeholder="https://example.com/audio.mp3"
|
|
165
|
+
className="h-7"
|
|
166
|
+
/>
|
|
167
|
+
</label>
|
|
168
|
+
<div className="flex items-center gap-2">
|
|
169
|
+
<label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
|
|
170
|
+
{t("panel.config.uploadAudio")}
|
|
171
|
+
<Input
|
|
172
|
+
type="file"
|
|
173
|
+
accept="audio/*"
|
|
174
|
+
className="hidden"
|
|
175
|
+
onChange={async (e) => {
|
|
176
|
+
const file = e.target.files?.[0];
|
|
177
|
+
e.currentTarget.value = "";
|
|
178
|
+
if (!file) return;
|
|
179
|
+
await handleUploadAudioFile(file);
|
|
180
|
+
}}
|
|
181
|
+
/>
|
|
182
|
+
</label>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
className="rounded border border-border px-2 py-1 text-[11px] hover:bg-accent disabled:opacity-50"
|
|
186
|
+
onClick={isRecordingAudio ? stopRecordingAudio : startRecordingAudio}
|
|
187
|
+
>
|
|
188
|
+
{isRecordingAudio ? t("panel.config.stopRecord") : t("panel.config.startRecord")}
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
{audioStatus ? (
|
|
192
|
+
<div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
|
|
193
|
+
{audioStatus}
|
|
194
|
+
</div>
|
|
195
|
+
) : null}
|
|
196
|
+
<audio
|
|
197
|
+
controls
|
|
198
|
+
className="h-8 w-full"
|
|
199
|
+
src={selectedElement.audioSrc || selectedElement.audioRemoteUrl || ""}
|
|
200
|
+
/>
|
|
201
|
+
</>
|
|
202
|
+
)}
|
|
203
|
+
{renderFieldGroup(
|
|
204
|
+
t("panel.config.groupDisplayStyle"),
|
|
205
|
+
<>
|
|
206
|
+
<label className="flex items-center gap-2">
|
|
207
|
+
<Checkbox
|
|
208
|
+
checked={selectedElement.mediaAutoPauseOnEdit !== false}
|
|
209
|
+
className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
|
|
210
|
+
onCheckedChange={(checked) =>
|
|
211
|
+
updateSelectedAudio({
|
|
212
|
+
mediaAutoPauseOnEdit: checked !== false,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
/>
|
|
216
|
+
<span>{t("panel.config.autoPauseMedia")}</span>
|
|
217
|
+
</label>
|
|
218
|
+
<label className="block space-y-1">
|
|
219
|
+
<div>{t("panel.config.audioIconPreset")}</div>
|
|
220
|
+
<Select
|
|
221
|
+
value={selectedElement.audioIconPreset ?? "__none__"}
|
|
222
|
+
onValueChange={(value) =>
|
|
223
|
+
updateSelectedAudio({
|
|
224
|
+
audioIconPreset:
|
|
225
|
+
value === "__none__"
|
|
226
|
+
? undefined
|
|
227
|
+
: (value as "speaker" | "music" | "headphone" | "wave"),
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
>
|
|
231
|
+
<SelectTrigger className="h-7">
|
|
232
|
+
<SelectValue placeholder={t("panel.config.selectIcon")} />
|
|
233
|
+
</SelectTrigger>
|
|
234
|
+
<SelectContent>
|
|
235
|
+
<SelectItem value="__none__">{t("panel.config.iconDefaultProgress")}</SelectItem>
|
|
236
|
+
<SelectItem value="speaker">{t("panel.config.iconSpeaker")}</SelectItem>
|
|
237
|
+
<SelectItem value="music">{t("panel.config.iconMusic")}</SelectItem>
|
|
238
|
+
<SelectItem value="headphone">{t("panel.config.iconHeadphone")}</SelectItem>
|
|
239
|
+
<SelectItem value="wave">{t("panel.config.iconWave")}</SelectItem>
|
|
240
|
+
</SelectContent>
|
|
241
|
+
</Select>
|
|
242
|
+
</label>
|
|
243
|
+
<label className="block space-y-1">
|
|
244
|
+
<div>{t("panel.config.visualEffect")}</div>
|
|
245
|
+
<Select
|
|
246
|
+
value={selectedElement.audioVisualEffect ?? "pulse"}
|
|
247
|
+
onValueChange={(value) =>
|
|
248
|
+
updateSelectedAudio({
|
|
249
|
+
audioVisualEffect: value as "none" | "pulse" | "ripple",
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
>
|
|
253
|
+
<SelectTrigger className="h-7">
|
|
254
|
+
<SelectValue placeholder={t("panel.config.selectEffect")} />
|
|
255
|
+
</SelectTrigger>
|
|
256
|
+
<SelectContent>
|
|
257
|
+
<SelectItem value="none">{t("panel.config.effectNone")}</SelectItem>
|
|
258
|
+
<SelectItem value="pulse">{t("panel.config.effectPulse")}</SelectItem>
|
|
259
|
+
<SelectItem value="ripple">{t("panel.config.effectRipple")}</SelectItem>
|
|
260
|
+
</SelectContent>
|
|
261
|
+
</Select>
|
|
262
|
+
</label>
|
|
263
|
+
<label className="block space-y-1">
|
|
264
|
+
<div>{t("panel.config.effectSpeed")}</div>
|
|
265
|
+
<Select
|
|
266
|
+
value={selectedElement.audioVisualSpeed ?? "normal"}
|
|
267
|
+
onValueChange={(value) =>
|
|
268
|
+
updateSelectedAudio({
|
|
269
|
+
audioVisualSpeed: value as "slow" | "normal" | "fast",
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
>
|
|
273
|
+
<SelectTrigger className="h-7">
|
|
274
|
+
<SelectValue placeholder={t("panel.config.selectSpeed")} />
|
|
275
|
+
</SelectTrigger>
|
|
276
|
+
<SelectContent>
|
|
277
|
+
<SelectItem value="slow">{t("panel.config.speedSlow")}</SelectItem>
|
|
278
|
+
<SelectItem value="normal">{t("panel.config.speedNormal")}</SelectItem>
|
|
279
|
+
<SelectItem value="fast">{t("panel.config.speedFast")}</SelectItem>
|
|
280
|
+
</SelectContent>
|
|
281
|
+
</Select>
|
|
282
|
+
</label>
|
|
283
|
+
<div className="flex items-center gap-2">
|
|
284
|
+
<label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
|
|
285
|
+
{t("panel.config.uploadPoster")}
|
|
286
|
+
<Input
|
|
287
|
+
type="file"
|
|
288
|
+
accept="image/*"
|
|
289
|
+
className="hidden"
|
|
290
|
+
onChange={async (e) => {
|
|
291
|
+
const file = e.target.files?.[0];
|
|
292
|
+
e.currentTarget.value = "";
|
|
293
|
+
if (!file) return;
|
|
294
|
+
await handleUploadAudioPoster(file);
|
|
295
|
+
}}
|
|
296
|
+
/>
|
|
297
|
+
</label>
|
|
298
|
+
<button
|
|
299
|
+
type="button"
|
|
300
|
+
className="rounded border border-border px-2 py-1 text-[11px] hover:bg-accent"
|
|
301
|
+
onClick={() =>
|
|
302
|
+
updateSelectedAudio({
|
|
303
|
+
audioPosterImage: undefined,
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
>
|
|
307
|
+
{t("panel.config.clearPoster")}
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
310
|
+
{selectedElement.audioPosterImage ? (
|
|
311
|
+
<img
|
|
312
|
+
src={selectedElement.audioPosterImage}
|
|
313
|
+
alt={t("panel.config.audioPosterAlt")}
|
|
314
|
+
className="h-20 w-full rounded border border-border/60 object-cover"
|
|
315
|
+
/>
|
|
316
|
+
) : null}
|
|
317
|
+
<div className="flex items-center gap-1">
|
|
318
|
+
<div className="text-[11px] text-muted-foreground">{t("panel.config.audioPoster")}</div>
|
|
319
|
+
<ConfigHintIcon label={t("panel.config.audioPoster")}>
|
|
320
|
+
{t("panel.config.audioPosterHint")}
|
|
321
|
+
</ConfigHintIcon>
|
|
322
|
+
</div>
|
|
323
|
+
</>,
|
|
324
|
+
undefined,
|
|
325
|
+
{ groupKey: "audioDisplayStyle", defaultOpen: false }
|
|
326
|
+
)}
|
|
327
|
+
</>,
|
|
328
|
+
true,
|
|
329
|
+
[t("panel.material.audio"), "url", t("panel.config.searchKwUpload"), t("panel.config.searchKwRecord"), "icon", t("panel.config.searchKwPoster"), t("panel.config.effect"), t("panel.config.searchKwAutoPause"), "media"]
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
</>
|
|
333
|
+
);
|
|
334
|
+
}
|