@arronqzy/react-view 1.0.13 → 1.0.15

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 (29) hide show
  1. package/dist/styles.css +96 -4
  2. package/package.json +11 -10
  3. package/src/panel/ReactViewOnlinePreview.tsx +31 -2
  4. package/src/panel/ReactViewPanel.tsx +186 -124
  5. package/src/panel/ai/applyAssistantAction.ts +1 -0
  6. package/src/panel/components/BusyOverlay.tsx +25 -0
  7. package/src/panel/components/ElementsLayer.tsx +110 -68
  8. package/src/panel/components/MaterialSidebar.tsx +15 -0
  9. package/src/panel/components/MoveableLayer.tsx +8 -0
  10. package/src/panel/components/PanelCanvas.tsx +3 -0
  11. package/src/panel/components/PanelConfigSidebar.tsx +140 -91
  12. package/src/panel/components/SelectLayer.tsx +5 -0
  13. package/src/panel/components/WorkspaceConfigSidebar.tsx +1 -0
  14. package/src/panel/components/WorkspaceProjectNav.tsx +27 -4
  15. package/src/panel/components/config/PanelConfigScene3dSection.tsx +874 -0
  16. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.tsx +1 -2
  17. package/src/panel/components/table/PanelConfigTableSection.tsx +82 -50
  18. package/src/panel/components/table/TableNodeContent.tsx +40 -15
  19. package/src/panel/hooks/usePanelElements.ts +13 -1
  20. package/src/panel/hooks/useWorkspaceProjects.ts +133 -66
  21. package/src/panel/library/workspace-project-cache.ts +8 -2
  22. package/src/panel/library/workspace-project-db.ts +29 -11
  23. package/src/panel/library/workspace-snapshot.ts +21 -4
  24. package/src/panel/scope/view-scope-store.ts +8 -0
  25. package/src/panel/types.ts +4 -3
  26. package/src/panel/utils/async-work.ts +178 -0
  27. package/src/panel/utils/gridPlacement.ts +29 -0
  28. package/src/panel/utils/panelElementDefaults.ts +37 -1
  29. package/src/panel/utils/scope-autocomplete.ts +0 -114
@@ -37,10 +37,10 @@ import { collectElementScopeWarnings } from "../utils/scope-template-warnings";
37
37
  import { ScopeConfigProvider } from "./scope-config/ScopeConfigContext";
38
38
  import { ScopeTemplateWarningsPanel } from "./scope-config/ScopeTemplateWarningsPanel";
39
39
  import { PanelConfigTableSection } from "./table/PanelConfigTableSection";
40
- import {
41
- highlightConfigField,
42
- subscribeRevealPanelConfig,
43
- } from "../ai/revealConfigField";
40
+ import { cssTextLineHeight, cssTextAlignStyle } from "../utils/panelElementDefaults";
41
+ import { highlightConfigField, subscribeRevealPanelConfig } from "../ai/revealConfigField";
42
+ import { PanelConfigScene3dSection } from "./config/PanelConfigScene3dSection";
43
+ import { readFileAsDataUrl, runBusyTask } from "../utils/async-work";
44
44
 
45
45
  type UpdateElement = (
46
46
  id: string,
@@ -262,31 +262,34 @@ export function PanelConfigSidebar({
262
262
  const handleUploadBackgroundImage = useCallback(
263
263
  async (file: File) => {
264
264
  if (!selectedElement) return;
265
- const base64 = await new Promise<string>((resolve, reject) => {
266
- const reader = new FileReader();
267
- reader.onload = () => resolve(String(reader.result ?? ""));
268
- reader.onerror = () => reject(new Error(messages.readImageFailed));
269
- reader.readAsDataURL(file);
270
- });
271
- updateSelectedStyle({
272
- backgroundImage: `url("${base64}")`,
273
- });
274
- setUploadStatus(t("panel.config.uploadWrittenBase64"));
275
265
  try {
276
- const form = new FormData();
277
- form.append("file", file);
278
- const resp = await fetch("/api/upload", { method: "POST", body: form });
279
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
280
- const data = (await resp.json()) as { url?: string };
281
- if (data.url) {
282
- updateSelectedStyle({ backgroundImageRemoteUrl: data.url });
283
- setUploadStatus(t("panel.config.uploadServerAndBase64"));
284
- }
285
- } catch {
286
- setUploadStatus(t("panel.config.uploadServerFailedKeepBase64"));
266
+ await runBusyTask(t("common.uploadingFile"), async () => {
267
+ const base64 = await readFileAsDataUrl(file, messages.readImageFailed, "image");
268
+ updateSelectedStyle({
269
+ backgroundImage: `url("${base64}")`,
270
+ });
271
+ setUploadStatus(t("panel.config.uploadWrittenBase64"));
272
+ try {
273
+ const form = new FormData();
274
+ form.append("file", file);
275
+ const resp = await fetch("/api/upload", { method: "POST", body: form });
276
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
277
+ const data = (await resp.json()) as { url?: string };
278
+ if (data.url) {
279
+ updateSelectedStyle({ backgroundImageRemoteUrl: data.url });
280
+ setUploadStatus(t("panel.config.uploadServerAndBase64"));
281
+ }
282
+ } catch {
283
+ setUploadStatus(t("panel.config.uploadServerFailedKeepBase64"));
284
+ }
285
+ });
286
+ } catch (error) {
287
+ setUploadStatus(
288
+ error instanceof Error ? error.message : messages.readImageFailed
289
+ );
287
290
  }
288
291
  },
289
- [selectedElement, updateSelectedStyle]
292
+ [messages.readImageFailed, selectedElement, t, updateSelectedStyle]
290
293
  );
291
294
  const updateSelectedAudio = useCallback(
292
295
  (patch: Partial<PanelElement>) => {
@@ -298,43 +301,47 @@ export function PanelConfigSidebar({
298
301
  const handleUploadAudioFile = useCallback(
299
302
  async (file: File) => {
300
303
  if (!selectedElement || selectedElement.materialType !== "audio") return;
301
- const base64 = await new Promise<string>((resolve, reject) => {
302
- const reader = new FileReader();
303
- reader.onload = () => resolve(String(reader.result ?? ""));
304
- reader.onerror = () => reject(new Error(messages.readAudioFailed));
305
- reader.readAsDataURL(file);
306
- });
307
- updateSelectedAudio({ audioSrc: base64 });
308
- setAudioStatus(messages.audioLocalSaved);
309
304
  try {
310
- const form = new FormData();
311
- form.append("file", file);
312
- const resp = await fetch("/api/upload", { method: "POST", body: form });
313
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
314
- const data = (await resp.json()) as { url?: string };
315
- if (data.url) {
316
- updateSelectedAudio({ audioRemoteUrl: data.url });
317
- setAudioStatus(messages.audioRemoteUploaded);
318
- }
319
- } catch {
320
- setAudioStatus(messages.audioServerUploadFailed);
305
+ await runBusyTask(t("common.uploadingFile"), async () => {
306
+ const base64 = await readFileAsDataUrl(file, messages.readAudioFailed, "audio");
307
+ updateSelectedAudio({ audioSrc: base64 });
308
+ setAudioStatus(messages.audioLocalSaved);
309
+ try {
310
+ const form = new FormData();
311
+ form.append("file", file);
312
+ const resp = await fetch("/api/upload", { method: "POST", body: form });
313
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
314
+ const data = (await resp.json()) as { url?: string };
315
+ if (data.url) {
316
+ updateSelectedAudio({ audioRemoteUrl: data.url });
317
+ setAudioStatus(messages.audioRemoteUploaded);
318
+ }
319
+ } catch {
320
+ setAudioStatus(messages.audioServerUploadFailed);
321
+ }
322
+ });
323
+ } catch (error) {
324
+ setAudioStatus(
325
+ error instanceof Error ? error.message : messages.readAudioFailed
326
+ );
321
327
  }
322
328
  },
323
- [selectedElement, updateSelectedAudio]
329
+ [messages, selectedElement, t, updateSelectedAudio]
324
330
  );
325
331
  const handleUploadAudioPoster = useCallback(
326
332
  async (file: File) => {
327
333
  if (!selectedElement || selectedElement.materialType !== "audio") return;
328
- const base64 = await new Promise<string>((resolve, reject) => {
329
- const reader = new FileReader();
330
- reader.onload = () => resolve(String(reader.result ?? ""));
331
- reader.onerror = () => reject(new Error(messages.readImageFailed));
332
- reader.readAsDataURL(file);
333
- });
334
- updateSelectedAudio({ audioPosterImage: base64 });
335
- setAudioStatus(messages.audioPosterSet);
334
+ try {
335
+ const base64 = await readFileAsDataUrl(file, messages.readImageFailed, "image");
336
+ updateSelectedAudio({ audioPosterImage: base64 });
337
+ setAudioStatus(messages.audioPosterSet);
338
+ } catch (error) {
339
+ setAudioStatus(
340
+ error instanceof Error ? error.message : messages.readImageFailed
341
+ );
342
+ }
336
343
  },
337
- [selectedElement, updateSelectedAudio]
344
+ [messages, selectedElement, updateSelectedAudio]
338
345
  );
339
346
  const stopRecordingAudio = useCallback(() => {
340
347
  recorderRef.current?.stop();
@@ -356,14 +363,15 @@ export function PanelConfigSidebar({
356
363
  };
357
364
  recorder.onstop = async () => {
358
365
  const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
359
- const dataUrl = await new Promise<string>((resolve, reject) => {
360
- const reader = new FileReader();
361
- reader.onload = () => resolve(String(reader.result ?? ""));
362
- reader.onerror = () => reject(new Error(messages.readRecordAudioFailed));
363
- reader.readAsDataURL(blob);
364
- });
365
- updateSelectedAudio({ audioSrc: dataUrl });
366
- setAudioStatus(messages.audioRecordSaved);
366
+ try {
367
+ const dataUrl = await readFileAsDataUrl(blob, messages.readRecordAudioFailed, "audio");
368
+ updateSelectedAudio({ audioSrc: dataUrl });
369
+ setAudioStatus(messages.audioRecordSaved);
370
+ } catch (error) {
371
+ setAudioStatus(
372
+ error instanceof Error ? error.message : messages.readRecordAudioFailed
373
+ );
374
+ }
367
375
  recordStreamRef.current?.getTracks().forEach((track) => track.stop());
368
376
  recordStreamRef.current = null;
369
377
  recorderRef.current = null;
@@ -387,29 +395,32 @@ export function PanelConfigSidebar({
387
395
  const handleUploadVideoFile = useCallback(
388
396
  async (file: File) => {
389
397
  if (!selectedElement || selectedElement.materialType !== "video") return;
390
- const base64 = await new Promise<string>((resolve, reject) => {
391
- const reader = new FileReader();
392
- reader.onload = () => resolve(String(reader.result ?? ""));
393
- reader.onerror = () => reject(new Error(messages.readVideoFailed));
394
- reader.readAsDataURL(file);
395
- });
396
- updateSelectedVideo({ videoSrc: base64 });
397
- setVideoStatus(messages.videoLocalSaved);
398
398
  try {
399
- const form = new FormData();
400
- form.append("file", file);
401
- const resp = await fetch("/api/upload", { method: "POST", body: form });
402
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
403
- const data = (await resp.json()) as { url?: string };
404
- if (data.url) {
405
- updateSelectedVideo({ videoRemoteUrl: data.url });
406
- setVideoStatus(messages.videoRemoteUploaded);
407
- }
408
- } catch {
409
- setVideoStatus(messages.videoServerUploadFailed);
399
+ await runBusyTask(t("common.uploadingFile"), async () => {
400
+ const base64 = await readFileAsDataUrl(file, messages.readVideoFailed, "video");
401
+ updateSelectedVideo({ videoSrc: base64 });
402
+ setVideoStatus(messages.videoLocalSaved);
403
+ try {
404
+ const form = new FormData();
405
+ form.append("file", file);
406
+ const resp = await fetch("/api/upload", { method: "POST", body: form });
407
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
408
+ const data = (await resp.json()) as { url?: string };
409
+ if (data.url) {
410
+ updateSelectedVideo({ videoRemoteUrl: data.url });
411
+ setVideoStatus(messages.videoRemoteUploaded);
412
+ }
413
+ } catch {
414
+ setVideoStatus(messages.videoServerUploadFailed);
415
+ }
416
+ });
417
+ } catch (error) {
418
+ setVideoStatus(
419
+ error instanceof Error ? error.message : messages.readVideoFailed
420
+ );
410
421
  }
411
422
  },
412
- [selectedElement, updateSelectedVideo]
423
+ [messages, selectedElement, t, updateSelectedVideo]
413
424
  );
414
425
 
415
426
  const isSectionExpanded = (key: string, defaultValue = true) =>
@@ -2848,7 +2859,8 @@ export function PanelConfigSidebar({
2848
2859
  <div
2849
2860
  ref={textEditorRef}
2850
2861
  data-config-field="textHtml"
2851
- className="min-h-[120px] rounded border border-border bg-background px-2 py-1.5 text-xs leading-6 outline-none"
2862
+ data-panel-user-text=""
2863
+ className="min-h-[120px] rounded border border-border bg-background px-2 py-1.5 leading-6 outline-none"
2852
2864
  style={{
2853
2865
  fontFamily: selectedElement.textFontFamily || undefined,
2854
2866
  fontSize: selectedElement.textFontSize
@@ -2856,10 +2868,8 @@ export function PanelConfigSidebar({
2856
2868
  : undefined,
2857
2869
  fontWeight: selectedElement.textFontWeight || undefined,
2858
2870
  color: selectedElement.textColor || undefined,
2859
- lineHeight: selectedElement.textLineHeight
2860
- ? String(selectedElement.textLineHeight)
2861
- : undefined,
2862
- textAlign: selectedElement.textAlign ?? "left",
2871
+ lineHeight: cssTextLineHeight(selectedElement.textLineHeight),
2872
+ ...cssTextAlignStyle(selectedElement.textAlign),
2863
2873
  }}
2864
2874
  contentEditable
2865
2875
  suppressContentEditableWarning
@@ -2935,7 +2945,7 @@ export function PanelConfigSidebar({
2935
2945
  value={selectedElement.textAlign ?? "left"}
2936
2946
  onValueChange={(value) =>
2937
2947
  updateSelectedText({
2938
- textAlign: value as "left" | "center" | "right" | "justify",
2948
+ textAlign: value as PanelElement["textAlign"],
2939
2949
  })
2940
2950
  }
2941
2951
  >
@@ -2945,19 +2955,40 @@ export function PanelConfigSidebar({
2945
2955
  <SelectContent>
2946
2956
  <SelectItem value="left">{t("panel.config.alignLeft")}</SelectItem>
2947
2957
  <SelectItem value="center">{t("panel.config.alignCenter")}</SelectItem>
2958
+ <SelectItem value="middle">{t("panel.config.alignMiddle")}</SelectItem>
2948
2959
  <SelectItem value="right">{t("panel.config.alignRight")}</SelectItem>
2949
2960
  <SelectItem value="justify">{t("panel.config.alignJustify")}</SelectItem>
2950
2961
  </SelectContent>
2951
2962
  </Select>
2952
2963
  </label>
2953
2964
  <label className="block space-y-1">
2954
- <div>{t("panel.config.lineHeight")}</div>
2965
+ <div className="flex items-center justify-between gap-2">
2966
+ <span>{t("panel.config.lineHeight")}</span>
2967
+ <label className="flex items-center gap-1 font-normal">
2968
+ <Checkbox
2969
+ checked={selectedElement.textLineHeight === "auto"}
2970
+ className="h-3.5 w-3.5 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2971
+ onCheckedChange={(checked) =>
2972
+ updateSelectedText({
2973
+ textLineHeight: checked === true ? "auto" : 1.6,
2974
+ })
2975
+ }
2976
+ />
2977
+ <span>{t("panel.config.lineHeightAuto")}</span>
2978
+ </label>
2979
+ </div>
2955
2980
  <Input
2956
2981
  type="number"
2957
2982
  min={1}
2958
2983
  max={3}
2959
2984
  step={0.1}
2960
- value={selectedElement.textLineHeight ?? 1.6}
2985
+ disabled={selectedElement.textLineHeight === "auto"}
2986
+ placeholder={t("panel.config.lineHeightAuto")}
2987
+ value={
2988
+ selectedElement.textLineHeight === "auto"
2989
+ ? ""
2990
+ : (selectedElement.textLineHeight ?? 1.6)
2991
+ }
2961
2992
  onChange={(e) =>
2962
2993
  updateSelectedText({
2963
2994
  textLineHeight: Math.min(
@@ -3459,6 +3490,24 @@ export function PanelConfigSidebar({
3459
3490
  true,
3460
3491
  [t("panel.material.geometry"), "geometry", t("panel.config.shape"), "canvas", t("panel.config.searchKwScript"), t("panel.config.searchKwSketch")]
3461
3492
  )
3493
+ ) : selectedElement.materialType === "scene3d" ? (
3494
+ renderSection(
3495
+ "scene3dConfig",
3496
+ t("panel.config.sectionScene3d"),
3497
+ <PanelConfigScene3dSection
3498
+ element={selectedElement}
3499
+ updateElement={updateElement}
3500
+ />,
3501
+ true,
3502
+ [
3503
+ t("panel.material.scene3d"),
3504
+ "3d",
3505
+ "glb",
3506
+ t("panel.config.scene3dUploadModels"),
3507
+ t("panel.config.groupScene3dCamera"),
3508
+ t("panel.config.groupScene3dModelAnim"),
3509
+ ]
3510
+ )
3462
3511
  ) : selectedElement.materialType === "grid" ? (
3463
3512
  renderSection(
3464
3513
  "gridConfig",
@@ -67,6 +67,7 @@ export function SelectLayer({
67
67
  // 只允许左键触发框选,右键不参与选区变更
68
68
  if (input && input.button !== 0) return false;
69
69
  const target = (input?.target as HTMLElement | null) ?? null;
70
+ if (target?.closest("[data-scene3d-orbit-active='true']")) return false;
70
71
  const isShift = !!input?.shiftKey;
71
72
  if (isShift) return true;
72
73
 
@@ -95,6 +96,10 @@ export function SelectLayer({
95
96
  }
96
97
  // 已有多选时,默认应该是拖动移动;只有按住 shift 才继续框选/追加
97
98
  const target = e.inputEvent.target as HTMLElement | null;
99
+ if (target?.closest("[data-scene3d-orbit-active='true']")) {
100
+ e.stop();
101
+ return;
102
+ }
98
103
  const selectable = target?.closest(".rv-selectable") as HTMLElement | null;
99
104
  const id = selectable?.dataset.elementId;
100
105
  const isShift = (e.inputEvent as MouseEvent | undefined)?.shiftKey ?? false;
@@ -64,6 +64,7 @@ export type WorkspaceConfigSidebarProps = Omit<
64
64
  | "jsonConfig"
65
65
  | "logicConfig"
66
66
  | "clockConfig"
67
+ | "eventConfig"
67
68
  >
68
69
  >
69
70
  ) => void;
@@ -35,6 +35,7 @@ export type WorkspaceProjectNavProps = {
35
35
  activeProjectId: string | null;
36
36
  activeProjectName: string | null;
37
37
  dirty: boolean;
38
+ previewingProjectIds?: string[];
38
39
  onCreateProject: () => Promise<{ name: string; id: string } | void>;
39
40
  onOpenProject: (id: string) => Promise<void>;
40
41
  onSyncProject: () => Promise<string | void>;
@@ -47,6 +48,7 @@ export function WorkspaceProjectNav({
47
48
  activeProjectId,
48
49
  activeProjectName,
49
50
  dirty,
51
+ previewingProjectIds = [],
50
52
  onCreateProject,
51
53
  onOpenProject,
52
54
  onSyncProject,
@@ -136,14 +138,22 @@ export function WorkspaceProjectNav({
136
138
  {projects.length === 0 ? (
137
139
  <DropdownMenuItem disabled>{t("panel.workspace.noSavedWorkspaces")}</DropdownMenuItem>
138
140
  ) : (
139
- projects.map((project) => (
141
+ projects.map((project) => {
142
+ const isCurrent = activeProjectId === project.id;
143
+ const isPreviewing = previewingProjectIds.includes(project.id);
144
+ return (
140
145
  <div key={project.id} className="px-1 py-0.5">
141
146
  <div className="flex items-center gap-1">
142
147
  <button
143
148
  type="button"
144
149
  className={[
145
150
  "min-w-0 flex-1 rounded px-2 py-1.5 text-left text-xs hover:bg-accent",
146
- activeProjectId === project.id ? "bg-accent/60 font-medium" : "",
151
+ isCurrent
152
+ ? "bg-primary/15 font-medium text-primary ring-1 ring-inset ring-primary/40"
153
+ : "",
154
+ !isCurrent && isPreviewing
155
+ ? "bg-sky-500/10 ring-1 ring-inset ring-sky-400/50"
156
+ : "",
147
157
  ].join(" ")}
148
158
  onClick={() =>
149
159
  void runAction(async () => {
@@ -151,7 +161,19 @@ export function WorkspaceProjectNav({
151
161
  })
152
162
  }
153
163
  >
154
- <div className="truncate">{project.name}</div>
164
+ <div className="flex items-center gap-1">
165
+ <div className="min-w-0 flex-1 truncate">{project.name}</div>
166
+ {isCurrent ? (
167
+ <span className="shrink-0 rounded bg-primary px-1 py-px text-[9px] leading-none text-primary-foreground">
168
+ {t("panel.workspace.currentBadge")}
169
+ </span>
170
+ ) : null}
171
+ {isPreviewing ? (
172
+ <span className="shrink-0 rounded bg-sky-600 px-1 py-px text-[9px] leading-none text-white">
173
+ {t("panel.workspace.previewingBadge")}
174
+ </span>
175
+ ) : null}
176
+ </div>
155
177
  <div className="truncate text-[10px] text-muted-foreground">
156
178
  {formatUpdatedAt(project.updatedAt)}
157
179
  </div>
@@ -182,7 +204,8 @@ export function WorkspaceProjectNav({
182
204
  </Button>
183
205
  </div>
184
206
  </div>
185
- ))
207
+ );
208
+ })
186
209
  )}
187
210
  {/* <DropdownMenuSeparator />
188
211
  <DropdownMenuItem onClick={() => void runAction(onCreateProject)}>