@nocobase/flow-engine 2.1.0-beta.26 → 2.1.0-beta.29

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.
@@ -42,6 +42,7 @@ __export(data_source_exports, {
42
42
  CollectionManager: () => CollectionManager,
43
43
  DataSource: () => DataSource,
44
44
  DataSourceManager: () => DataSourceManager,
45
+ getCollectionFieldInterface: () => getCollectionFieldInterface,
45
46
  isFieldInterfaceMatch: () => isFieldInterfaceMatch,
46
47
  jioToJoiSchema: () => import_jioToJoiSchema.jioToJoiSchema
47
48
  });
@@ -279,6 +280,20 @@ const _DataSourceManager = class _DataSourceManager {
279
280
  };
280
281
  __name(_DataSourceManager, "DataSourceManager");
281
282
  let DataSourceManager = _DataSourceManager;
283
+ function getCollectionFieldInterface(interfaceName, ...dataSourceManagers) {
284
+ if (!interfaceName) {
285
+ return void 0;
286
+ }
287
+ for (const dataSourceManager of dataSourceManagers) {
288
+ const collectionFieldInterfaceManager = dataSourceManager == null ? void 0 : dataSourceManager.collectionFieldInterfaceManager;
289
+ const getFieldInterface = collectionFieldInterfaceManager == null ? void 0 : collectionFieldInterfaceManager.getFieldInterface;
290
+ if (typeof getFieldInterface === "function") {
291
+ return getFieldInterface.call(collectionFieldInterfaceManager, interfaceName);
292
+ }
293
+ }
294
+ return void 0;
295
+ }
296
+ __name(getCollectionFieldInterface, "getCollectionFieldInterface");
282
297
  const _DataSource = class _DataSource {
283
298
  dataSourceManager;
284
299
  collectionManager;
@@ -982,7 +997,17 @@ const _CollectionField = class _CollectionField {
982
997
  abortEarly: false
983
998
  });
984
999
  if (error) {
985
- const message = error.details.map((d) => d.message.replace(/"value"/g, `"${label}"`)).join(", ");
1000
+ const message = error.details.map((d) => {
1001
+ const translated = this.flowEngine.translate(d.type, {
1002
+ ...d.context,
1003
+ ns: "data-source-main",
1004
+ label
1005
+ });
1006
+ if (translated && translated !== d.type) {
1007
+ return translated;
1008
+ }
1009
+ return d.message.replace(/"value"/g, `"${label}"`);
1010
+ }).join(", ");
986
1011
  return Promise.reject(message);
987
1012
  }
988
1013
  return Promise.resolve();
@@ -1002,8 +1027,14 @@ const _CollectionField = class _CollectionField {
1002
1027
  return this.targetCollection.getFields();
1003
1028
  }
1004
1029
  getInterfaceOptions() {
1005
- const app = this.flowEngine.context.app;
1006
- return app.dataSourceManager.collectionFieldInterfaceManager.getFieldInterface(this.interface);
1030
+ var _a, _b, _c;
1031
+ const ctx = this.flowEngine.context;
1032
+ return getCollectionFieldInterface(
1033
+ this.interface,
1034
+ (_b = (_a = this.collection) == null ? void 0 : _a.dataSource) == null ? void 0 : _b.dataSourceManager,
1035
+ ctx.dataSourceManager,
1036
+ (_c = ctx.app) == null ? void 0 : _c.dataSourceManager
1037
+ );
1007
1038
  }
1008
1039
  getFilterOperators() {
1009
1040
  var _a;
@@ -1068,6 +1099,7 @@ __name(isFieldInterfaceMatch, "isFieldInterfaceMatch");
1068
1099
  CollectionManager,
1069
1100
  DataSource,
1070
1101
  DataSourceManager,
1102
+ getCollectionFieldInterface,
1071
1103
  isFieldInterfaceMatch,
1072
1104
  jioToJoiSchema
1073
1105
  });
@@ -43,6 +43,10 @@ function encodeFilterByTk(val) {
43
43
  return encodeURIComponent(String(val));
44
44
  }
45
45
  __name(encodeFilterByTk, "encodeFilterByTk");
46
+ function hasUsableSourceId(sourceId) {
47
+ return sourceId !== void 0 && sourceId !== null && String(sourceId) !== "";
48
+ }
49
+ __name(hasUsableSourceId, "hasUsableSourceId");
46
50
  function generatePathnameFromViewParams(viewParams) {
47
51
  if (!viewParams || viewParams.length === 0) {
48
52
  return "/admin";
@@ -62,8 +66,8 @@ function generatePathnameFromViewParams(viewParams) {
62
66
  segments.push("filterbytk", encoded);
63
67
  }
64
68
  }
65
- if (viewParam.sourceId) {
66
- segments.push("sourceid", viewParam.sourceId);
69
+ if (hasUsableSourceId(viewParam.sourceId)) {
70
+ segments.push("sourceid", String(viewParam.sourceId));
67
71
  }
68
72
  });
69
73
  return "/" + segments.join("/");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.1.0-beta.26",
3
+ "version": "2.1.0-beta.29",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.1.0-beta.26",
12
- "@nocobase/shared": "2.1.0-beta.26",
11
+ "@nocobase/sdk": "2.1.0-beta.29",
12
+ "@nocobase/shared": "2.1.0-beta.29",
13
13
  "ahooks": "^3.7.2",
14
14
  "axios": "^1.7.0",
15
15
  "dayjs": "^1.11.9",
@@ -37,5 +37,5 @@
37
37
  ],
38
38
  "author": "NocoBase Team",
39
39
  "license": "Apache-2.0",
40
- "gitHead": "b17e1a72057813fa27d8435bf0f2af67ea4b059f"
40
+ "gitHead": "86c41be29dcbcac6fd6aa46b4a137ef07a27c1d0"
41
41
  }
@@ -19,6 +19,9 @@ interface ExtendedFormItemProps extends FormItemProps {
19
19
  showLabel?: boolean;
20
20
  }
21
21
 
22
+ export const verticalFormItemLabelStyle = { paddingBottom: 0 };
23
+ export const formItemStyle = { marginBottom: 12 };
24
+
22
25
  const formItemPropKeys: (keyof ExtendedFormItemProps)[] = [
23
26
  'colon',
24
27
  'dependencies',
@@ -73,6 +76,8 @@ export const FormItem = ({
73
76
  });
74
77
  const { label, labelWrap, colon = true, layout } = rest;
75
78
  const effectiveLabelWrap = !layout || layout === 'vertical' ? true : labelWrap;
79
+ const labelColStyle =
80
+ layout === 'vertical' ? { width: labelWidth, ...verticalFormItemLabelStyle } : { width: labelWidth };
76
81
  const renderLabel = () => {
77
82
  if (!showLabel) return null;
78
83
  if (effectiveLabelWrap) {
@@ -118,7 +123,8 @@ export const FormItem = ({
118
123
  return (
119
124
  <Form.Item
120
125
  {...rest}
121
- labelCol={{ style: { width: labelWidth } }}
126
+ style={{ ...formItemStyle, ...rest.style }}
127
+ labelCol={{ style: labelColStyle }}
122
128
  layout={layout}
123
129
  label={renderLabel()}
124
130
  colon={false}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+ import { formItemStyle, verticalFormItemLabelStyle } from '../FormItem';
12
+
13
+ describe('FormItem', () => {
14
+ it('keeps vertical label-to-value spacing consistent with v1', () => {
15
+ expect(verticalFormItemLabelStyle).toEqual({
16
+ paddingBottom: 0,
17
+ });
18
+ });
19
+
20
+ it('keeps spacing between form items consistent with v1', () => {
21
+ expect(formItemStyle).toEqual({
22
+ marginBottom: 12,
23
+ });
24
+ });
25
+ });
@@ -0,0 +1,531 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { css, cx } from '@emotion/css';
11
+ import { Space, theme } from 'antd';
12
+ import React, { isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
13
+ import type { MetaTreeNode } from '../../flowContext';
14
+ import { useFlowContext } from '../../FlowContextProvider';
15
+ import { FlowContextSelector } from '../FlowContextSelector';
16
+ import { useResolvedMetaTree } from './useResolvedMetaTree';
17
+ import { formatPathToValue as defaultFormatPathToValue, parseValueToPath as defaultParseValueToPath } from './utils';
18
+
19
+ type RangeIndexes = [number, number, number, number];
20
+
21
+ const DEFAULT_VARIABLE_REGEXP = /\{\{\s*([^{}]+?)\s*\}\}/g;
22
+ const TAG_CLASS = 'nb-variable-tag';
23
+
24
+ export interface VariableHybridInputConverters {
25
+ formatPathToValue?: (item?: MetaTreeNode) => string | undefined;
26
+ parseValueToPath?: (value?: string) => string[] | undefined;
27
+ variableRegExp?: RegExp;
28
+ }
29
+
30
+ export interface VariableHybridInputProps {
31
+ value?: string;
32
+ onChange?: (value: string) => void;
33
+ disabled?: boolean;
34
+ placeholder?: string;
35
+ addonBefore?: React.ReactNode;
36
+ metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
37
+ converters?: VariableHybridInputConverters;
38
+ style?: React.CSSProperties;
39
+ className?: string;
40
+ }
41
+
42
+ function reactNodeToPlainText(node: React.ReactNode): string {
43
+ if (node == null || typeof node === 'boolean') return '';
44
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
45
+ if (Array.isArray(node)) return node.map(reactNodeToPlainText).join('');
46
+ if (isValidElement(node)) return reactNodeToPlainText(node.props.children);
47
+ return '';
48
+ }
49
+
50
+ function escapeHtml(value: string) {
51
+ return value
52
+ .replace(/&/g, '&amp;')
53
+ .replace(/</g, '&lt;')
54
+ .replace(/>/g, '&gt;')
55
+ .replace(/"/g, '&quot;')
56
+ .replace(/'/g, '&#039;');
57
+ }
58
+
59
+ function getDomValue(element: HTMLElement) {
60
+ const out: string[] = [];
61
+ for (const node of Array.from(element.childNodes)) {
62
+ if (node instanceof HTMLElement && node.dataset.variable) {
63
+ out.push(node.dataset.variable);
64
+ } else {
65
+ out.push(node.textContent || '');
66
+ }
67
+ }
68
+ return out.join('');
69
+ }
70
+
71
+ function createTagHTML(variable: string, label: string) {
72
+ return `<span class="${TAG_CLASS}" contenteditable="false" data-variable="${escapeHtml(
73
+ variable,
74
+ )}" title="${escapeHtml(label)}">${escapeHtml(label)}</span>`;
75
+ }
76
+
77
+ // Strip outer `{{ }}` and trim, so values like `{{ $env.x }}` and `{{$env.x}}`
78
+ // share the same lookup key.
79
+ function normalizeVariableKey(value: string): string {
80
+ return value
81
+ .replace(/^\{\{\s*/, '')
82
+ .replace(/\s*\}\}$/, '')
83
+ .trim();
84
+ }
85
+
86
+ function renderHTML(value: string, labelMap: Map<string, string>, regExp: RegExp) {
87
+ const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
88
+ return escapeHtml(value || '').replace(re, (matched) => {
89
+ const label = labelMap.get(normalizeVariableKey(matched)) || matched;
90
+ return createTagHTML(matched, label);
91
+ });
92
+ }
93
+
94
+ function buildLabelMap(
95
+ nodes: MetaTreeNode[] | undefined,
96
+ ctxT: (text: string) => string,
97
+ converters?: VariableHybridInputConverters,
98
+ ) {
99
+ const map = new Map<string, string>();
100
+
101
+ function walk(items: MetaTreeNode[] = [], parentTitles: string[] = []) {
102
+ for (const item of items) {
103
+ const titlePart = reactNodeToPlainText(item.title || item.name);
104
+ const titles = [...parentTitles, titlePart];
105
+ const value = converters?.formatPathToValue?.(item) ?? defaultFormatPathToValue(item);
106
+ if (value) {
107
+ map.set(normalizeVariableKey(value), titles.map(ctxT).join('/'));
108
+ }
109
+ if (Array.isArray(item.children)) {
110
+ walk(item.children as MetaTreeNode[], titles);
111
+ }
112
+ }
113
+ }
114
+
115
+ walk(nodes);
116
+ return map;
117
+ }
118
+
119
+ function pasteHTML(container: HTMLElement, html: string, indexes?: RangeIndexes) {
120
+ const selection = window.getSelection?.();
121
+ const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
122
+ if (!range) return;
123
+
124
+ if (indexes) {
125
+ const children = Array.from(container.childNodes);
126
+ if (indexes[0] === -1) {
127
+ if (indexes[1] && children[indexes[1] - 1]) {
128
+ range.setStartAfter(children[indexes[1] - 1]);
129
+ } else {
130
+ range.setStart(container, 0);
131
+ }
132
+ } else {
133
+ range.setStart(children[indexes[0]], indexes[1]);
134
+ }
135
+
136
+ if (indexes[2] === -1) {
137
+ if (indexes[3] && children[indexes[3] - 1]) {
138
+ range.setEndAfter(children[indexes[3] - 1]);
139
+ } else {
140
+ range.setEnd(container, 0);
141
+ }
142
+ } else {
143
+ range.setEnd(children[indexes[2]], indexes[3]);
144
+ }
145
+ }
146
+
147
+ const wrapper = document.createElement('div');
148
+ wrapper.innerHTML = html;
149
+ const fragment = document.createDocumentFragment();
150
+ let lastNode: ChildNode | null = null;
151
+ while (wrapper.firstChild) {
152
+ lastNode = fragment.appendChild(wrapper.firstChild);
153
+ }
154
+ range.deleteContents();
155
+ range.insertNode(fragment);
156
+
157
+ if (lastNode) {
158
+ const next = new Range();
159
+ next.setStartAfter(lastNode);
160
+ next.collapse(true);
161
+ selection?.removeAllRanges();
162
+ selection?.addRange(next);
163
+ }
164
+ }
165
+
166
+ function getSingleEndRange(nodes: ChildNode[], index: number, offset: number): [number, number] {
167
+ if (index === -1) {
168
+ let realIndex = offset;
169
+ let collapseFlag = false;
170
+ if (realIndex && nodes[realIndex - 1]?.nodeName === '#text' && nodes[realIndex]?.nodeName === '#text') {
171
+ collapseFlag = true;
172
+ }
173
+ let textOffset = 0;
174
+ for (let i = offset - 1; i >= 0; i -= 1) {
175
+ if (collapseFlag) {
176
+ if (nodes[i]?.nodeName === '#text') {
177
+ textOffset += nodes[i].textContent?.length || 0;
178
+ } else {
179
+ collapseFlag = false;
180
+ }
181
+ }
182
+ if (nodes[i]?.nodeName === '#text' && nodes[i + 1]?.nodeName === '#text') {
183
+ realIndex -= 1;
184
+ }
185
+ }
186
+ return textOffset ? [realIndex, textOffset] : [-1, realIndex];
187
+ }
188
+
189
+ let realIndex = 0;
190
+ let textOffset = 0;
191
+ for (let i = 0; i < index + 1; i += 1) {
192
+ if (nodes[i]?.nodeName === '#text') {
193
+ if (i !== index && nodes[i + 1] && nodes[i + 1]?.nodeName !== '#text') {
194
+ realIndex += 1;
195
+ }
196
+ textOffset += i === index ? offset : nodes[i].textContent?.length || 0;
197
+ } else {
198
+ realIndex += 1;
199
+ textOffset = 0;
200
+ }
201
+ }
202
+ return [realIndex, textOffset];
203
+ }
204
+
205
+ function getCurrentRange(element: HTMLElement): RangeIndexes {
206
+ const selection = window.getSelection?.();
207
+ const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
208
+ if (!range || !element.contains(range.commonAncestorContainer)) {
209
+ return [-1, 0, -1, 0];
210
+ }
211
+
212
+ const nodes = Array.from(element.childNodes);
213
+ if (!nodes.length) return [-1, 0, -1, 0];
214
+
215
+ const startIndex = range.startContainer === element ? -1 : nodes.indexOf(range.startContainer as HTMLElement);
216
+ const endIndex = range.endContainer === element ? -1 : nodes.indexOf(range.endContainer as HTMLElement);
217
+
218
+ return [
219
+ ...getSingleEndRange(nodes, startIndex, range.startOffset),
220
+ ...getSingleEndRange(nodes, endIndex, range.endOffset),
221
+ ];
222
+ }
223
+
224
+ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props) => {
225
+ const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, style } = props;
226
+ const { token } = theme.useToken();
227
+ const ctx = useFlowContext();
228
+ const { resolvedMetaTree } = useResolvedMetaTree(metaTree);
229
+ const inputRef = useRef<HTMLDivElement>(null);
230
+ const [isComposing, setIsComposing] = useState(false);
231
+ const [changed, setChanged] = useState(false);
232
+ const [range, setRange] = useState<RangeIndexes>([-1, 0, -1, 0]);
233
+
234
+ const value = typeof props.value === 'string' ? props.value : props.value == null ? '' : String(props.value);
235
+ const variableRegExp = converters?.variableRegExp ?? DEFAULT_VARIABLE_REGEXP;
236
+
237
+ const labelMap = useMemo(
238
+ () => buildLabelMap(resolvedMetaTree as MetaTreeNode[] | undefined, ctx.t, converters),
239
+ [resolvedMetaTree, ctx, converters],
240
+ );
241
+
242
+ const [html, setHtml] = useState(() => renderHTML(value, labelMap, variableRegExp));
243
+
244
+ const emitChange = useCallback(
245
+ (target: HTMLElement) => {
246
+ onChange?.(getDomValue(target).trim());
247
+ },
248
+ [onChange],
249
+ );
250
+
251
+ useEffect(() => {
252
+ setHtml(renderHTML(value, labelMap, variableRegExp));
253
+ if (!changed) {
254
+ setRange([-1, 0, -1, 0]);
255
+ }
256
+ // eslint-disable-next-line react-hooks/exhaustive-deps
257
+ }, [value, labelMap]);
258
+
259
+ // Restore caret position after html update
260
+ useEffect(() => {
261
+ const element = inputRef.current;
262
+ if (!element) return;
263
+ if (document.activeElement !== element) return;
264
+
265
+ const nextRange = new Range();
266
+ if (changed) {
267
+ if (range.join() === '-1,0,-1,0') return;
268
+ const selection = window.getSelection?.();
269
+ if (!selection) return;
270
+ try {
271
+ const children = Array.from(element.childNodes) as HTMLElement[];
272
+ if (children.length) {
273
+ if (range[0] === -1) {
274
+ if (range[1]) nextRange.setStartAfter(children[range[1] - 1]);
275
+ } else {
276
+ nextRange.setStart(children[range[0]], range[1]);
277
+ }
278
+ if (range[2] === -1) {
279
+ if (range[3]) nextRange.setEndAfter(children[range[3] - 1]);
280
+ } else {
281
+ nextRange.setEnd(children[range[2]], range[3]);
282
+ }
283
+ }
284
+ nextRange.collapse(true);
285
+ selection.removeAllRanges();
286
+ selection.addRange(nextRange);
287
+ } catch {
288
+ /* ignore */
289
+ }
290
+ } else {
291
+ const { lastChild } = element;
292
+ if (lastChild) {
293
+ nextRange.setStartAfter(lastChild);
294
+ nextRange.setEndAfter(lastChild);
295
+ const nodes = Array.from(element.childNodes);
296
+ const idx = nodes.indexOf(lastChild);
297
+ const startIndex = nextRange.startContainer === element ? -1 : idx;
298
+ const endIndex = nextRange.startContainer === element ? -1 : idx;
299
+ setRange([startIndex, nextRange.startOffset, endIndex, nextRange.endOffset]);
300
+ }
301
+ }
302
+ }, [changed, html, range]);
303
+
304
+ const insertVariable = useCallback(
305
+ (variable: string, meta?: MetaTreeNode) => {
306
+ const current = inputRef.current;
307
+ if (!current || !variable) return;
308
+
309
+ const label =
310
+ labelMap.get(normalizeVariableKey(variable)) ||
311
+ (meta
312
+ ? [...(meta.parentTitles || []), reactNodeToPlainText(meta.title || meta.name)].map(ctx.t).join('/')
313
+ : variable);
314
+
315
+ current.focus();
316
+ pasteHTML(current, createTagHTML(variable, label), range);
317
+ setChanged(true);
318
+ setRange(getCurrentRange(current));
319
+ emitChange(current);
320
+ },
321
+ [labelMap, range, emitChange, ctx],
322
+ );
323
+
324
+ const handleSelectorChange = useCallback(
325
+ (next: string, meta?: MetaTreeNode) => {
326
+ if (!next) return;
327
+ insertVariable(next, meta);
328
+ },
329
+ [insertVariable],
330
+ );
331
+
332
+ const handleInput = useCallback(
333
+ ({ currentTarget }: React.FormEvent<HTMLDivElement>) => {
334
+ if (isComposing) return;
335
+ setChanged(true);
336
+ setRange(getCurrentRange(currentTarget));
337
+ emitChange(currentTarget);
338
+ },
339
+ [emitChange, isComposing],
340
+ );
341
+
342
+ const handleBlur = useCallback(({ currentTarget }: React.FocusEvent<HTMLDivElement>) => {
343
+ setRange(getCurrentRange(currentTarget));
344
+ }, []);
345
+
346
+ const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
347
+ if (event.key === 'Enter') {
348
+ event.preventDefault();
349
+ }
350
+ }, []);
351
+
352
+ const handlePaste = useCallback(
353
+ (event: React.ClipboardEvent<HTMLDivElement>) => {
354
+ event.preventDefault();
355
+ // Paste as plain text only; variable tags must be inserted via the picker.
356
+ const text = event.clipboardData.getData('text/plain').replace(/\n/g, ' ');
357
+ if (!text) return;
358
+ setChanged(true);
359
+ pasteHTML(event.currentTarget, escapeHtml(text));
360
+ setRange(getCurrentRange(event.currentTarget));
361
+ emitChange(event.currentTarget);
362
+ },
363
+ [emitChange],
364
+ );
365
+
366
+ const handleCompositionStart = useCallback(() => setIsComposing(true), []);
367
+ const handleCompositionEnd = useCallback(
368
+ ({ currentTarget }: React.CompositionEvent<HTMLDivElement>) => {
369
+ setIsComposing(false);
370
+ setChanged(true);
371
+ setRange(getCurrentRange(currentTarget));
372
+ emitChange(currentTarget);
373
+ },
374
+ [emitChange],
375
+ );
376
+
377
+ const wrapperClassName = useMemo(
378
+ () => css`
379
+ display: flex;
380
+ width: 100%;
381
+ min-width: 0;
382
+
383
+ &.ant-space-compact {
384
+ display: flex;
385
+ }
386
+
387
+ /* The trigger button from FlowContextSelector sits at the right end.
388
+ Flatten its left corners so it shares the border with the editor. */
389
+ > .ant-btn {
390
+ flex-shrink: 0;
391
+ border-top-left-radius: 0;
392
+ border-bottom-left-radius: 0;
393
+ margin-left: -${token.lineWidth}px;
394
+ }
395
+
396
+ > .ant-btn:hover,
397
+ > .ant-btn:focus {
398
+ z-index: 2;
399
+ }
400
+ `,
401
+ [token.lineWidth],
402
+ );
403
+
404
+ const addonClassName = useMemo(
405
+ () => css`
406
+ display: inline-flex;
407
+ align-items: center;
408
+ padding: 0 ${token.paddingSM}px;
409
+ background: ${token.colorFillTertiary};
410
+ border: ${token.lineWidth}px ${token.lineType} ${token.colorBorder};
411
+ border-right: 0;
412
+ border-radius: ${token.borderRadius}px 0 0 ${token.borderRadius}px;
413
+ color: ${token.colorText};
414
+ font-size: ${token.fontSize}px;
415
+ line-height: 1;
416
+ white-space: nowrap;
417
+ `,
418
+ [token],
419
+ );
420
+
421
+ const editorClassName = useMemo(() => {
422
+ const verticalPad = Math.max(
423
+ 0,
424
+ (token.controlHeight - Math.round(token.lineHeight * token.fontSize)) / 2 - token.lineWidth,
425
+ );
426
+ return css`
427
+ flex: 1 1 auto;
428
+ min-width: 0;
429
+ min-height: ${token.controlHeight}px;
430
+ padding: ${verticalPad}px ${token.paddingSM}px;
431
+ overflow: hidden;
432
+ white-space: pre-wrap;
433
+ word-break: break-word;
434
+ line-height: ${token.lineHeight};
435
+ font-size: ${token.fontSize}px;
436
+ color: ${token.colorText};
437
+ background: ${token.colorBgContainer};
438
+ border: ${token.lineWidth}px ${token.lineType} ${token.colorBorder};
439
+ /* Right corners are always flat because the X picker button is glued to the right side. */
440
+ border-radius: ${addonBefore ? '0' : `${token.borderRadius}px 0 0 ${token.borderRadius}px`};
441
+ cursor: text;
442
+ transition: all ${token.motionDurationMid};
443
+ outline: none;
444
+
445
+ &:hover {
446
+ border-color: ${token.colorPrimaryHover};
447
+ z-index: 1;
448
+ }
449
+
450
+ &:focus,
451
+ &:focus-visible {
452
+ border-color: ${token.colorPrimary};
453
+ box-shadow: 0 0 0 ${token.controlOutlineWidth}px ${token.controlOutline};
454
+ z-index: 1;
455
+ }
456
+
457
+ &[data-placeholder]:empty::before {
458
+ content: attr(data-placeholder);
459
+ color: ${token.colorTextPlaceholder};
460
+ pointer-events: none;
461
+ }
462
+
463
+ .${TAG_CLASS} {
464
+ /* inline lets long tag content wrap naturally across lines, mirroring v1. */
465
+ display: inline;
466
+ margin: 0 ${token.marginXXS}px;
467
+ padding: ${token.paddingXXS}px ${token.paddingXS}px;
468
+ font-size: ${token.fontSizeSM}px;
469
+ line-height: ${token.lineHeightSM};
470
+ color: ${token.colorPrimaryText};
471
+ background: ${token.colorPrimaryBg};
472
+ border: ${token.lineWidth}px ${token.lineType} ${token.colorPrimaryBorder};
473
+ border-radius: ${token.borderRadiusSM}px;
474
+ vertical-align: baseline;
475
+ user-select: none;
476
+ cursor: default;
477
+ }
478
+
479
+ &.is-disabled {
480
+ background: ${token.colorBgContainerDisabled};
481
+ color: ${token.colorTextDisabled};
482
+ border-color: ${token.colorBorder};
483
+ cursor: not-allowed;
484
+
485
+ &:hover {
486
+ border-color: ${token.colorBorder};
487
+ }
488
+
489
+ .${TAG_CLASS} {
490
+ color: ${token.colorTextDisabled};
491
+ background: ${token.colorFillTertiary};
492
+ border-color: ${token.colorBorder};
493
+ }
494
+ }
495
+ `;
496
+ }, [token, addonBefore]);
497
+
498
+ return (
499
+ <>
500
+ <Space.Compact className={cx('nb-variable-hybrid-input', wrapperClassName, className)} style={style}>
501
+ {addonBefore != null && <span className={addonClassName}>{addonBefore}</span>}
502
+ <div
503
+ ref={inputRef}
504
+ role="textbox"
505
+ aria-label="textbox"
506
+ className={cx(editorClassName, {
507
+ 'is-disabled': disabled,
508
+ })}
509
+ contentEditable={!disabled}
510
+ data-placeholder={placeholder}
511
+ onInput={handleInput}
512
+ onBlur={handleBlur}
513
+ onKeyDown={handleKeyDown}
514
+ onPaste={handlePaste}
515
+ onCompositionStart={handleCompositionStart}
516
+ onCompositionEnd={handleCompositionEnd}
517
+ dangerouslySetInnerHTML={{ __html: html }}
518
+ />
519
+ <FlowContextSelector
520
+ metaTree={metaTree}
521
+ disabled={disabled}
522
+ parseValueToPath={converters?.parseValueToPath ?? defaultParseValueToPath}
523
+ formatPathToValue={(item) => converters?.formatPathToValue?.(item) || defaultFormatPathToValue(item)}
524
+ onChange={handleSelectorChange}
525
+ />
526
+ </Space.Compact>
527
+ </>
528
+ );
529
+ };
530
+
531
+ export const VariableHybridInput = React.memo(VariableHybridInputComponent);