@shipfox/react-ui 0.32.2 → 0.33.0
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/dist/components/code-block/code-block-footer.js +7 -2
- package/dist/components/shipql-editor/index.d.ts +1 -1
- package/dist/components/shipql-editor/lexical/on-blur-plugin.d.ts +2 -1
- package/dist/components/shipql-editor/lexical/on-blur-plugin.js +8 -4
- package/dist/components/shipql-editor/lexical/shipql-leaf-node.d.ts +5 -2
- package/dist/components/shipql-editor/lexical/shipql-leaf-node.js +18 -8
- package/dist/components/shipql-editor/lexical/shipql-plugin.d.ts +2 -1
- package/dist/components/shipql-editor/lexical/shipql-plugin.js +188 -34
- package/dist/components/shipql-editor/shipql-editor-inner.d.ts +1 -1
- package/dist/components/shipql-editor/shipql-editor-inner.js +53 -7
- package/dist/components/shipql-editor/shipql-editor.d.ts +2 -1
- package/dist/components/shipql-editor/suggestions/generate-suggestions.d.ts +8 -2
- package/dist/components/shipql-editor/suggestions/generate-suggestions.js +94 -20
- package/dist/components/shipql-editor/suggestions/shipql-range-facet-panel.js +2 -2
- package/dist/components/shipql-editor/suggestions/shipql-suggestion-item.d.ts +1 -1
- package/dist/components/shipql-editor/suggestions/shipql-suggestion-item.js +74 -10
- package/dist/components/shipql-editor/suggestions/shipql-suggestions-dropdown.d.ts +2 -1
- package/dist/components/shipql-editor/suggestions/shipql-suggestions-dropdown.js +43 -11
- package/dist/components/shipql-editor/suggestions/shipql-suggestions-plugin.js +7 -5
- package/dist/components/shipql-editor/suggestions/types.d.ts +25 -3
- package/dist/styles.css +2 -1
- package/package.json +7 -7
|
@@ -6,6 +6,9 @@ import { Text } from '../../components/typography/index.js';
|
|
|
6
6
|
import { useResolvedTheme } from '../../hooks/useResolvedTheme.js';
|
|
7
7
|
import { ShipfoxLoader } from 'shipfox-loader-react';
|
|
8
8
|
import { cn } from '../../utils/cn.js';
|
|
9
|
+
// In Playwright (Argos CI) navigator.webdriver is true. Disable canvas-based
|
|
10
|
+
// animations so screenshots are deterministic.
|
|
11
|
+
const isTest = typeof navigator !== 'undefined' && navigator.webdriver === true;
|
|
9
12
|
export function CodeBlockFooter({ className, asChild = false, state = 'running', message, description, icon, children, ...props }) {
|
|
10
13
|
const Comp = asChild ? Slot : 'div';
|
|
11
14
|
const resolvedTheme = useResolvedTheme();
|
|
@@ -13,7 +16,8 @@ export function CodeBlockFooter({ className, asChild = false, state = 'running',
|
|
|
13
16
|
size: 20,
|
|
14
17
|
animation: "circular",
|
|
15
18
|
color: resolvedTheme === 'dark' ? 'white' : 'orange',
|
|
16
|
-
background: resolvedTheme === 'dark' ? 'dark' : 'light'
|
|
19
|
+
background: resolvedTheme === 'dark' ? 'dark' : 'light',
|
|
20
|
+
autoPlay: !isTest
|
|
17
21
|
}) : /*#__PURE__*/ _jsx(Icon, {
|
|
18
22
|
name: "checkCircleSolid",
|
|
19
23
|
className: "size-20 text-foreground-neutral-base",
|
|
@@ -41,7 +45,8 @@ export function CodeBlockFooter({ className, asChild = false, state = 'running',
|
|
|
41
45
|
message && /*#__PURE__*/ _jsx(CodeBlockFooterMessage, {
|
|
42
46
|
children: state === 'running' && typeof message === 'string' ? /*#__PURE__*/ _jsx(ShinyText, {
|
|
43
47
|
text: message,
|
|
44
|
-
speed: 3
|
|
48
|
+
speed: 3,
|
|
49
|
+
disabled: isTest
|
|
45
50
|
}) : message
|
|
46
51
|
}),
|
|
47
52
|
description && /*#__PURE__*/ _jsx(CodeBlockFooterDescription, {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export type { LeafAstNode } from './lexical/shipql-leaf-node';
|
|
2
|
-
export { type FacetDef, type FormatLeafDisplay, type LeafChangePayload, type RangeFacetConfig, ShipQLEditor, type ShipQLEditorProps, } from './shipql-editor';
|
|
2
|
+
export { type FacetDef, type FacetMetadata, type FormatLeafDisplay, type LeafChangePayload, type RangeFacetConfig, ShipQLEditor, type ShipQLEditorProps, } from './shipql-editor';
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AstNode } from '@shipfox/shipql-parser';
|
|
2
2
|
interface OnBlurPluginProps {
|
|
3
3
|
onChange?: (ast: AstNode) => void;
|
|
4
|
+
allowFreeText?: boolean;
|
|
4
5
|
}
|
|
5
|
-
export declare function OnBlurPlugin({ onChange }: OnBlurPluginProps): null;
|
|
6
|
+
export declare function OnBlurPlugin({ onChange, allowFreeText }: OnBlurPluginProps): null;
|
|
6
7
|
export {};
|
|
7
8
|
//# sourceMappingURL=on-blur-plugin.d.ts.map
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
2
|
-
import { parse } from '@shipfox/shipql-parser';
|
|
2
|
+
import { hasTextNodes, parse } from '@shipfox/shipql-parser';
|
|
3
3
|
import { $getRoot, BLUR_COMMAND, COMMAND_PRIORITY_LOW } from 'lexical';
|
|
4
4
|
import { useEffect, useRef } from 'react';
|
|
5
|
-
export function OnBlurPlugin({ onChange }) {
|
|
5
|
+
export function OnBlurPlugin({ onChange, allowFreeText = true }) {
|
|
6
6
|
const [editor] = useLexicalComposerContext();
|
|
7
|
-
// Keep latest callback accessible without re-registering the command.
|
|
8
7
|
const onChangeRef = useRef(onChange);
|
|
9
8
|
onChangeRef.current = onChange;
|
|
9
|
+
const allowFreeTextRef = useRef(allowFreeText);
|
|
10
|
+
allowFreeTextRef.current = allowFreeText;
|
|
10
11
|
useEffect(()=>{
|
|
11
12
|
return editor.registerCommand(BLUR_COMMAND, ()=>{
|
|
12
13
|
const text = editor.getEditorState().read(()=>$getRoot().getTextContent());
|
|
13
14
|
try {
|
|
14
15
|
const ast = parse(text);
|
|
15
|
-
if (ast)
|
|
16
|
+
if (ast) {
|
|
17
|
+
if (!allowFreeTextRef.current && hasTextNodes(ast)) return false;
|
|
18
|
+
onChangeRef.current?.(ast);
|
|
19
|
+
}
|
|
16
20
|
} catch {
|
|
17
21
|
// Invalid query — do not call onChange.
|
|
18
22
|
}
|
|
@@ -11,20 +11,23 @@ type SerializedShipQLLeafNode = SerializedTextNode & {
|
|
|
11
11
|
export declare class ShipQLLeafNode extends TextNode {
|
|
12
12
|
__shipqlNode: LeafAstNode;
|
|
13
13
|
__displayText: string | null;
|
|
14
|
-
|
|
14
|
+
__freeTextError: boolean;
|
|
15
|
+
constructor(text: string, shipqlNode: LeafAstNode, key?: NodeKey, displayText?: string, freeTextError?: boolean);
|
|
15
16
|
static getType(): string;
|
|
16
17
|
static clone(node: ShipQLLeafNode): ShipQLLeafNode;
|
|
17
18
|
static importJSON(serialized: SerializedShipQLLeafNode): ShipQLLeafNode;
|
|
18
19
|
exportJSON(): SerializedShipQLLeafNode;
|
|
19
20
|
createDOM(config: EditorConfig): HTMLElement;
|
|
20
21
|
updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean;
|
|
22
|
+
_isLeafValid(): boolean;
|
|
21
23
|
isSimpleText(): boolean;
|
|
22
24
|
canInsertTextAfter(): boolean;
|
|
23
25
|
canInsertTextBefore(): boolean;
|
|
24
26
|
getShipQLNode(): LeafAstNode;
|
|
25
27
|
}
|
|
26
28
|
export declare function $isShipQLLeafNode(node: unknown): node is ShipQLLeafNode;
|
|
27
|
-
export declare function $createShipQLLeafNode(text: string, shipqlNode: LeafAstNode, displayText?: string): ShipQLLeafNode;
|
|
29
|
+
export declare function $createShipQLLeafNode(text: string, shipqlNode: LeafAstNode, displayText?: string, freeTextError?: boolean): ShipQLLeafNode;
|
|
30
|
+
export declare function $setLeafFreeTextError(node: ShipQLLeafNode, value: boolean): void;
|
|
28
31
|
/** Returns true if the AST node qualifies as a visual leaf chip in the editor. */
|
|
29
32
|
export declare function isAstLeafNode(ast: AstNode): ast is LeafAstNode;
|
|
30
33
|
export declare function leafSource(node: LeafAstNode): string;
|
|
@@ -30,16 +30,17 @@ function isValidLeafText(text) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
export class ShipQLLeafNode extends TextNode {
|
|
33
|
-
constructor(text, shipqlNode, key, displayText){
|
|
33
|
+
constructor(text, shipqlNode, key, displayText, freeTextError = false){
|
|
34
34
|
super(text, key);
|
|
35
35
|
this.__shipqlNode = shipqlNode;
|
|
36
36
|
this.__displayText = displayText ?? null;
|
|
37
|
+
this.__freeTextError = freeTextError;
|
|
37
38
|
}
|
|
38
39
|
static getType() {
|
|
39
40
|
return 'shipql-leaf';
|
|
40
41
|
}
|
|
41
42
|
static clone(node) {
|
|
42
|
-
return new ShipQLLeafNode(node.__text, node.__shipqlNode, node.__key, node.__displayText ?? undefined);
|
|
43
|
+
return new ShipQLLeafNode(node.__text, node.__shipqlNode, node.__key, node.__displayText ?? undefined, node.__freeTextError);
|
|
43
44
|
}
|
|
44
45
|
static importJSON(serialized) {
|
|
45
46
|
const text = serialized.text;
|
|
@@ -80,7 +81,7 @@ export class ShipQLLeafNode extends TextNode {
|
|
|
80
81
|
if (this.__displayText) {
|
|
81
82
|
element.textContent = this.__displayText;
|
|
82
83
|
}
|
|
83
|
-
const valid =
|
|
84
|
+
const valid = this._isLeafValid();
|
|
84
85
|
for (const cls of LEAF_BASE_CLASSES.split(' '))element.classList.add(cls);
|
|
85
86
|
for (const cls of (valid ? LEAF_NORMAL_CLASSES : LEAF_ERROR_CLASSES).split(' '))element.classList.add(cls);
|
|
86
87
|
element.setAttribute('data-shipql-leaf', 'true');
|
|
@@ -89,9 +90,9 @@ export class ShipQLLeafNode extends TextNode {
|
|
|
89
90
|
}
|
|
90
91
|
updateDOM(prevNode, dom, config) {
|
|
91
92
|
const result = super.updateDOM(prevNode, dom, config);
|
|
92
|
-
if (prevNode.__text !== this.__text) {
|
|
93
|
-
const prevValid =
|
|
94
|
-
const nextValid =
|
|
93
|
+
if (prevNode.__text !== this.__text || prevNode.__freeTextError !== this.__freeTextError) {
|
|
94
|
+
const prevValid = prevNode._isLeafValid();
|
|
95
|
+
const nextValid = this._isLeafValid();
|
|
95
96
|
if (prevValid !== nextValid) {
|
|
96
97
|
for (const cls of (prevValid ? LEAF_NORMAL_CLASSES : LEAF_ERROR_CLASSES).split(' '))dom.classList.remove(cls);
|
|
97
98
|
for (const cls of (nextValid ? LEAF_NORMAL_CLASSES : LEAF_ERROR_CLASSES).split(' '))dom.classList.add(cls);
|
|
@@ -99,6 +100,11 @@ export class ShipQLLeafNode extends TextNode {
|
|
|
99
100
|
}
|
|
100
101
|
return result;
|
|
101
102
|
}
|
|
103
|
+
_isLeafValid() {
|
|
104
|
+
if (!isValidLeafText(this.__text)) return false;
|
|
105
|
+
if (this.__freeTextError) return false;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
102
108
|
isSimpleText() {
|
|
103
109
|
return false;
|
|
104
110
|
}
|
|
@@ -115,8 +121,12 @@ export class ShipQLLeafNode extends TextNode {
|
|
|
115
121
|
export function $isShipQLLeafNode(node) {
|
|
116
122
|
return node instanceof ShipQLLeafNode;
|
|
117
123
|
}
|
|
118
|
-
export function $createShipQLLeafNode(text, shipqlNode, displayText) {
|
|
119
|
-
return new ShipQLLeafNode(text, shipqlNode, undefined, displayText);
|
|
124
|
+
export function $createShipQLLeafNode(text, shipqlNode, displayText, freeTextError = false) {
|
|
125
|
+
return new ShipQLLeafNode(text, shipqlNode, undefined, displayText, freeTextError);
|
|
126
|
+
}
|
|
127
|
+
export function $setLeafFreeTextError(node, value) {
|
|
128
|
+
const writable = node.getWritable();
|
|
129
|
+
writable.__freeTextError = value;
|
|
120
130
|
}
|
|
121
131
|
/** Returns true if the AST node qualifies as a visual leaf chip in the editor. */ export function isAstLeafNode(ast) {
|
|
122
132
|
return isSimpleLeaf(ast) || isNotLeaf(ast) || isGroupedCompound(ast);
|
|
@@ -4,7 +4,8 @@ export declare const REMOVE_LEAF_COMMAND: import("lexical").LexicalCommand<strin
|
|
|
4
4
|
interface ShipQLPluginProps {
|
|
5
5
|
onLeafFocus?: (node: LeafAstNode | null) => void;
|
|
6
6
|
formatLeafDisplay?: (source: string, node: LeafAstNode) => string;
|
|
7
|
+
allowFreeText?: boolean;
|
|
7
8
|
}
|
|
8
|
-
export declare function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }: ShipQLPluginProps): null;
|
|
9
|
+
export declare function ShipQLPlugin({ onLeafFocus, formatLeafDisplay, allowFreeText, }: ShipQLPluginProps): null;
|
|
9
10
|
export {};
|
|
10
11
|
//# sourceMappingURL=shipql-plugin.d.ts.map
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
2
2
|
import { parse, removeBySource, stringify } from '@shipfox/shipql-parser';
|
|
3
|
-
import { $createRangeSelection, $createTextNode, $getRoot, $getSelection, $isRangeSelection, $isTextNode, $setSelection, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_NORMAL, createCommand, INSERT_PARAGRAPH_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ENTER_COMMAND } from 'lexical';
|
|
3
|
+
import { $createRangeSelection, $createTextNode, $getRoot, $getSelection, $isRangeSelection, $isTextNode, $setSelection, BLUR_COMMAND, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_NORMAL, createCommand, FOCUS_COMMAND, INSERT_PARAGRAPH_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ENTER_COMMAND } from 'lexical';
|
|
4
4
|
import { useEffect, useRef } from 'react';
|
|
5
5
|
import { handleArrowRightFromLeaf } from './handle-arrow-right-from-leaf.js';
|
|
6
|
-
import { $createShipQLLeafNode, $isShipQLLeafNode, isGroupedCompound, isSimpleLeaf, leafSource } from './shipql-leaf-node.js';
|
|
6
|
+
import { $createShipQLLeafNode, $isShipQLLeafNode, $setLeafFreeTextError, isGroupedCompound, isSimpleLeaf, leafSource } from './shipql-leaf-node.js';
|
|
7
7
|
function collectLeaves(ast) {
|
|
8
8
|
// Simple terminals are always a single leaf chip.
|
|
9
9
|
if (isSimpleLeaf(ast)) {
|
|
@@ -86,7 +86,15 @@ function needsRebuild(children, segments) {
|
|
|
86
86
|
}
|
|
87
87
|
return false;
|
|
88
88
|
}
|
|
89
|
+
function getOrderedLeafStateKey(text, counts) {
|
|
90
|
+
const index = (counts.get(text) ?? 0) + 1;
|
|
91
|
+
counts.set(text, index);
|
|
92
|
+
return `${text}::${index}`;
|
|
93
|
+
}
|
|
89
94
|
function getAbsoluteOffset(para, point) {
|
|
95
|
+
if (point.type === 'element') {
|
|
96
|
+
return para.getChildren().slice(0, point.offset).reduce((offset, child)=>offset + ($isTextNode(child) ? child.getTextContentSize() : 0), 0);
|
|
97
|
+
}
|
|
90
98
|
let offset = 0;
|
|
91
99
|
for (const child of para.getChildren()){
|
|
92
100
|
if (child.getKey() === point.key) return offset + point.offset;
|
|
@@ -94,19 +102,62 @@ function getAbsoluteOffset(para, point) {
|
|
|
94
102
|
}
|
|
95
103
|
return offset + point.offset;
|
|
96
104
|
}
|
|
105
|
+
function getDesiredLeafErrorState(segments, selectionOffset, allowFreeText, isFocused) {
|
|
106
|
+
const counts = new Map();
|
|
107
|
+
const leafRanges = [];
|
|
108
|
+
let offset = 0;
|
|
109
|
+
for (const seg of segments){
|
|
110
|
+
if (seg.kind === 'leaf') {
|
|
111
|
+
leafRanges.push({
|
|
112
|
+
stateKey: getOrderedLeafStateKey(seg.text, counts),
|
|
113
|
+
node: seg.node,
|
|
114
|
+
start: offset,
|
|
115
|
+
end: offset + seg.text.length
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
offset += seg.text.length;
|
|
119
|
+
}
|
|
120
|
+
const activeLeafKey = selectionOffset >= 0 ? leafRanges.find((leaf)=>selectionOffset >= leaf.start && selectionOffset <= leaf.end)?.stateKey ?? null : null;
|
|
121
|
+
const errorState = new Map();
|
|
122
|
+
for (const leaf of leafRanges){
|
|
123
|
+
const shouldError = !allowFreeText && isTextLeaf(leaf.node) && (!isFocused || leaf.stateKey !== activeLeafKey);
|
|
124
|
+
errorState.set(leaf.stateKey, shouldError);
|
|
125
|
+
}
|
|
126
|
+
return errorState;
|
|
127
|
+
}
|
|
128
|
+
function leafErrorStateMatches(children, desiredErrorState) {
|
|
129
|
+
const counts = new Map();
|
|
130
|
+
for (const child of children){
|
|
131
|
+
if (!$isShipQLLeafNode(child)) continue;
|
|
132
|
+
const stateKey = getOrderedLeafStateKey(child.getTextContent(), counts);
|
|
133
|
+
if ((desiredErrorState.get(stateKey) ?? false) !== child.getLatest().__freeTextError) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
/** Returns true if a leaf segment represents a free-text node (bare word / quoted string). */ function isTextLeaf(node) {
|
|
140
|
+
if (node.type === 'text') return true;
|
|
141
|
+
if (node.type === 'not' && node.expr.type === 'text') return true;
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
97
144
|
// ─── Commands ─────────────────────────────────────────────────────────────────
|
|
98
145
|
/** Payload: the Lexical node key of the leaf to remove. */ export const REMOVE_LEAF_COMMAND = createCommand('REMOVE_LEAF_COMMAND');
|
|
99
146
|
// ─── Plugin ───────────────────────────────────────────────────────────────────
|
|
100
147
|
const REBUILD_TAG = 'shipql-rebuild';
|
|
101
|
-
export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
|
|
148
|
+
export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay, allowFreeText = true }) {
|
|
102
149
|
const [editor] = useLexicalComposerContext();
|
|
103
150
|
// Keep latest callback accessible inside Lexical listeners without re-registering.
|
|
104
151
|
const onLeafFocusRef = useRef(onLeafFocus);
|
|
105
152
|
onLeafFocusRef.current = onLeafFocus;
|
|
106
153
|
const formatLeafDisplayRef = useRef(formatLeafDisplay);
|
|
107
154
|
formatLeafDisplayRef.current = formatLeafDisplay;
|
|
155
|
+
const allowFreeTextRef = useRef(allowFreeText);
|
|
156
|
+
allowFreeTextRef.current = allowFreeText;
|
|
108
157
|
// Track the key of the last focused leaf to avoid redundant callbacks.
|
|
109
158
|
const lastFocusedKeyRef = useRef(null);
|
|
159
|
+
// Track whether the editor is focused so we can show errors only when blurred.
|
|
160
|
+
const isFocusedRef = useRef(true);
|
|
110
161
|
useEffect(()=>{
|
|
111
162
|
// Block newlines — ShipQL is a single-line language.
|
|
112
163
|
const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, (event)=>{
|
|
@@ -156,6 +207,85 @@ export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
|
|
|
156
207
|
});
|
|
157
208
|
return true;
|
|
158
209
|
}, COMMAND_PRIORITY_HIGH);
|
|
210
|
+
// ── Blur / Focus: toggle free-text error styling via rebuild ────────────
|
|
211
|
+
function syncLeafNodesWithCurrentState() {
|
|
212
|
+
editor.update(()=>{
|
|
213
|
+
const para = $getRoot().getFirstChild();
|
|
214
|
+
if (!para) return;
|
|
215
|
+
const text = para.getTextContent();
|
|
216
|
+
const children = para.getChildren();
|
|
217
|
+
const sel = $getSelection();
|
|
218
|
+
const selectionOffset = $isRangeSelection(sel) ? getAbsoluteOffset(para, sel.anchor) : -1;
|
|
219
|
+
if (!text) {
|
|
220
|
+
if (children.some($isShipQLLeafNode)) {
|
|
221
|
+
para.clear();
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
let ast = null;
|
|
226
|
+
try {
|
|
227
|
+
ast = parse(text);
|
|
228
|
+
} catch {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!ast) {
|
|
232
|
+
if (!children.some($isShipQLLeafNode)) return;
|
|
233
|
+
para.clear();
|
|
234
|
+
para.append($createTextNode(text));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const segments = tokenize(text, collectLeaves(ast));
|
|
238
|
+
const fmt = formatLeafDisplayRef.current;
|
|
239
|
+
const desiredLeafErrors = getDesiredLeafErrorState(segments, selectionOffset, allowFreeTextRef.current, isFocusedRef.current);
|
|
240
|
+
if (!needsRebuild(children, segments) && leafErrorStateMatches(children, desiredLeafErrors)) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
para.clear();
|
|
244
|
+
const newNodes = [];
|
|
245
|
+
const counts = new Map();
|
|
246
|
+
for (const seg of segments){
|
|
247
|
+
const node = seg.kind === 'leaf' ? $createShipQLLeafNode(seg.text, seg.node, fmt?.(seg.text, seg.node), desiredLeafErrors.get(getOrderedLeafStateKey(seg.text, counts)) ?? false) : $createTextNode(seg.text);
|
|
248
|
+
newNodes.push(node);
|
|
249
|
+
para.append(node);
|
|
250
|
+
}
|
|
251
|
+
if (selectionOffset >= 0 && newNodes.length > 0) {
|
|
252
|
+
let remaining = selectionOffset;
|
|
253
|
+
let lastTextNode = null;
|
|
254
|
+
for (const node of newNodes){
|
|
255
|
+
if (!$isTextNode(node)) continue;
|
|
256
|
+
lastTextNode = node;
|
|
257
|
+
const len = node.getTextContentSize();
|
|
258
|
+
if (remaining <= len) {
|
|
259
|
+
const nextSelection = $createRangeSelection();
|
|
260
|
+
nextSelection.anchor.set(node.getKey(), remaining, 'text');
|
|
261
|
+
nextSelection.focus.set(node.getKey(), remaining, 'text');
|
|
262
|
+
$setSelection(nextSelection);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
remaining -= len;
|
|
266
|
+
}
|
|
267
|
+
if (lastTextNode && $isTextNode(lastTextNode)) {
|
|
268
|
+
const endSelection = $createRangeSelection();
|
|
269
|
+
const end = lastTextNode.getTextContentSize();
|
|
270
|
+
endSelection.anchor.set(lastTextNode.getKey(), end, 'text');
|
|
271
|
+
endSelection.focus.set(lastTextNode.getKey(), end, 'text');
|
|
272
|
+
$setSelection(endSelection);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}, {
|
|
276
|
+
tag: REBUILD_TAG
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const unregisterBlur = editor.registerCommand(BLUR_COMMAND, ()=>{
|
|
280
|
+
isFocusedRef.current = false;
|
|
281
|
+
if (!allowFreeTextRef.current) syncLeafNodesWithCurrentState();
|
|
282
|
+
return false;
|
|
283
|
+
}, COMMAND_PRIORITY_LOW);
|
|
284
|
+
const unregisterFocus = editor.registerCommand(FOCUS_COMMAND, ()=>{
|
|
285
|
+
isFocusedRef.current = true;
|
|
286
|
+
if (!allowFreeTextRef.current) syncLeafNodesWithCurrentState();
|
|
287
|
+
return false;
|
|
288
|
+
}, COMMAND_PRIORITY_LOW);
|
|
159
289
|
const unregisterUpdate = editor.registerUpdateListener(({ editorState, tags })=>{
|
|
160
290
|
// After our own rebuild we only need to refresh the leaf-focus callback.
|
|
161
291
|
if (tags.has(REBUILD_TAG)) {
|
|
@@ -175,6 +305,8 @@ export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
|
|
|
175
305
|
let shouldRebuild = false;
|
|
176
306
|
let nextSegments = [];
|
|
177
307
|
let savedOffset = -1;
|
|
308
|
+
let desiredLeafErrors = new Map();
|
|
309
|
+
let needsErrorStateUpdate = false;
|
|
178
310
|
editorState.read(()=>{
|
|
179
311
|
const para = $getRoot().getFirstChild();
|
|
180
312
|
if (!para) return;
|
|
@@ -197,7 +329,7 @@ export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
|
|
|
197
329
|
}
|
|
198
330
|
] : [];
|
|
199
331
|
const sel = $getSelection();
|
|
200
|
-
if ($isRangeSelection(sel)
|
|
332
|
+
if ($isRangeSelection(sel)) {
|
|
201
333
|
savedOffset = getAbsoluteOffset(para, sel.anchor);
|
|
202
334
|
}
|
|
203
335
|
}
|
|
@@ -209,16 +341,16 @@ export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
|
|
|
209
341
|
return;
|
|
210
342
|
}
|
|
211
343
|
const segments = tokenize(text, collectLeaves(ast));
|
|
344
|
+
const sel = $getSelection();
|
|
345
|
+
if ($isRangeSelection(sel)) {
|
|
346
|
+
savedOffset = getAbsoluteOffset(para, sel.anchor);
|
|
347
|
+
}
|
|
348
|
+
desiredLeafErrors = getDesiredLeafErrorState(segments, savedOffset, allowFreeTextRef.current, isFocusedRef.current);
|
|
212
349
|
if (needsRebuild(children, segments)) {
|
|
213
350
|
shouldRebuild = true;
|
|
214
351
|
nextSegments = segments;
|
|
215
|
-
const sel = $getSelection();
|
|
216
|
-
if ($isRangeSelection(sel) && sel.anchor.type === 'text') {
|
|
217
|
-
savedOffset = getAbsoluteOffset(para, sel.anchor);
|
|
218
|
-
}
|
|
219
352
|
} else {
|
|
220
|
-
// No structural change —
|
|
221
|
-
const sel = $getSelection();
|
|
353
|
+
// No structural change — update leaf-focus and deferred text-node errors.
|
|
222
354
|
if ($isRangeSelection(sel)) {
|
|
223
355
|
const anchor = sel.anchor.getNode();
|
|
224
356
|
const key = $isShipQLLeafNode(anchor) ? anchor.getKey() : null;
|
|
@@ -226,32 +358,72 @@ export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
|
|
|
226
358
|
lastFocusedKeyRef.current = key;
|
|
227
359
|
onLeafFocusRef.current?.(key !== null && $isShipQLLeafNode(anchor) ? anchor.getShipQLNode() : null);
|
|
228
360
|
}
|
|
361
|
+
} else if (lastFocusedKeyRef.current !== null) {
|
|
362
|
+
lastFocusedKeyRef.current = null;
|
|
363
|
+
onLeafFocusRef.current?.(null);
|
|
229
364
|
}
|
|
365
|
+
needsErrorStateUpdate = !leafErrorStateMatches(children, desiredLeafErrors);
|
|
230
366
|
}
|
|
231
367
|
});
|
|
232
|
-
if (!shouldRebuild)
|
|
368
|
+
if (!shouldRebuild) {
|
|
369
|
+
if (needsErrorStateUpdate) {
|
|
370
|
+
editor.update(()=>{
|
|
371
|
+
const para = $getRoot().getFirstChild();
|
|
372
|
+
if (!para) return;
|
|
373
|
+
const counts = new Map();
|
|
374
|
+
for (const child of para.getChildren()){
|
|
375
|
+
if (!$isShipQLLeafNode(child)) continue;
|
|
376
|
+
const stateKey = getOrderedLeafStateKey(child.getTextContent(), counts);
|
|
377
|
+
$setLeafFreeTextError(child, desiredLeafErrors.get(stateKey) ?? false);
|
|
378
|
+
}
|
|
379
|
+
}, {
|
|
380
|
+
tag: REBUILD_TAG
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
233
385
|
// ── Rebuild node structure ─────────────────────────────────────────────
|
|
234
386
|
editor.update(()=>{
|
|
235
387
|
const para = $getRoot().getFirstChild();
|
|
236
388
|
if (!para) return;
|
|
237
389
|
para.clear();
|
|
238
390
|
const fmt = formatLeafDisplayRef.current;
|
|
239
|
-
const
|
|
391
|
+
const occurrenceCounts = new Map();
|
|
392
|
+
const newNodes = nextSegments.map((seg)=>{
|
|
393
|
+
if (seg.kind === 'leaf') {
|
|
394
|
+
const stateKey = getOrderedLeafStateKey(seg.text, occurrenceCounts);
|
|
395
|
+
const freeTextError = desiredLeafErrors.get(stateKey) ?? false;
|
|
396
|
+
return $createShipQLLeafNode(seg.text, seg.node, fmt?.(seg.text, seg.node), freeTextError);
|
|
397
|
+
}
|
|
398
|
+
return $createTextNode(seg.text);
|
|
399
|
+
});
|
|
240
400
|
for (const node of newNodes)para.append(node);
|
|
241
401
|
// Restore cursor to the same absolute character position.
|
|
242
402
|
if (savedOffset >= 0 && newNodes.length > 0) {
|
|
243
403
|
let remaining = savedOffset;
|
|
404
|
+
let lastTextNode = null;
|
|
405
|
+
let restoredSelection = false;
|
|
244
406
|
for (const node of newNodes){
|
|
245
|
-
|
|
407
|
+
if (!$isTextNode(node)) continue;
|
|
408
|
+
lastTextNode = node;
|
|
409
|
+
const len = node.getTextContentSize();
|
|
246
410
|
if (remaining <= len) {
|
|
247
411
|
const sel = $createRangeSelection();
|
|
248
412
|
sel.anchor.set(node.getKey(), remaining, 'text');
|
|
249
413
|
sel.focus.set(node.getKey(), remaining, 'text');
|
|
250
414
|
$setSelection(sel);
|
|
415
|
+
restoredSelection = true;
|
|
251
416
|
break;
|
|
252
417
|
}
|
|
253
418
|
remaining -= len;
|
|
254
419
|
}
|
|
420
|
+
if (!restoredSelection && lastTextNode && $isTextNode(lastTextNode)) {
|
|
421
|
+
const sel = $createRangeSelection();
|
|
422
|
+
const end = lastTextNode.getTextContentSize();
|
|
423
|
+
sel.anchor.set(lastTextNode.getKey(), end, 'text');
|
|
424
|
+
sel.focus.set(lastTextNode.getKey(), end, 'text');
|
|
425
|
+
$setSelection(sel);
|
|
426
|
+
}
|
|
255
427
|
}
|
|
256
428
|
}, {
|
|
257
429
|
tag: REBUILD_TAG
|
|
@@ -261,32 +433,14 @@ export function ShipQLPlugin({ onLeafFocus, formatLeafDisplay }) {
|
|
|
261
433
|
// registered after Lexical has already committed its initialConfig editorState
|
|
262
434
|
// (useEffect runs post-paint), so the listener never fires for the first render.
|
|
263
435
|
// We do the full rebuild here instead of relying on the listener being triggered.
|
|
264
|
-
|
|
265
|
-
const para = $getRoot().getFirstChild();
|
|
266
|
-
if (!para) return;
|
|
267
|
-
const text = para.getTextContent();
|
|
268
|
-
if (!text) return;
|
|
269
|
-
const children = para.getChildren();
|
|
270
|
-
let ast = null;
|
|
271
|
-
try {
|
|
272
|
-
ast = parse(text);
|
|
273
|
-
} catch {
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
if (!ast) return;
|
|
277
|
-
const segments = tokenize(text, collectLeaves(ast));
|
|
278
|
-
if (!needsRebuild(children, segments)) return;
|
|
279
|
-
const fmt = formatLeafDisplayRef.current;
|
|
280
|
-
para.clear();
|
|
281
|
-
for (const seg of segments){
|
|
282
|
-
para.append(seg.kind === 'leaf' ? $createShipQLLeafNode(seg.text, seg.node, fmt?.(seg.text, seg.node)) : $createTextNode(seg.text));
|
|
283
|
-
}
|
|
284
|
-
});
|
|
436
|
+
syncLeafNodesWithCurrentState();
|
|
285
437
|
return ()=>{
|
|
286
438
|
unregisterEnter();
|
|
287
439
|
unregisterParagraph();
|
|
288
440
|
unregisterArrowRight();
|
|
289
441
|
unregisterRemoveLeaf();
|
|
442
|
+
unregisterBlur();
|
|
443
|
+
unregisterFocus();
|
|
290
444
|
unregisterUpdate();
|
|
291
445
|
};
|
|
292
446
|
}, [
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { ShipQLEditorInnerProps } from './shipql-editor';
|
|
2
|
-
export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder, className, disabled, mode, text, editorKey, isError, onTextChange, onClear, onToggleMode, facets, currentFacet, setCurrentFacet, valueSuggestions, isLoadingValueSuggestions, onLeafChange, formatLeafDisplay, }: ShipQLEditorInnerProps): import("react/jsx-runtime").JSX.Element;
|
|
2
|
+
export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder, className, disabled, mode, text, editorKey, isError, onTextChange, onClear, onToggleMode, facets, currentFacet, setCurrentFacet, valueSuggestions, isLoadingValueSuggestions, onLeafChange, formatLeafDisplay, allowFreeText, }: ShipQLEditorInnerProps): import("react/jsx-runtime").JSX.Element;
|
|
3
3
|
//# sourceMappingURL=shipql-editor-inner.d.ts.map
|
|
@@ -4,10 +4,11 @@ import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
|
|
4
4
|
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
|
5
5
|
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
|
6
6
|
import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin';
|
|
7
|
+
import { hasTextNodes } from '@shipfox/shipql-parser';
|
|
7
8
|
import { Input } from '../../components/input/index.js';
|
|
8
9
|
import { Popover, PopoverAnchor } from '../../components/popover/index.js';
|
|
9
10
|
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical';
|
|
10
|
-
import { useCallback, useRef, useState } from 'react';
|
|
11
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
11
12
|
import { cn } from '../../utils/cn.js';
|
|
12
13
|
import { Icon } from '../icon/icon.js';
|
|
13
14
|
import { LeafCloseOverlay } from './lexical/leaf-close-overlay.js';
|
|
@@ -21,16 +22,19 @@ import { ShipQLSuggestionsPlugin } from './suggestions/shipql-suggestions-plugin
|
|
|
21
22
|
const INPUT_CLASSES = 'block w-full rounded-6 bg-background-field-base py-2 pl-32 pr-58 sm:pr-64 text-md text-foreground-neutral-base caret-foreground-neutral-base outline-none focus:border-border-highlights-interactive shadow-button-neutral';
|
|
22
23
|
const INPUT_ERROR_CLASSES = 'shadow-border-error';
|
|
23
24
|
const BUTTON_CLASSES = 'shrink-0 text-foreground-neutral-subtle hover:text-foreground-neutral-base transition-all duration-150 flex justify-center items-center cursor-pointer w-28 sm:w-32 h-full';
|
|
24
|
-
export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder, className, disabled, mode, text, editorKey, isError, onTextChange, onClear, onToggleMode, facets, currentFacet, setCurrentFacet, valueSuggestions, isLoadingValueSuggestions, onLeafChange, formatLeafDisplay }) {
|
|
25
|
+
export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder, className, disabled, mode, text, editorKey, isError, onTextChange, onClear, onToggleMode, facets, currentFacet, setCurrentFacet, valueSuggestions, isLoadingValueSuggestions, onLeafChange, formatLeafDisplay, allowFreeText }) {
|
|
25
26
|
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
|
|
27
|
+
const [suggestionsSuppressed, setSuggestionsSuppressed] = useState(false);
|
|
26
28
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
27
29
|
const [items, setItems] = useState([]);
|
|
28
30
|
const [focusedLeafNode, setFocusedLeafNode] = useState(null);
|
|
29
31
|
const [isNegated, setIsNegated] = useState(false);
|
|
30
32
|
const [showSyntaxHelp, setShowSyntaxHelp] = useState(false);
|
|
33
|
+
const [isTextModeBlurError, setIsTextModeBlurError] = useState(false);
|
|
31
34
|
const isSelectingRef = useRef(false);
|
|
32
35
|
const applyRef = useRef(null);
|
|
33
36
|
const negationPrefixRef = useRef('');
|
|
37
|
+
const containerRef = useRef(null);
|
|
34
38
|
const hasSuggestions = Boolean(facets && facets.length > 0);
|
|
35
39
|
const showValueActions = Boolean(currentFacet);
|
|
36
40
|
const isRangeContext = items.length === 1 && items[0]?.type === 'range-slider';
|
|
@@ -56,9 +60,44 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
|
|
|
56
60
|
const handleToggleSyntaxHelp = useCallback(()=>{
|
|
57
61
|
setShowSyntaxHelp((prev)=>!prev);
|
|
58
62
|
}, []);
|
|
63
|
+
const handleDismissSuggestions = useCallback(()=>{
|
|
64
|
+
setSuggestionsSuppressed(true);
|
|
65
|
+
setSuggestionsOpen(false);
|
|
66
|
+
}, []);
|
|
67
|
+
const handleSuggestionsOpenChange = useCallback((open)=>{
|
|
68
|
+
if (open && suggestionsSuppressed) return;
|
|
69
|
+
setSuggestionsOpen(open);
|
|
70
|
+
}, [
|
|
71
|
+
suggestionsSuppressed
|
|
72
|
+
]);
|
|
73
|
+
const handleEditorMouseDownCapture = useCallback(()=>{
|
|
74
|
+
setSuggestionsSuppressed(false);
|
|
75
|
+
}, []);
|
|
76
|
+
useEffect(()=>{
|
|
77
|
+
if (!suggestionsOpen) return;
|
|
78
|
+
const handleMouseDown = (event)=>{
|
|
79
|
+
if (isSelectingRef.current) return;
|
|
80
|
+
const target = event.target;
|
|
81
|
+
if (target && containerRef.current?.contains(target)) return;
|
|
82
|
+
handleDismissSuggestions();
|
|
83
|
+
const activeElement = document.activeElement;
|
|
84
|
+
if (activeElement && containerRef.current?.contains(activeElement)) {
|
|
85
|
+
activeElement.blur();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
document.addEventListener('mousedown', handleMouseDown, true);
|
|
89
|
+
return ()=>{
|
|
90
|
+
document.removeEventListener('mousedown', handleMouseDown, true);
|
|
91
|
+
};
|
|
92
|
+
}, [
|
|
93
|
+
suggestionsOpen,
|
|
94
|
+
handleDismissSuggestions
|
|
95
|
+
]);
|
|
59
96
|
return /*#__PURE__*/ _jsxs("div", {
|
|
97
|
+
ref: containerRef,
|
|
60
98
|
"data-shipql-editor": true,
|
|
61
99
|
className: cn('relative', className),
|
|
100
|
+
onMouseDownCapture: handleEditorMouseDownCapture,
|
|
62
101
|
children: [
|
|
63
102
|
mode === 'editor' ? /*#__PURE__*/ _jsxs(Popover, {
|
|
64
103
|
open: hasSuggestions && suggestionsOpen,
|
|
@@ -108,10 +147,12 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
|
|
|
108
147
|
}),
|
|
109
148
|
/*#__PURE__*/ _jsx(ShipQLPlugin, {
|
|
110
149
|
onLeafFocus: handleLeafFocus,
|
|
111
|
-
formatLeafDisplay: formatLeafDisplay
|
|
150
|
+
formatLeafDisplay: formatLeafDisplay,
|
|
151
|
+
allowFreeText: allowFreeText
|
|
112
152
|
}),
|
|
113
153
|
/*#__PURE__*/ _jsx(OnBlurPlugin, {
|
|
114
|
-
onChange: onChange
|
|
154
|
+
onChange: onChange,
|
|
155
|
+
allowFreeText: allowFreeText
|
|
115
156
|
}),
|
|
116
157
|
/*#__PURE__*/ _jsx(OnTextChangePlugin, {
|
|
117
158
|
onTextChange: onTextChange
|
|
@@ -125,7 +166,7 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
|
|
|
125
166
|
valueSuggestions: valueSuggestions ?? [],
|
|
126
167
|
isLoadingValueSuggestions: isLoadingValueSuggestions ?? false,
|
|
127
168
|
open: suggestionsOpen,
|
|
128
|
-
setOpen:
|
|
169
|
+
setOpen: handleSuggestionsOpenChange,
|
|
129
170
|
selectedIndex: selectedIndex,
|
|
130
171
|
setSelectedIndex: setSelectedIndex,
|
|
131
172
|
items: items,
|
|
@@ -144,6 +185,7 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
|
|
|
144
185
|
selectedIndex: selectedIndex,
|
|
145
186
|
isSelectingRef: isSelectingRef,
|
|
146
187
|
onSelect: handleSelect,
|
|
188
|
+
onClose: handleDismissSuggestions,
|
|
147
189
|
isLoading: isLoadingValueSuggestions,
|
|
148
190
|
isNegated: isNegated,
|
|
149
191
|
onToggleNegate: handleToggleNegate,
|
|
@@ -163,10 +205,11 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
|
|
|
163
205
|
className: "shrink-0 text-foreground-neutral-muted"
|
|
164
206
|
}),
|
|
165
207
|
className: cn(INPUT_CLASSES, disabled && 'pointer-events-none opacity-50'),
|
|
166
|
-
"aria-invalid": isError,
|
|
208
|
+
"aria-invalid": isError || isTextModeBlurError,
|
|
167
209
|
value: text,
|
|
168
210
|
onChange: (e)=>{
|
|
169
211
|
const newText = e.target.value;
|
|
212
|
+
setIsTextModeBlurError(false);
|
|
170
213
|
onTextChange(newText);
|
|
171
214
|
const facetNames = facets ? normalizeFacets(facets) : [];
|
|
172
215
|
const facetCtx = detectFacetContext(newText, facetNames);
|
|
@@ -178,8 +221,11 @@ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder,
|
|
|
178
221
|
},
|
|
179
222
|
onBlur: (e)=>{
|
|
180
223
|
const ast = tryParse(e.target.value);
|
|
181
|
-
|
|
224
|
+
const hasDeferredFreeTextError = Boolean(ast && !allowFreeText && hasTextNodes(ast));
|
|
225
|
+
setIsTextModeBlurError(hasDeferredFreeTextError);
|
|
226
|
+
if (ast && !hasDeferredFreeTextError) onChange?.(ast);
|
|
182
227
|
},
|
|
228
|
+
onFocus: ()=>setIsTextModeBlurError(false),
|
|
183
229
|
placeholder: placeholder,
|
|
184
230
|
disabled: disabled
|
|
185
231
|
}),
|