@arronqzy/react-view 0.0.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.
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/aix-ppc64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-arm.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/darwin-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/darwin-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/freebsd-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/freebsd-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-arm.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-ia32.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-loong64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-mips64el.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-ppc64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-riscv64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-s390x.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/netbsd-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/openbsd-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/sunos-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-ia32.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-android-arm-eabi.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-android-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-darwin-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-darwin-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-freebsd-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-freebsd-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm64-gnu.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm64-musl.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-riscv64-musl.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-s390x-gnu.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-x64-gnu.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-x64-musl.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-arm64-msvc.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-ia32-msvc.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-x64-msvc.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/eslint.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/fsevents.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/pnpm.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/react-dom.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/react-is.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/turbo-darwin-64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/turbo-darwin-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/turbo-linux-64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/turbo-linux-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/turbo-windows-64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/turbo-windows-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/typescript.json +1 -0
- package/.turbo/turbo-lint.log +4 -0
- package/dist/styles.css +2624 -0
- package/env.d.ts +11 -0
- package/operation-guide.md +275 -0
- package/package.json +43 -0
- package/postcss.config.cjs +7 -0
- package/readme.md +21 -0
- package/src/core/createAction.ts +5 -0
- package/src/core/createView.ts +28 -0
- package/src/core/instructions.tsx +57 -0
- package/src/core/nodePool.ts +127 -0
- package/src/core/signal.ts +130 -0
- package/src/core/template.tsx +25 -0
- package/src/index.ts +7 -0
- package/src/panel/ReactViewOnlinePreview.tsx +331 -0
- package/src/panel/ReactViewPanel.tsx +3221 -0
- package/src/panel/components/ElementsLayer.tsx +891 -0
- package/src/panel/components/MaterialSidebar.tsx +1142 -0
- package/src/panel/components/MoveableLayer.tsx +803 -0
- package/src/panel/components/PanelCanvas.tsx +660 -0
- package/src/panel/components/PanelConfigSidebar.tsx +3703 -0
- package/src/panel/components/PanelRulers.tsx +89 -0
- package/src/panel/components/SelectLayer.tsx +106 -0
- package/src/panel/components/ViewElementScopePanel.tsx +79 -0
- package/src/panel/components/WorkspaceConfigSidebar.tsx +158 -0
- package/src/panel/components/WorkspaceProjectNav.tsx +220 -0
- package/src/panel/components/WorkspaceStageSplit.tsx +179 -0
- package/src/panel/components/scope-config/ScopeConfigContext.tsx +170 -0
- package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.tsx +250 -0
- package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.tsx +59 -0
- package/src/panel/constants/messages.ts +34 -0
- package/src/panel/constants/zIndex.ts +6 -0
- package/src/panel/hooks/usePanelElements.ts +1108 -0
- package/src/panel/hooks/useWorkspaceProjects.ts +278 -0
- package/src/panel/index.tsx +3 -0
- package/src/panel/library/workspace-project-cache.ts +23 -0
- package/src/panel/library/workspace-project-db.ts +112 -0
- package/src/panel/library/workspace-project-sync.ts +41 -0
- package/src/panel/library/workspace-snapshot.ts +27 -0
- package/src/panel/scope/view-scope-store.ts +55 -0
- package/src/panel/types.ts +125 -0
- package/src/panel/utils/chartOptionBuilder.ts +316 -0
- package/src/panel/utils/gridPlacement.ts +189 -0
- package/src/panel/utils/mappingLayerOps.ts +142 -0
- package/src/panel/utils/panelElementDefaults.ts +161 -0
- package/src/panel/utils/panelElementNodes.ts +35 -0
- package/src/panel/utils/panelStateIO.ts +124 -0
- package/src/panel/utils/scope-autocomplete.ts +114 -0
- package/src/panel/utils/scope-field-labels.ts +45 -0
- package/src/panel/utils/scope-template-warnings.ts +135 -0
- package/src/panel/utils/scope-template.ts +75 -0
- package/src/panel/utils/updateElementDraft.ts +221 -0
- package/src/panel/viewportZoom.ts +26 -0
- package/src/tailwind.css +58 -0
- package/src/types/react-infinite-viewer.d.ts +39 -0
- package/tailwind.config.ts +7 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ComponentRef, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BluePrintReactRoot,
|
|
5
|
+
BlueprintPanelToolbar,
|
|
6
|
+
type BluePrintReactRootProps,
|
|
7
|
+
type BlueprintDebugToolbarProps,
|
|
8
|
+
type BlueprintLibraryListItem,
|
|
9
|
+
} from "@arronqzy/react-blueprint";
|
|
10
|
+
import {
|
|
11
|
+
ResizableHandle,
|
|
12
|
+
ResizablePanel,
|
|
13
|
+
ResizablePanelGroup,
|
|
14
|
+
} from "@arronqzy/ui";
|
|
15
|
+
|
|
16
|
+
type WorkspaceStageSplitProps = {
|
|
17
|
+
blueprintOpen: boolean;
|
|
18
|
+
viewStage: ReactNode;
|
|
19
|
+
blueprintProps: BluePrintReactRootProps;
|
|
20
|
+
blueprintLibraryItems?: BlueprintLibraryListItem[];
|
|
21
|
+
activeBlueprintLibraryId?: string | null;
|
|
22
|
+
currentBlueprintLabel?: string;
|
|
23
|
+
onSelectBlueprintLibraryItem?: (id: string) => void;
|
|
24
|
+
onRenameBlueprintLibraryItem?: (id: string, name: string) => void;
|
|
25
|
+
onDeleteBlueprintLibraryItem?: (id: string) => void;
|
|
26
|
+
onSaveBlueprint?: () => void;
|
|
27
|
+
onSyncBlueprint?: () => void;
|
|
28
|
+
canSyncBlueprint?: boolean;
|
|
29
|
+
blueprintDebug?: BlueprintDebugToolbarProps;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const MIN_BLUEPRINT_HEIGHT = 48;
|
|
33
|
+
|
|
34
|
+
function BlueprintPanel({
|
|
35
|
+
blueprintOpen,
|
|
36
|
+
blueprintProps,
|
|
37
|
+
blueprintLibraryItems = [],
|
|
38
|
+
activeBlueprintLibraryId = null,
|
|
39
|
+
currentBlueprintLabel,
|
|
40
|
+
onSelectBlueprintLibraryItem,
|
|
41
|
+
onRenameBlueprintLibraryItem,
|
|
42
|
+
onDeleteBlueprintLibraryItem,
|
|
43
|
+
onSaveBlueprint,
|
|
44
|
+
onSyncBlueprint,
|
|
45
|
+
canSyncBlueprint = false,
|
|
46
|
+
blueprintDebug,
|
|
47
|
+
}: {
|
|
48
|
+
blueprintOpen: boolean;
|
|
49
|
+
blueprintProps: BluePrintReactRootProps;
|
|
50
|
+
blueprintLibraryItems?: BlueprintLibraryListItem[];
|
|
51
|
+
activeBlueprintLibraryId?: string | null;
|
|
52
|
+
currentBlueprintLabel?: string;
|
|
53
|
+
onSelectBlueprintLibraryItem?: (id: string) => void;
|
|
54
|
+
onRenameBlueprintLibraryItem?: (id: string, name: string) => void;
|
|
55
|
+
onDeleteBlueprintLibraryItem?: (id: string) => void;
|
|
56
|
+
onSaveBlueprint?: () => void;
|
|
57
|
+
onSyncBlueprint?: () => void;
|
|
58
|
+
canSyncBlueprint?: boolean;
|
|
59
|
+
blueprintDebug?: BlueprintDebugToolbarProps;
|
|
60
|
+
}) {
|
|
61
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
62
|
+
const [layoutReady, setLayoutReady] = useState(false);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!blueprintOpen) {
|
|
66
|
+
setLayoutReady(false);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const el = wrapRef.current;
|
|
71
|
+
if (!el) return;
|
|
72
|
+
|
|
73
|
+
const check = () => {
|
|
74
|
+
setLayoutReady(el.clientHeight >= MIN_BLUEPRINT_HEIGHT);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
check();
|
|
78
|
+
const ro = new ResizeObserver(check);
|
|
79
|
+
ro.observe(el);
|
|
80
|
+
return () => ro.disconnect();
|
|
81
|
+
}, [blueprintOpen]);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
data-workspace-region="blueprint"
|
|
86
|
+
className="flex h-full min-h-0 flex-col overflow-hidden bg-background"
|
|
87
|
+
>
|
|
88
|
+
<div className="flex shrink-0 items-center border-b border-border bg-background/90 px-2 py-1">
|
|
89
|
+
<BlueprintPanelToolbar
|
|
90
|
+
items={blueprintLibraryItems}
|
|
91
|
+
activeId={activeBlueprintLibraryId}
|
|
92
|
+
currentBlueprintLabel={currentBlueprintLabel}
|
|
93
|
+
onSelectItem={(id) => onSelectBlueprintLibraryItem?.(id)}
|
|
94
|
+
onRenameItem={(id, name) => onRenameBlueprintLibraryItem?.(id, name)}
|
|
95
|
+
onDeleteItem={(id) => onDeleteBlueprintLibraryItem?.(id)}
|
|
96
|
+
onSave={() => onSaveBlueprint?.()}
|
|
97
|
+
onSync={() => onSyncBlueprint?.()}
|
|
98
|
+
canSync={canSyncBlueprint}
|
|
99
|
+
debug={blueprintDebug}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
<div ref={wrapRef} className="relative min-h-0 flex-1">
|
|
103
|
+
{layoutReady ? (
|
|
104
|
+
<BluePrintReactRoot style={{ width: "100%", height: "100%" }} {...blueprintProps} />
|
|
105
|
+
) : (
|
|
106
|
+
<div className="flex h-full items-center justify-center text-[11px] text-muted-foreground">
|
|
107
|
+
{blueprintOpen ? "画布加载中…" : ""}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function WorkspaceStageSplit({
|
|
116
|
+
blueprintOpen,
|
|
117
|
+
viewStage,
|
|
118
|
+
blueprintProps,
|
|
119
|
+
blueprintLibraryItems,
|
|
120
|
+
activeBlueprintLibraryId,
|
|
121
|
+
currentBlueprintLabel,
|
|
122
|
+
onSelectBlueprintLibraryItem,
|
|
123
|
+
onRenameBlueprintLibraryItem,
|
|
124
|
+
onDeleteBlueprintLibraryItem,
|
|
125
|
+
onSaveBlueprint,
|
|
126
|
+
onSyncBlueprint,
|
|
127
|
+
canSyncBlueprint,
|
|
128
|
+
blueprintDebug,
|
|
129
|
+
}: WorkspaceStageSplitProps) {
|
|
130
|
+
const blueprintPanelRef = useRef<ComponentRef<typeof ResizablePanel>>(null);
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const panel = blueprintPanelRef.current;
|
|
134
|
+
if (!panel) return;
|
|
135
|
+
if (blueprintOpen) {
|
|
136
|
+
panel.expand();
|
|
137
|
+
} else {
|
|
138
|
+
panel.collapse();
|
|
139
|
+
}
|
|
140
|
+
}, [blueprintOpen]);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<ResizablePanelGroup
|
|
144
|
+
direction="vertical"
|
|
145
|
+
className="relative min-h-0 flex-1"
|
|
146
|
+
>
|
|
147
|
+
<ResizablePanel defaultSize={100} minSize={10} className="min-h-0">
|
|
148
|
+
<div className="h-full min-h-0 overflow-hidden">{viewStage}</div>
|
|
149
|
+
</ResizablePanel>
|
|
150
|
+
<ResizableHandle
|
|
151
|
+
withHandle
|
|
152
|
+
className={blueprintOpen ? undefined : "hidden"}
|
|
153
|
+
/>
|
|
154
|
+
<ResizablePanel
|
|
155
|
+
ref={blueprintPanelRef}
|
|
156
|
+
collapsible
|
|
157
|
+
collapsedSize={0}
|
|
158
|
+
defaultSize={0}
|
|
159
|
+
minSize={15}
|
|
160
|
+
className="min-h-0"
|
|
161
|
+
>
|
|
162
|
+
<BlueprintPanel
|
|
163
|
+
blueprintOpen={blueprintOpen}
|
|
164
|
+
blueprintProps={blueprintProps}
|
|
165
|
+
blueprintLibraryItems={blueprintLibraryItems}
|
|
166
|
+
activeBlueprintLibraryId={activeBlueprintLibraryId}
|
|
167
|
+
currentBlueprintLabel={currentBlueprintLabel}
|
|
168
|
+
onSelectBlueprintLibraryItem={onSelectBlueprintLibraryItem}
|
|
169
|
+
onRenameBlueprintLibraryItem={onRenameBlueprintLibraryItem}
|
|
170
|
+
onDeleteBlueprintLibraryItem={onDeleteBlueprintLibraryItem}
|
|
171
|
+
onSaveBlueprint={onSaveBlueprint}
|
|
172
|
+
onSyncBlueprint={onSyncBlueprint}
|
|
173
|
+
canSyncBlueprint={canSyncBlueprint}
|
|
174
|
+
blueprintDebug={blueprintDebug}
|
|
175
|
+
/>
|
|
176
|
+
</ResizablePanel>
|
|
177
|
+
</ResizablePanelGroup>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type RefObject,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
import type { ScopeTemplateWarning } from "../../utils/scope-template-warnings";
|
|
12
|
+
import { scopeFieldDomId } from "../../utils/scope-field-labels";
|
|
13
|
+
import { groupScopeWarningsByField } from "../../utils/scope-template-warnings";
|
|
14
|
+
|
|
15
|
+
import { ScopeTemplateAutocompleteHost } from "./ScopeTemplateAutocompleteHost";
|
|
16
|
+
|
|
17
|
+
type ScopeConfigContextValue = {
|
|
18
|
+
warnings: ScopeTemplateWarning[];
|
|
19
|
+
warningsByField: Map<string, ScopeTemplateWarning[]>;
|
|
20
|
+
scrollToField: (fieldId: string) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ScopeConfigContext = createContext<ScopeConfigContextValue | null>(null);
|
|
24
|
+
|
|
25
|
+
const INLINE_HINT_ATTR = "data-scope-inline-hint";
|
|
26
|
+
|
|
27
|
+
function findFieldAnchor(
|
|
28
|
+
container: HTMLElement,
|
|
29
|
+
fieldId: string,
|
|
30
|
+
fieldWarnings: ScopeTemplateWarning[]
|
|
31
|
+
): HTMLElement | null {
|
|
32
|
+
const existing = document.getElementById(scopeFieldDomId(fieldId));
|
|
33
|
+
if (existing) return existing;
|
|
34
|
+
|
|
35
|
+
const inputs = container.querySelectorAll("input, textarea");
|
|
36
|
+
for (const input of inputs) {
|
|
37
|
+
if (
|
|
38
|
+
!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement)
|
|
39
|
+
) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const matched = fieldWarnings.some(
|
|
43
|
+
(warning) =>
|
|
44
|
+
input.value === warning.template ||
|
|
45
|
+
input.value.includes(warning.expression)
|
|
46
|
+
);
|
|
47
|
+
if (!matched) continue;
|
|
48
|
+
const host = input.closest("label") ?? input.parentElement;
|
|
49
|
+
if (host instanceof HTMLElement) {
|
|
50
|
+
host.id = scopeFieldDomId(fieldId);
|
|
51
|
+
return host;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function attachInlineHints(
|
|
59
|
+
container: HTMLElement,
|
|
60
|
+
warningsByField: Map<string, ScopeTemplateWarning[]>
|
|
61
|
+
) {
|
|
62
|
+
container
|
|
63
|
+
.querySelectorAll(`[${INLINE_HINT_ATTR}]`)
|
|
64
|
+
.forEach((node) => node.remove());
|
|
65
|
+
|
|
66
|
+
for (const [fieldId, fieldWarnings] of warningsByField) {
|
|
67
|
+
const anchor = findFieldAnchor(container, fieldId, fieldWarnings);
|
|
68
|
+
if (!anchor || anchor.querySelector(`[${INLINE_HINT_ATTR}]`)) continue;
|
|
69
|
+
|
|
70
|
+
const hint = document.createElement("div");
|
|
71
|
+
hint.setAttribute(INLINE_HINT_ATTR, "1");
|
|
72
|
+
hint.className = "space-y-0.5";
|
|
73
|
+
for (const warning of fieldWarnings) {
|
|
74
|
+
const line = document.createElement("p");
|
|
75
|
+
line.className =
|
|
76
|
+
"text-[10px] leading-relaxed text-amber-700 dark:text-amber-300";
|
|
77
|
+
line.textContent = warning.message;
|
|
78
|
+
hint.appendChild(line);
|
|
79
|
+
}
|
|
80
|
+
anchor.appendChild(hint);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function ScopeConfigProvider({
|
|
85
|
+
scope,
|
|
86
|
+
warnings,
|
|
87
|
+
scrollContainerRef,
|
|
88
|
+
children,
|
|
89
|
+
}: {
|
|
90
|
+
scope?: unknown;
|
|
91
|
+
warnings: ScopeTemplateWarning[];
|
|
92
|
+
scrollContainerRef?: RefObject<HTMLElement | null>;
|
|
93
|
+
children: ReactNode;
|
|
94
|
+
}) {
|
|
95
|
+
const warningsByField = useMemo(
|
|
96
|
+
() => groupScopeWarningsByField(warnings),
|
|
97
|
+
[warnings]
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const scrollToField = useCallback(
|
|
101
|
+
(fieldId: string) => {
|
|
102
|
+
const container = scrollContainerRef?.current;
|
|
103
|
+
const fieldWarnings = warningsByField.get(fieldId);
|
|
104
|
+
if (container && fieldWarnings?.length) {
|
|
105
|
+
findFieldAnchor(container, fieldId, fieldWarnings);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const target = document.getElementById(scopeFieldDomId(fieldId));
|
|
109
|
+
if (!target) return;
|
|
110
|
+
|
|
111
|
+
if (container && container.contains(target)) {
|
|
112
|
+
const containerRect = container.getBoundingClientRect();
|
|
113
|
+
const targetRect = target.getBoundingClientRect();
|
|
114
|
+
const offset =
|
|
115
|
+
targetRect.top -
|
|
116
|
+
containerRect.top +
|
|
117
|
+
container.scrollTop -
|
|
118
|
+
container.clientHeight / 2 +
|
|
119
|
+
targetRect.height / 2;
|
|
120
|
+
container.scrollTo({ top: Math.max(0, offset), behavior: "smooth" });
|
|
121
|
+
} else {
|
|
122
|
+
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
target.classList.add("scope-field--highlight");
|
|
126
|
+
window.setTimeout(() => {
|
|
127
|
+
target.classList.remove("scope-field--highlight");
|
|
128
|
+
}, 1600);
|
|
129
|
+
},
|
|
130
|
+
[scrollContainerRef, warningsByField]
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
useLayoutEffect(() => {
|
|
134
|
+
const container = scrollContainerRef?.current;
|
|
135
|
+
if (!container || warnings.length === 0) {
|
|
136
|
+
return () => undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
attachInlineHints(container, warningsByField);
|
|
140
|
+
return () => {
|
|
141
|
+
container
|
|
142
|
+
.querySelectorAll(`[${INLINE_HINT_ATTR}]`)
|
|
143
|
+
.forEach((node) => node.remove());
|
|
144
|
+
};
|
|
145
|
+
}, [scrollContainerRef, warnings, warningsByField]);
|
|
146
|
+
|
|
147
|
+
const value = useMemo(
|
|
148
|
+
() => ({ warnings, warningsByField, scrollToField }),
|
|
149
|
+
[warnings, warningsByField, scrollToField]
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<ScopeConfigContext.Provider value={value}>
|
|
154
|
+
{children}
|
|
155
|
+
<ScopeTemplateAutocompleteHost
|
|
156
|
+
scope={scope}
|
|
157
|
+
containerRef={scrollContainerRef}
|
|
158
|
+
/>
|
|
159
|
+
</ScopeConfigContext.Provider>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function useScopeConfig() {
|
|
164
|
+
return useContext(ScopeConfigContext);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function useScopeFieldWarnings(fieldId: string) {
|
|
168
|
+
const ctx = useScopeConfig();
|
|
169
|
+
return ctx?.warningsByField.get(fieldId) ?? [];
|
|
170
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type RefObject,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { createPortal } from "react-dom";
|
|
9
|
+
import { cn } from "@arronqzy/ui";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
applyScopeAutocompleteSelection,
|
|
13
|
+
buildScopeExpression,
|
|
14
|
+
getScopeAutocompleteSuggestions,
|
|
15
|
+
parseScopeAutocomplete,
|
|
16
|
+
type ScopeAutocompleteState,
|
|
17
|
+
} from "../../utils/scope-autocomplete";
|
|
18
|
+
|
|
19
|
+
function isAutocompleteTarget(
|
|
20
|
+
target: EventTarget | null
|
|
21
|
+
): target is HTMLInputElement | HTMLTextAreaElement {
|
|
22
|
+
if (target instanceof HTMLTextAreaElement) {
|
|
23
|
+
return target.dataset.scopeAutocomplete !== "off";
|
|
24
|
+
}
|
|
25
|
+
if (target instanceof HTMLInputElement) {
|
|
26
|
+
if (target.dataset.scopeAutocomplete === "off") return false;
|
|
27
|
+
const type = target.type;
|
|
28
|
+
return (
|
|
29
|
+
!type ||
|
|
30
|
+
type === "text" ||
|
|
31
|
+
type === "search" ||
|
|
32
|
+
type === "url" ||
|
|
33
|
+
type === "password"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setNativeInputValue(
|
|
40
|
+
element: HTMLInputElement | HTMLTextAreaElement,
|
|
41
|
+
value: string
|
|
42
|
+
) {
|
|
43
|
+
const proto =
|
|
44
|
+
element instanceof HTMLTextAreaElement
|
|
45
|
+
? HTMLTextAreaElement.prototype
|
|
46
|
+
: HTMLInputElement.prototype;
|
|
47
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, "value");
|
|
48
|
+
descriptor?.set?.call(element, value);
|
|
49
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type DropdownState = {
|
|
53
|
+
input: HTMLInputElement | HTMLTextAreaElement;
|
|
54
|
+
state: ScopeAutocompleteState;
|
|
55
|
+
suggestions: string[];
|
|
56
|
+
activeIndex: number;
|
|
57
|
+
rect: DOMRect;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function ScopeTemplateAutocompleteHost({
|
|
61
|
+
scope,
|
|
62
|
+
containerRef,
|
|
63
|
+
}: {
|
|
64
|
+
scope: unknown;
|
|
65
|
+
containerRef?: RefObject<HTMLElement | null>;
|
|
66
|
+
}) {
|
|
67
|
+
const [dropdown, setDropdown] = useState<DropdownState | null>(null);
|
|
68
|
+
const dropdownRef = useRef<DropdownState | null>(null);
|
|
69
|
+
dropdownRef.current = dropdown;
|
|
70
|
+
|
|
71
|
+
const closeDropdown = useCallback(() => {
|
|
72
|
+
setDropdown(null);
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
const refreshForInput = useCallback(
|
|
76
|
+
(input: HTMLInputElement | HTMLTextAreaElement) => {
|
|
77
|
+
if (scope === undefined) {
|
|
78
|
+
closeDropdown();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const cursor = input.selectionStart ?? input.value.length;
|
|
82
|
+
const parsed = parseScopeAutocomplete(input.value, cursor, scope);
|
|
83
|
+
if (!parsed) {
|
|
84
|
+
closeDropdown();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const suggestions = getScopeAutocompleteSuggestions(scope, parsed);
|
|
88
|
+
if (suggestions.length === 0) {
|
|
89
|
+
closeDropdown();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setDropdown({
|
|
93
|
+
input,
|
|
94
|
+
state: parsed,
|
|
95
|
+
suggestions,
|
|
96
|
+
activeIndex: 0,
|
|
97
|
+
rect: input.getBoundingClientRect(),
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
[closeDropdown, scope]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const applySuggestion = useCallback(
|
|
104
|
+
(index: number) => {
|
|
105
|
+
const current = dropdownRef.current;
|
|
106
|
+
if (!current) return;
|
|
107
|
+
const selected = current.suggestions[index];
|
|
108
|
+
if (!selected) return;
|
|
109
|
+
|
|
110
|
+
const { value, cursor } = applyScopeAutocompleteSelection(
|
|
111
|
+
current.input.value,
|
|
112
|
+
current.state,
|
|
113
|
+
selected
|
|
114
|
+
);
|
|
115
|
+
setNativeInputValue(current.input, value);
|
|
116
|
+
current.input.focus();
|
|
117
|
+
current.input.setSelectionRange(cursor, cursor);
|
|
118
|
+
closeDropdown();
|
|
119
|
+
window.requestAnimationFrame(() => {
|
|
120
|
+
refreshForInput(current.input);
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
[closeDropdown, refreshForInput]
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const container = containerRef?.current;
|
|
128
|
+
if (!container || scope === undefined) return;
|
|
129
|
+
|
|
130
|
+
const onFocusIn = (event: FocusEvent) => {
|
|
131
|
+
if (!isAutocompleteTarget(event.target)) return;
|
|
132
|
+
refreshForInput(event.target);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const onInput = (event: Event) => {
|
|
136
|
+
if (!isAutocompleteTarget(event.target)) return;
|
|
137
|
+
refreshForInput(event.target);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
141
|
+
const current = dropdownRef.current;
|
|
142
|
+
if (!current || event.target !== current.input) return;
|
|
143
|
+
|
|
144
|
+
if (event.key === "ArrowDown") {
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
setDropdown((prev) =>
|
|
147
|
+
prev
|
|
148
|
+
? {
|
|
149
|
+
...prev,
|
|
150
|
+
activeIndex: Math.min(
|
|
151
|
+
prev.activeIndex + 1,
|
|
152
|
+
prev.suggestions.length - 1
|
|
153
|
+
),
|
|
154
|
+
}
|
|
155
|
+
: prev
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (event.key === "ArrowUp") {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
setDropdown((prev) =>
|
|
162
|
+
prev
|
|
163
|
+
? { ...prev, activeIndex: Math.max(prev.activeIndex - 1, 0) }
|
|
164
|
+
: prev
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
169
|
+
if (current.suggestions.length > 0) {
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
applySuggestion(current.activeIndex);
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (event.key === "Escape") {
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
closeDropdown();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const onPointerDown = (event: MouseEvent) => {
|
|
182
|
+
const target = event.target as Node | null;
|
|
183
|
+
if (
|
|
184
|
+
dropdownRef.current?.input.contains(target ?? null) ||
|
|
185
|
+
(target instanceof Element && target.closest("[data-scope-ac-list]"))
|
|
186
|
+
) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!container.contains(target)) {
|
|
190
|
+
closeDropdown();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
container.addEventListener("focusin", onFocusIn);
|
|
195
|
+
container.addEventListener("input", onInput, true);
|
|
196
|
+
container.addEventListener("keydown", onKeyDown, true);
|
|
197
|
+
window.addEventListener("pointerdown", onPointerDown, true);
|
|
198
|
+
window.addEventListener("scroll", closeDropdown, true);
|
|
199
|
+
window.addEventListener("resize", closeDropdown);
|
|
200
|
+
|
|
201
|
+
return () => {
|
|
202
|
+
container.removeEventListener("focusin", onFocusIn);
|
|
203
|
+
container.removeEventListener("input", onInput, true);
|
|
204
|
+
container.removeEventListener("keydown", onKeyDown, true);
|
|
205
|
+
window.removeEventListener("pointerdown", onPointerDown, true);
|
|
206
|
+
window.removeEventListener("scroll", closeDropdown, true);
|
|
207
|
+
window.removeEventListener("resize", closeDropdown);
|
|
208
|
+
};
|
|
209
|
+
}, [
|
|
210
|
+
applySuggestion,
|
|
211
|
+
closeDropdown,
|
|
212
|
+
containerRef,
|
|
213
|
+
refreshForInput,
|
|
214
|
+
scope,
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
if (!dropdown || scope === undefined) return null;
|
|
218
|
+
|
|
219
|
+
return createPortal(
|
|
220
|
+
<div
|
|
221
|
+
data-scope-ac-list
|
|
222
|
+
className="fixed z-[10120] max-h-44 min-w-[140px] overflow-auto rounded-md border border-border bg-popover py-1 text-popover-foreground shadow-md"
|
|
223
|
+
style={{
|
|
224
|
+
left: dropdown.rect.left,
|
|
225
|
+
top: dropdown.rect.bottom + 4,
|
|
226
|
+
width: Math.max(dropdown.rect.width, 160),
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
{dropdown.suggestions.map((key, index) => {
|
|
230
|
+
const preview = buildScopeExpression(dropdown.state.path, key);
|
|
231
|
+
return (
|
|
232
|
+
<button
|
|
233
|
+
key={key}
|
|
234
|
+
type="button"
|
|
235
|
+
className={cn(
|
|
236
|
+
"flex w-full flex-col items-start gap-0.5 px-2 py-1.5 text-left text-[11px] hover:bg-accent",
|
|
237
|
+
index === dropdown.activeIndex && "bg-accent"
|
|
238
|
+
)}
|
|
239
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
240
|
+
onClick={() => applySuggestion(index)}
|
|
241
|
+
>
|
|
242
|
+
<span className="font-medium text-foreground">{key}</span>
|
|
243
|
+
<span className="font-mono text-[10px] text-muted-foreground">{`{${preview}}`}</span>
|
|
244
|
+
</button>
|
|
245
|
+
);
|
|
246
|
+
})}
|
|
247
|
+
</div>,
|
|
248
|
+
document.body
|
|
249
|
+
);
|
|
250
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { useScopeConfig } from "./ScopeConfigContext";
|
|
4
|
+
|
|
5
|
+
const WARNINGS_COLLAPSE_STORAGE_KEY = "panel:config-scope-warnings-collapsed";
|
|
6
|
+
|
|
7
|
+
export function ScopeTemplateWarningsPanel() {
|
|
8
|
+
const ctx = useScopeConfig();
|
|
9
|
+
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const stored = window.localStorage.getItem(WARNINGS_COLLAPSE_STORAGE_KEY);
|
|
13
|
+
if (stored === "1") setIsCollapsed(true);
|
|
14
|
+
}, []);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
window.localStorage.setItem(
|
|
18
|
+
WARNINGS_COLLAPSE_STORAGE_KEY,
|
|
19
|
+
isCollapsed ? "1" : "0"
|
|
20
|
+
);
|
|
21
|
+
}, [isCollapsed]);
|
|
22
|
+
|
|
23
|
+
if (!ctx || ctx.warnings.length === 0) return null;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="mt-2 border-t border-border/50 pt-2">
|
|
27
|
+
<div className="flex items-center justify-between gap-2">
|
|
28
|
+
<div className="text-[11px] font-medium text-amber-700 dark:text-amber-300">
|
|
29
|
+
异常调用警告
|
|
30
|
+
<span className="ml-1.5 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold">
|
|
31
|
+
{ctx.warnings.length}
|
|
32
|
+
</span>
|
|
33
|
+
</div>
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
className="rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-accent"
|
|
37
|
+
onClick={() => setIsCollapsed((prev) => !prev)}
|
|
38
|
+
>
|
|
39
|
+
{isCollapsed ? "展开警告" : "收起警告"}
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
{!isCollapsed ? (
|
|
43
|
+
<ul className="mt-2 max-h-[200px] space-y-1 overflow-auto text-[10px] leading-relaxed">
|
|
44
|
+
{ctx.warnings.map((warning) => (
|
|
45
|
+
<li key={`${warning.fieldId}-${warning.missingPath}-${warning.expression}`}>
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className="w-full rounded border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-left text-amber-800 hover:bg-amber-500/15 dark:text-amber-200"
|
|
49
|
+
onClick={() => ctx.scrollToField(warning.fieldId)}
|
|
50
|
+
>
|
|
51
|
+
{warning.message}
|
|
52
|
+
</button>
|
|
53
|
+
</li>
|
|
54
|
+
))}
|
|
55
|
+
</ul>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const PANEL_MESSAGES = {
|
|
2
|
+
nodeNotFound: "未找到节点,可能已被删除",
|
|
3
|
+
sourceNodeNotFound: "未找到源节点,可能已被删除",
|
|
4
|
+
targetLayerNotFound: "目标图层不存在",
|
|
5
|
+
nodeMoveSameLayer: "已在当前图层",
|
|
6
|
+
nodeDeleteLocked: "锁定节点不可删除,请在右侧【节点信息】中取消锁定后重试",
|
|
7
|
+
nodeDeleteLayerLocked: "当前节点所在图层已锁定,无法删除。请先在图层栏取消锁定",
|
|
8
|
+
nodeBatchDeleteContainsLocked:
|
|
9
|
+
"选中节点包含锁定节点,无法批量删除。请先在右侧【节点信息】中取消锁定",
|
|
10
|
+
nodeMoveLocked: "锁定节点不允许更换图层,请先在右侧【节点信息】中取消锁定",
|
|
11
|
+
nodeMoveSourceLayerLocked: "节点所在图层已锁定,无法更换图层。请先在图层栏取消锁定",
|
|
12
|
+
nodeMoveTargetLayerLocked: "目标图层已锁定,无法移入。请先在图层栏取消锁定",
|
|
13
|
+
activeLayerLockedCannotAdd: "当前图层已锁定,无法新增节点",
|
|
14
|
+
nodeLayerLockedCannotCopy: "当前节点所在图层已锁定,无法复制",
|
|
15
|
+
selectionContainsLockedLayerNode: "选中节点包含锁定图层内容,无法批量删除",
|
|
16
|
+
sourceLayerLockedCannotPaste: "复制源节点所在图层已锁定,无法粘贴",
|
|
17
|
+
nodeConfigLocked: "当前节点已锁定,配置项不可编辑。",
|
|
18
|
+
nodeConfigLayerLocked: "当前节点所在图层已锁定,配置项不可编辑。",
|
|
19
|
+
readImageFailed: "读取图片失败",
|
|
20
|
+
readAudioFailed: "读取音频失败",
|
|
21
|
+
readVideoFailed: "读取视频失败",
|
|
22
|
+
readRecordAudioFailed: "录音读取失败",
|
|
23
|
+
audioLocalSaved: "已写入本地音频(base64)",
|
|
24
|
+
audioRemoteUploaded: "已上传服务器并更新远程链接",
|
|
25
|
+
audioServerUploadFailed: "服务器上传失败,仅保留本地音频",
|
|
26
|
+
audioPosterSet: "已设置音频占位图",
|
|
27
|
+
audioRecordUnsupported: "当前环境不支持录音",
|
|
28
|
+
audioRecordSaved: "录音已保存,可直接播放",
|
|
29
|
+
audioRecording: "录音中...",
|
|
30
|
+
audioRecordStartFailed: "无法开始录音,请检查麦克风权限",
|
|
31
|
+
videoLocalSaved: "已写入本地视频(base64)",
|
|
32
|
+
videoRemoteUploaded: "已上传服务器并更新远程链接",
|
|
33
|
+
videoServerUploadFailed: "服务器上传失败,仅保留本地视频",
|
|
34
|
+
} as const;
|