@arronqzy/react-view 1.0.22 → 1.0.24

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.
@@ -0,0 +1,268 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { useI18n } from "@arronqzy/i18n/react";
3
+ import {
4
+ Checkbox,
5
+ Input,
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from "@arronqzy/ui";
12
+ import type { PanelElement } from "../../types";
13
+ import { cssTextLineHeight, cssTextAlignStyle } from "../../utils/panelElementDefaults";
14
+ import { type ConfigSectionHelpers, type UpdateElement } from "./helpers";
15
+
16
+ export function PanelConfigTextSection({
17
+ element,
18
+ helpers,
19
+ updateElement,
20
+ }: {
21
+ element: PanelElement;
22
+ helpers: ConfigSectionHelpers;
23
+ updateElement: UpdateElement;
24
+ }) {
25
+ const { t } = useI18n();
26
+ const { renderSection, renderFieldGroup, renderColorField } = helpers;
27
+ const selectedElement = element;
28
+ const textEditorRef = useRef<HTMLDivElement | null>(null);
29
+
30
+ const updateSelectedText = useCallback(
31
+ (patch: Partial<PanelElement>) => {
32
+ if (selectedElement.materialType !== "text") return;
33
+ updateElement(selectedElement.id, patch);
34
+ },
35
+ [selectedElement, updateElement]
36
+ );
37
+
38
+ useEffect(() => {
39
+ if (selectedElement.materialType !== "text") return;
40
+ const nextHtml =
41
+ selectedElement.textHtml ?? `<p>${t("panel.defaults.doubleClickTextHtml")}</p>`;
42
+ if (textEditorRef.current && textEditorRef.current.innerHTML !== nextHtml) {
43
+ textEditorRef.current.innerHTML = nextHtml;
44
+ }
45
+ }, [selectedElement.id, selectedElement.materialType, selectedElement.textHtml]);
46
+
47
+ const execTextCommand = (cmd: "bold" | "italic" | "underline") => {
48
+ textEditorRef.current?.focus();
49
+ document.execCommand(cmd);
50
+ const next = textEditorRef.current?.innerHTML ?? "";
51
+ if (selectedElement.materialType === "text") {
52
+ updateElement(selectedElement.id, { textHtml: next || "<p><br/></p>" });
53
+ }
54
+ };
55
+
56
+ return (
57
+ <>
58
+ {
59
+ renderSection(
60
+ "textConfig",
61
+ t("panel.config.sectionText"),
62
+ <>
63
+ {renderFieldGroup(
64
+ t("panel.config.groupTextContent"),
65
+ <>
66
+ <div className="flex items-center gap-1">
67
+ <button
68
+ type="button"
69
+ className="rounded border border-border px-2 py-1 text-[11px] hover:bg-accent"
70
+ onClick={() => execTextCommand("bold")}
71
+ >
72
+ B
73
+ </button>
74
+ <button
75
+ type="button"
76
+ className="rounded border border-border px-2 py-1 text-[11px] italic hover:bg-accent"
77
+ onClick={() => execTextCommand("italic")}
78
+ >
79
+ I
80
+ </button>
81
+ <button
82
+ type="button"
83
+ className="rounded border border-border px-2 py-1 text-[11px] underline hover:bg-accent"
84
+ onClick={() => execTextCommand("underline")}
85
+ >
86
+ U
87
+ </button>
88
+ </div>
89
+ <div
90
+ ref={textEditorRef}
91
+ data-config-field="textHtml"
92
+ data-panel-user-text=""
93
+ className="min-h-[120px] rounded border border-border bg-background px-2 py-1.5 leading-6 outline-none"
94
+ style={{
95
+ fontFamily: selectedElement.textFontFamily || undefined,
96
+ fontSize: selectedElement.textFontSize
97
+ ? `${selectedElement.textFontSize}px`
98
+ : undefined,
99
+ fontWeight: selectedElement.textFontWeight || undefined,
100
+ color: selectedElement.textColor || undefined,
101
+ lineHeight: cssTextLineHeight(selectedElement.textLineHeight),
102
+ ...cssTextAlignStyle(selectedElement.textAlign),
103
+ }}
104
+ contentEditable
105
+ suppressContentEditableWarning
106
+ onInput={(e) => {
107
+ const nextHtml = (e.currentTarget as HTMLDivElement).innerHTML;
108
+ updateSelectedText({
109
+ textHtml: nextHtml || "<p><br/></p>",
110
+ });
111
+ }}
112
+ />
113
+ </>
114
+ )}
115
+ {renderFieldGroup(
116
+ t("panel.config.groupTextStyle"),
117
+ <>
118
+ <label className="block space-y-1">
119
+ <div>{t("panel.config.fontFamily")}</div>
120
+ <Input
121
+ value={selectedElement.textFontFamily ?? ""}
122
+ onChange={(e) =>
123
+ updateSelectedText({
124
+ textFontFamily: e.target.value || undefined,
125
+ })
126
+ }
127
+ placeholder={t("panel.config.fontFamilyPlaceholder")}
128
+ className="h-7"
129
+ />
130
+ </label>
131
+ <div className="grid grid-cols-2 gap-2">
132
+ <label className="block space-y-1">
133
+ <div>{t("panel.config.fontSizePx")}</div>
134
+ <Input
135
+ type="number"
136
+ min={8}
137
+ max={200}
138
+ step={1}
139
+ value={selectedElement.textFontSize ?? 14}
140
+ onChange={(e) =>
141
+ updateSelectedText({
142
+ textFontSize: Math.max(8, Number(e.target.value) || 14),
143
+ })
144
+ }
145
+ className="h-7"
146
+ />
147
+ </label>
148
+ <label className="block space-y-1">
149
+ <div>{t("panel.config.fontWeight")}</div>
150
+ <Select
151
+ value={selectedElement.textFontWeight ?? "400"}
152
+ onValueChange={(value) =>
153
+ updateSelectedText({
154
+ textFontWeight: value,
155
+ })
156
+ }
157
+ >
158
+ <SelectTrigger className="h-7">
159
+ <SelectValue placeholder={t("panel.config.selectFontWeight")} />
160
+ </SelectTrigger>
161
+ <SelectContent>
162
+ <SelectItem value="300">300</SelectItem>
163
+ <SelectItem value="400">400</SelectItem>
164
+ <SelectItem value="500">500</SelectItem>
165
+ <SelectItem value="600">600</SelectItem>
166
+ <SelectItem value="700">700</SelectItem>
167
+ </SelectContent>
168
+ </Select>
169
+ </label>
170
+ </div>
171
+ <div className="grid grid-cols-2 gap-2">
172
+ <label className="block space-y-1">
173
+ <div>{t("panel.config.textAlign")}</div>
174
+ <Select
175
+ value={selectedElement.textAlign ?? "left"}
176
+ onValueChange={(value) =>
177
+ updateSelectedText({
178
+ textAlign: value as PanelElement["textAlign"],
179
+ })
180
+ }
181
+ >
182
+ <SelectTrigger className="h-7">
183
+ <SelectValue placeholder={t("panel.config.selectTextAlign")} />
184
+ </SelectTrigger>
185
+ <SelectContent>
186
+ <SelectItem value="left">{t("panel.config.alignLeft")}</SelectItem>
187
+ <SelectItem value="center">{t("panel.config.alignCenter")}</SelectItem>
188
+ <SelectItem value="middle">{t("panel.config.alignMiddle")}</SelectItem>
189
+ <SelectItem value="right">{t("panel.config.alignRight")}</SelectItem>
190
+ <SelectItem value="justify">{t("panel.config.alignJustify")}</SelectItem>
191
+ </SelectContent>
192
+ </Select>
193
+ </label>
194
+ <label className="block space-y-1">
195
+ <div className="flex items-center justify-between gap-2">
196
+ <span>{t("panel.config.lineHeight")}</span>
197
+ <label className="flex items-center gap-1 font-normal">
198
+ <Checkbox
199
+ checked={selectedElement.textLineHeight === "auto"}
200
+ 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"
201
+ onCheckedChange={(checked) =>
202
+ updateSelectedText({
203
+ textLineHeight: checked === true ? "auto" : 1.6,
204
+ })
205
+ }
206
+ />
207
+ <span>{t("panel.config.lineHeightAuto")}</span>
208
+ </label>
209
+ </div>
210
+ <Input
211
+ type="number"
212
+ min={1}
213
+ max={3}
214
+ step={0.1}
215
+ disabled={selectedElement.textLineHeight === "auto"}
216
+ placeholder={t("panel.config.lineHeightAuto")}
217
+ value={
218
+ selectedElement.textLineHeight === "auto"
219
+ ? ""
220
+ : (selectedElement.textLineHeight ?? 1.6)
221
+ }
222
+ onChange={(e) =>
223
+ updateSelectedText({
224
+ textLineHeight: Math.min(
225
+ 3,
226
+ Math.max(1, Number(e.target.value) || 1.6)
227
+ ),
228
+ })
229
+ }
230
+ className="h-7"
231
+ />
232
+ </label>
233
+ </div>
234
+ {renderColorField(
235
+ t("panel.config.textColor"),
236
+ selectedElement.textColor ?? "",
237
+ (next) =>
238
+ updateSelectedText({
239
+ textColor: next || undefined,
240
+ })
241
+ )}
242
+ </>
243
+ )}
244
+ {renderFieldGroup(
245
+ t("panel.config.groupInputAbility"),
246
+ <label className="flex items-center gap-2">
247
+ <Checkbox
248
+ checked={selectedElement.textAllowInput ?? true}
249
+ 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"
250
+ onCheckedChange={(checked) =>
251
+ updateSelectedText({
252
+ textAllowInput: checked !== false,
253
+ })
254
+ }
255
+ />
256
+ <span>{t("panel.config.allowCanvasInput")}</span>
257
+ </label>,
258
+ undefined,
259
+ { groupKey: "textInputAbility", defaultOpen: false }
260
+ )}
261
+ </>,
262
+ true,
263
+ [t("panel.material.text"), t("panel.config.searchKwRichText"), t("panel.config.fontFamily"), t("panel.config.color"), t("panel.config.textAlign"), t("panel.config.lineHeight"), t("panel.config.searchKwInput")]
264
+ )
265
+ }
266
+ </>
267
+ );
268
+ }
@@ -0,0 +1,133 @@
1
+ import React, { useCallback, useState } from "react";
2
+ import { useI18n } from "@arronqzy/i18n/react";
3
+ import { Checkbox, Input } from "@arronqzy/ui";
4
+ import type { PanelElement } from "../../types";
5
+ import { readFileAsDataUrl, runBusyTask } from "../../utils/async-work";
6
+ import { getPanelMessages } from "../../constants/messages";
7
+ import { type ConfigSectionHelpers, type UpdateElement } from "./helpers";
8
+
9
+ export function PanelConfigVideoSection({
10
+ element,
11
+ helpers,
12
+ updateElement,
13
+ }: {
14
+ element: PanelElement;
15
+ helpers: ConfigSectionHelpers;
16
+ updateElement: UpdateElement;
17
+ }) {
18
+ const { t } = useI18n();
19
+ const messages = React.useMemo(() => getPanelMessages(t), [t]);
20
+ const { renderSection, renderFieldGroup } = helpers;
21
+ const selectedElement = element;
22
+ const [videoStatus, setVideoStatus] = useState("");
23
+
24
+ const updateSelectedVideo = useCallback(
25
+ (patch: Partial<PanelElement>) => {
26
+ if (selectedElement.materialType !== "video") return;
27
+ updateElement(selectedElement.id, patch);
28
+ },
29
+ [selectedElement, updateElement]
30
+ );
31
+ const handleUploadVideoFile = useCallback(
32
+ async (file: File) => {
33
+ if (selectedElement.materialType !== "video") return;
34
+ try {
35
+ await runBusyTask(t("common.uploadingFile"), async () => {
36
+ const base64 = await readFileAsDataUrl(file, messages.readVideoFailed, "video");
37
+ updateSelectedVideo({ videoSrc: base64 });
38
+ setVideoStatus(messages.videoLocalSaved);
39
+ try {
40
+ const form = new FormData();
41
+ form.append("file", file);
42
+ const resp = await fetch("/api/upload", { method: "POST", body: form });
43
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
44
+ const data = (await resp.json()) as { url?: string };
45
+ if (data.url) {
46
+ updateSelectedVideo({ videoRemoteUrl: data.url });
47
+ setVideoStatus(messages.videoRemoteUploaded);
48
+ }
49
+ } catch {
50
+ setVideoStatus(messages.videoServerUploadFailed);
51
+ }
52
+ });
53
+ } catch (error) {
54
+ setVideoStatus(
55
+ error instanceof Error ? error.message : messages.readVideoFailed
56
+ );
57
+ }
58
+ },
59
+ [messages, selectedElement, t, updateSelectedVideo]
60
+ );
61
+
62
+ return (
63
+ <>
64
+ {
65
+ renderSection(
66
+ "videoConfig",
67
+ t("panel.config.sectionVideo"),
68
+ <>
69
+ {renderFieldGroup(
70
+ t("panel.config.groupVideoSource"),
71
+ <>
72
+ <label className="block space-y-1">
73
+ <div>{t("panel.config.videoUrl")}</div>
74
+ <Input
75
+ value={selectedElement.videoRemoteUrl ?? ""}
76
+ onChange={(e) =>
77
+ updateSelectedVideo({
78
+ videoRemoteUrl: e.target.value || undefined,
79
+ videoSrc: e.target.value || selectedElement.videoSrc,
80
+ })
81
+ }
82
+ placeholder="https://example.com/video.mp4"
83
+ className="h-7"
84
+ />
85
+ </label>
86
+ <div className="flex items-center gap-2">
87
+ <label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
88
+ {t("panel.config.uploadVideo")}
89
+ <Input
90
+ type="file"
91
+ accept="video/*"
92
+ className="hidden"
93
+ onChange={async (e) => {
94
+ const file = e.target.files?.[0];
95
+ e.currentTarget.value = "";
96
+ if (!file) return;
97
+ await handleUploadVideoFile(file);
98
+ }}
99
+ />
100
+ </label>
101
+ </div>
102
+ {videoStatus ? (
103
+ <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
104
+ {videoStatus}
105
+ </div>
106
+ ) : null}
107
+ <video
108
+ controls
109
+ className="h-36 w-full rounded border border-border/60 bg-black/80 object-contain"
110
+ src={selectedElement.videoSrc || selectedElement.videoRemoteUrl || ""}
111
+ />
112
+ <label className="flex items-center gap-2">
113
+ <Checkbox
114
+ checked={selectedElement.mediaAutoPauseOnEdit !== false}
115
+ 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"
116
+ onCheckedChange={(checked) =>
117
+ updateSelectedVideo({
118
+ mediaAutoPauseOnEdit: checked !== false,
119
+ })
120
+ }
121
+ />
122
+ <span>{t("panel.config.autoPauseMedia")}</span>
123
+ </label>
124
+ </>
125
+ )}
126
+ </>,
127
+ true,
128
+ [t("panel.material.video"), "url", t("panel.config.searchKwUpload"), t("panel.config.searchKwPreview"), t("panel.config.searchKwAutoPause"), "media"]
129
+ )
130
+ }
131
+ </>
132
+ );
133
+ }
@@ -0,0 +1,139 @@
1
+ import { useI18n } from "@arronqzy/i18n/react";
2
+ import {
3
+ Select,
4
+ SelectContent,
5
+ SelectItem,
6
+ SelectTrigger,
7
+ SelectValue,
8
+ } from "@arronqzy/ui";
9
+ import type { PanelElement, PanelLayer, ReferenceCopyMode } from "../../types";
10
+ import { VIEWPORT_OVERFLOW_MODES, normalizeViewportOverflow, type ViewportOverflowMode } from "../../utils/viewportPlacement";
11
+ import { type ConfigSectionHelpers, type UpdateElement } from "./helpers";
12
+
13
+ export function PanelConfigViewportSection({
14
+ element,
15
+ helpers,
16
+ updateElement,
17
+ layers,
18
+ setReferenceCopyMode,
19
+ }: {
20
+ element: PanelElement;
21
+ helpers: ConfigSectionHelpers;
22
+ updateElement: UpdateElement;
23
+ layers: PanelLayer[];
24
+ setReferenceCopyMode?: (id: string, mode: ReferenceCopyMode) => void;
25
+ }) {
26
+ const { t } = useI18n();
27
+ const { renderSection, renderFieldGroup } = helpers;
28
+ const selectedElement = element;
29
+ return (
30
+ <>
31
+ {
32
+ renderSection(
33
+ "viewportConfig",
34
+ t("panel.config.sectionViewport"),
35
+ <>
36
+ {renderFieldGroup(
37
+ t("panel.config.groupRefSource"),
38
+ <label className="block space-y-1">
39
+ <div>{t("panel.config.refLayer")}</div>
40
+ <Select
41
+ value={selectedElement.refLayerId ?? "__none__"}
42
+ onValueChange={(value) =>
43
+ updateElement(selectedElement.id, {
44
+ refLayerId: value === "__none__" ? undefined : value,
45
+ })
46
+ }
47
+ >
48
+ <SelectTrigger className="h-7">
49
+ <SelectValue placeholder={t("panel.config.pleaseSelectLayer")} />
50
+ </SelectTrigger>
51
+ <SelectContent>
52
+ <SelectItem value="__none__">{t("panel.config.noneNoRef")}</SelectItem>
53
+ {layers
54
+ .filter((l) => l.id !== selectedElement.layerId)
55
+ .map((l) => (
56
+ <SelectItem key={l.id} value={l.id}>
57
+ {l.name}
58
+ </SelectItem>
59
+ ))}
60
+ </SelectContent>
61
+ </Select>
62
+ </label>
63
+ )}
64
+ {renderFieldGroup(
65
+ t("panel.config.groupCopyStrategy"),
66
+ <>
67
+ <label className="block space-y-1">
68
+ <div>{t("panel.config.copyMode")}</div>
69
+ <Select
70
+ value={selectedElement.refCopyMode ?? "shallow"}
71
+ onValueChange={(value) =>
72
+ setReferenceCopyMode?.(
73
+ selectedElement.id,
74
+ value as ReferenceCopyMode
75
+ )
76
+ }
77
+ >
78
+ <SelectTrigger className="h-7">
79
+ <SelectValue placeholder={t("panel.config.selectCopyMode")} />
80
+ </SelectTrigger>
81
+ <SelectContent>
82
+ <SelectItem value="shallow">{t("panel.config.shallowFollow")}</SelectItem>
83
+ <SelectItem value="deep">{t("panel.config.deepFreeze")}</SelectItem>
84
+ </SelectContent>
85
+ </Select>
86
+ </label>
87
+ </>,
88
+ <>{t("panel.config.copyStrategyHint")}</>,
89
+ { groupKey: "viewportCopyStrategy", defaultOpen: false }
90
+ )}
91
+ {renderFieldGroup(
92
+ t("panel.config.groupViewportOverflow"),
93
+ <label className="block space-y-1">
94
+ <div>{t("panel.config.viewportOverflow")}</div>
95
+ <Select
96
+ value={normalizeViewportOverflow(selectedElement.viewportOverflow)}
97
+ onValueChange={(value) =>
98
+ updateElement(selectedElement.id, {
99
+ viewportOverflow: value as ViewportOverflowMode,
100
+ })
101
+ }
102
+ >
103
+ <SelectTrigger className="h-7">
104
+ <SelectValue placeholder={t("panel.config.viewportOverflow")} />
105
+ </SelectTrigger>
106
+ <SelectContent>
107
+ {VIEWPORT_OVERFLOW_MODES.map((mode) => (
108
+ <SelectItem key={mode} value={mode}>
109
+ {mode === "scroll-x"
110
+ ? t("panel.config.viewportOverflowScrollX")
111
+ : mode === "scroll-y"
112
+ ? t("panel.config.viewportOverflowScrollY")
113
+ : mode === "scroll"
114
+ ? t("panel.config.viewportOverflowScroll")
115
+ : t("panel.config.viewportOverflowClip")}
116
+ </SelectItem>
117
+ ))}
118
+ </SelectContent>
119
+ </Select>
120
+ </label>
121
+ )}
122
+ </>,
123
+ true,
124
+ [
125
+ t("panel.config.searchKwViewport"),
126
+ "viewport",
127
+ t("panel.config.refLayer"),
128
+ t("panel.config.viewportOverflow"),
129
+ t("panel.config.viewportOverflowScrollX"),
130
+ t("panel.config.viewportOverflowClip"),
131
+ t("panel.material.shallowCopy"),
132
+ t("panel.material.deepCopy"),
133
+ ],
134
+ <>{t("panel.config.viewportHint")}</>
135
+ )
136
+ }
137
+ </>
138
+ );
139
+ }
@@ -0,0 +1,85 @@
1
+ import type React from "react";
2
+ import type { PanelChartConfig, PanelElement } from "../../types";
3
+
4
+ export type UpdateElement = (
5
+ id: string,
6
+ patch: Partial<PanelElement>,
7
+ options?: { batchId?: string; meta?: Record<string, unknown> }
8
+ ) => void;
9
+
10
+ export type RenderSection = (
11
+ key: string,
12
+ title: string,
13
+ children: React.ReactNode,
14
+ defaultOpen?: boolean,
15
+ searchTerms?: string[],
16
+ hint?: React.ReactNode
17
+ ) => React.ReactNode;
18
+
19
+ export type RenderFieldGroup = (
20
+ title: string,
21
+ children: React.ReactNode,
22
+ hint?: React.ReactNode,
23
+ options?: { groupKey?: string; defaultOpen?: boolean }
24
+ ) => React.ReactNode;
25
+
26
+ export type RenderColorField = (
27
+ label: string,
28
+ value: string,
29
+ onTextChange: (next: string) => void
30
+ ) => React.ReactNode;
31
+
32
+ export type ConfigSectionHelpers = {
33
+ renderSection: RenderSection;
34
+ renderFieldGroup: RenderFieldGroup;
35
+ renderColorField: RenderColorField;
36
+ hasSearch: boolean;
37
+ normalizedSearch: string;
38
+ isSectionExpanded: (key: string, defaultValue?: boolean) => boolean;
39
+ setSectionExpanded: (key: string, next: boolean) => void;
40
+ optionCheckboxClass: string;
41
+ optionInputClass: string;
42
+ optionSelectTriggerClass: string;
43
+ renderOptionLabel: (label: string, keyPath: string, desc: string) => React.ReactNode;
44
+ renderFormatterLabel: (label?: string) => React.ReactNode;
45
+ };
46
+
47
+ export const OPTION_CHECKBOX_CLASS =
48
+ "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";
49
+ export const OPTION_INPUT_CLASS =
50
+ "h-7 border border-border/60 bg-background/90 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-border/60";
51
+ export const OPTION_SELECT_TRIGGER_CLASS =
52
+ "h-7 border border-border/60 bg-muted/40 shadow-none ring-0 focus:ring-0 focus:ring-offset-0 focus:border-border/60 data-[state=open]:border-border/60";
53
+
54
+ export function mergeOptionPatch(
55
+ base: Record<string, unknown> | undefined,
56
+ patch: Record<string, unknown>
57
+ ): Record<string, unknown> {
58
+ const output: Record<string, unknown> = { ...(base ?? {}) };
59
+ for (const [key, value] of Object.entries(patch)) {
60
+ const prev = output[key];
61
+ if (
62
+ value &&
63
+ typeof value === "object" &&
64
+ !Array.isArray(value) &&
65
+ prev &&
66
+ typeof prev === "object" &&
67
+ !Array.isArray(prev)
68
+ ) {
69
+ output[key] = mergeOptionPatch(
70
+ prev as Record<string, unknown>,
71
+ value as Record<string, unknown>
72
+ );
73
+ } else {
74
+ output[key] = value;
75
+ }
76
+ }
77
+ return output;
78
+ }
79
+
80
+ export function patchChart(
81
+ chart: PanelChartConfig | undefined,
82
+ patch: Partial<PanelChartConfig>
83
+ ): PanelChartConfig {
84
+ return { ...(chart ?? {}), ...patch };
85
+ }