Add FilterInput component for enhanced filtering capabilities
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Introduced a new FilterInput component to streamline user input for filter values, replacing the standard Input component in FilterSidebar and ObjectTable. - Updated FilterSidebar and ObjectTable to utilize FilterInput, improving the overall filtering experience and consistency across the dashboard. - Enhanced expression handling in ColumnFilterDropdown to accommodate the new FilterInput component, ensuring seamless integration with existing functionality.
This commit is contained in:
parent
fdd7a09d84
commit
6b848d392c
990
src/components/Dashboard/common/FilterInput.jsx
Normal file
990
src/components/Dashboard/common/FilterInput.jsx
Normal file
@ -0,0 +1,990 @@
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { ConfigProvider, Tag, theme } from 'antd'
|
||||
import { CloseCircleFilled } from '@ant-design/icons'
|
||||
import PropTypes from 'prop-types'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import classNames from 'classnames'
|
||||
import useCSSVarCls from 'antd/es/config-provider/hooks/useCSSVarCls'
|
||||
import useSize from 'antd/es/config-provider/hooks/useSize'
|
||||
import { useCompactItemContext } from 'antd/es/space/Compact'
|
||||
import useStyle, { useSharedStyle } from 'antd/es/input/style'
|
||||
import { useThemeContext } from '../context/ThemeContext'
|
||||
|
||||
// Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">".
|
||||
// Wildcards (* ?) and @ stay as plain text — they are not operands.
|
||||
const OPERANDS = [
|
||||
{ symbol: '..', label: 'TO', color: 'cyan' },
|
||||
{ symbol: '<>', label: '≠', color: 'volcano' },
|
||||
{ symbol: '>=', label: '> OR =', color: 'orange' },
|
||||
{ symbol: '<=', label: '< OR =', color: 'orange' },
|
||||
{ symbol: '|', label: 'OR', color: 'purple' },
|
||||
{ symbol: '&', label: 'AND', color: 'purple' },
|
||||
{ symbol: '>', label: '>', color: 'orange' },
|
||||
{ symbol: '<', label: '<', color: 'orange' },
|
||||
{ symbol: '=', label: '=', color: 'blue' }
|
||||
]
|
||||
|
||||
const OPERAND_BY_SYMBOL = Object.fromEntries(
|
||||
OPERANDS.map((operand) => [operand.symbol, operand])
|
||||
)
|
||||
|
||||
const tokenize = (raw = '') => {
|
||||
const value = String(raw)
|
||||
const tokens = []
|
||||
let text = ''
|
||||
let i = 0
|
||||
|
||||
while (i < value.length) {
|
||||
const match = OPERANDS.find((operand) =>
|
||||
value.startsWith(operand.symbol, i)
|
||||
)
|
||||
if (match) {
|
||||
if (text) {
|
||||
tokens.push({ type: 'text', value: text })
|
||||
text = ''
|
||||
}
|
||||
tokens.push({ type: 'operand', value: match.symbol })
|
||||
i += match.symbol.length
|
||||
} else {
|
||||
text += value[i]
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (text) tokens.push({ type: 'text', value: text })
|
||||
return tokens
|
||||
}
|
||||
|
||||
const normalize = (raw = '') =>
|
||||
tokenize(raw)
|
||||
.map((token) => token.value)
|
||||
.join('')
|
||||
|
||||
const nodeLength = (node) => {
|
||||
if (!node) return 0
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent?.length ?? 0
|
||||
const operand = node.getAttribute?.('data-operand')
|
||||
if (operand != null) return operand.length
|
||||
return Array.from(node.childNodes).reduce(
|
||||
(sum, child) => sum + nodeLength(child),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const serializeNode = (root) => {
|
||||
if (!root) return ''
|
||||
let result = ''
|
||||
for (const node of root.childNodes) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
result += node.textContent ?? ''
|
||||
} else if (node.getAttribute?.('data-operand') != null) {
|
||||
result += node.getAttribute('data-operand')
|
||||
} else {
|
||||
result += serializeNode(node)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Character offset in the serialized filter string for a DOM point. */
|
||||
const getOffsetAt = (root, targetNode, targetOffset, chipSnap = 'end') => {
|
||||
if (!root || !targetNode || !root.contains(targetNode)) {
|
||||
return serializeNode(root).length
|
||||
}
|
||||
|
||||
let offset = 0
|
||||
|
||||
const visit = (node) => {
|
||||
if (node === targetNode) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
offset += targetOffset
|
||||
} else if (node.getAttribute?.('data-operand') != null) {
|
||||
offset += targetOffset <= 0 ? 0 : node.getAttribute('data-operand').length
|
||||
} else {
|
||||
for (let i = 0; i < targetOffset; i += 1) {
|
||||
offset += nodeLength(node.childNodes[i])
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
offset += node.textContent?.length ?? 0
|
||||
return false
|
||||
}
|
||||
|
||||
if (node.getAttribute?.('data-operand') != null) {
|
||||
if (node.contains(targetNode)) {
|
||||
// Chips are atomic: snap range starts to the chip start, ends to the chip end.
|
||||
if (chipSnap !== 'start') {
|
||||
offset += node.getAttribute('data-operand').length
|
||||
}
|
||||
return true
|
||||
}
|
||||
offset += node.getAttribute('data-operand').length
|
||||
return false
|
||||
}
|
||||
|
||||
for (const child of node.childNodes) {
|
||||
if (visit(child)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
visit(root)
|
||||
return offset
|
||||
}
|
||||
|
||||
/** Character offset of the selection focus (caret). */
|
||||
const getCaretOffset = (root) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.rangeCount || !root.contains(selection.focusNode)) {
|
||||
return serializeNode(root).length
|
||||
}
|
||||
return getOffsetAt(root, selection.focusNode, selection.focusOffset, 'end')
|
||||
}
|
||||
|
||||
/** Serialized filter text for the current selection (operand symbols, not labels). */
|
||||
const getSelectedSerializedText = (root) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.rangeCount || selection.isCollapsed) return ''
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
if (
|
||||
!root.contains(range.startContainer) ||
|
||||
!root.contains(range.endContainer)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const start = getOffsetAt(
|
||||
root,
|
||||
range.startContainer,
|
||||
range.startOffset,
|
||||
'start'
|
||||
)
|
||||
const end = getOffsetAt(root, range.endContainer, range.endOffset, 'end')
|
||||
return serializeNode(root).slice(Math.min(start, end), Math.max(start, end))
|
||||
}
|
||||
|
||||
/** Keep the caret / selection focus visible inside a horizontally clipped editor. */
|
||||
const scrollPointIntoView = (root, clientX) => {
|
||||
if (!root || clientX == null || Number.isNaN(clientX)) return
|
||||
const rect = root.getBoundingClientRect()
|
||||
const pad = 8
|
||||
if (clientX > rect.right - pad) {
|
||||
root.scrollLeft += clientX - (rect.right - pad)
|
||||
} else if (clientX < rect.left + pad) {
|
||||
root.scrollLeft -= rect.left + pad - clientX
|
||||
}
|
||||
}
|
||||
|
||||
const scrollSelectionIntoView = (root) => {
|
||||
if (!root) return
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.rangeCount || !root.contains(selection.focusNode)) return
|
||||
|
||||
try {
|
||||
const range = document.createRange()
|
||||
range.setStart(selection.focusNode, selection.focusOffset)
|
||||
range.collapse(true)
|
||||
const rects = range.getClientRects()
|
||||
const rect = rects[rects.length - 1] ?? range.getBoundingClientRect()
|
||||
if (rect && (rect.width > 0 || rect.height > 0)) {
|
||||
scrollPointIntoView(root, rect.left)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to caret-at-end heuristic.
|
||||
}
|
||||
|
||||
// Empty / collapsed-at-end selections often have a zero rect — scroll to end.
|
||||
if (getCaretOffset(root) >= serializeNode(root).length) {
|
||||
root.scrollLeft = root.scrollWidth
|
||||
}
|
||||
}
|
||||
|
||||
const caretRangeFromPoint = (clientX, clientY) => {
|
||||
if (document.caretRangeFromPoint) {
|
||||
return document.caretRangeFromPoint(clientX, clientY)
|
||||
}
|
||||
if (document.caretPositionFromPoint) {
|
||||
const pos = document.caretPositionFromPoint(clientX, clientY)
|
||||
if (!pos) return null
|
||||
const range = document.createRange()
|
||||
range.setStart(pos.offsetNode, pos.offset)
|
||||
range.collapse(true)
|
||||
return range
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Extend the live selection to the DOM point under the pointer (used while edge-scrolling). */
|
||||
const extendSelectionToPoint = (root, clientX, clientY) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.rangeCount || !root) return
|
||||
const caret = caretRangeFromPoint(clientX, clientY)
|
||||
if (!caret || !root.contains(caret.startContainer)) return
|
||||
try {
|
||||
selection.setBaseAndExtent(
|
||||
selection.anchorNode,
|
||||
selection.anchorOffset,
|
||||
caret.startContainer,
|
||||
caret.startOffset
|
||||
)
|
||||
} catch {
|
||||
// Ignore invalid selection boundaries (e.g. inside non-editable chips).
|
||||
}
|
||||
}
|
||||
|
||||
const setCaretOffset = (root, targetOffset) => {
|
||||
if (!root) return
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
let remaining = Math.max(0, targetOffset)
|
||||
|
||||
const place = (node, offset) => {
|
||||
const range = document.createRange()
|
||||
range.setStart(node, offset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
scrollSelectionIntoView(root)
|
||||
}
|
||||
|
||||
for (let i = 0; i < root.childNodes.length; i += 1) {
|
||||
const child = root.childNodes[i]
|
||||
const length = nodeLength(child)
|
||||
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
if (remaining <= length) {
|
||||
place(child, remaining)
|
||||
return
|
||||
}
|
||||
remaining -= length
|
||||
continue
|
||||
}
|
||||
|
||||
if (child.getAttribute?.('data-operand') != null) {
|
||||
if (remaining === 0) {
|
||||
place(root, i)
|
||||
return
|
||||
}
|
||||
if (remaining <= length) {
|
||||
place(root, i + 1)
|
||||
return
|
||||
}
|
||||
remaining -= length
|
||||
}
|
||||
}
|
||||
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(root)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
scrollSelectionIntoView(root)
|
||||
}
|
||||
|
||||
/** True when the DOM already mirrors tokenize(serialized). */
|
||||
const domMatchesTokens = (root, raw) => {
|
||||
const tokens = tokenize(raw)
|
||||
const children = Array.from(root.childNodes).filter((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? '') !== ''
|
||||
return node.getAttribute?.('data-operand') != null
|
||||
})
|
||||
|
||||
// Empty editor may keep a single empty text node.
|
||||
if (tokens.length === 0) {
|
||||
return (
|
||||
root.childNodes.length === 0 ||
|
||||
(root.childNodes.length === 1 &&
|
||||
root.childNodes[0].nodeType === Node.TEXT_NODE &&
|
||||
(root.childNodes[0].textContent ?? '') === '')
|
||||
)
|
||||
}
|
||||
|
||||
if (children.length !== tokens.length) return false
|
||||
|
||||
return tokens.every((token, index) => {
|
||||
const child = children[index]
|
||||
if (token.type === 'text') {
|
||||
return (
|
||||
child.nodeType === Node.TEXT_NODE && child.textContent === token.value
|
||||
)
|
||||
}
|
||||
return child.getAttribute?.('data-operand') === token.value
|
||||
})
|
||||
}
|
||||
|
||||
/** Operand chips are only "live" when their portal React root is still mounted. */
|
||||
const chipsHaveLiveRoots = (root, roots) => {
|
||||
for (const node of root.childNodes) {
|
||||
if (node.getAttribute?.('data-operand') == null) continue
|
||||
if (!roots.has(node)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const FilterInput = ({
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder = 'Filter…',
|
||||
disabled = false,
|
||||
allowClear = false,
|
||||
className = '',
|
||||
style,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onPressEnter,
|
||||
size
|
||||
}) => {
|
||||
const { getPrefixCls, direction } = useContext(ConfigProvider.ConfigContext)
|
||||
const prefixCls = getPrefixCls('input')
|
||||
const { token } = theme.useToken()
|
||||
const { themeConfig } = useThemeContext()
|
||||
const themeConfigRef = useRef(themeConfig)
|
||||
themeConfigRef.current = themeConfig
|
||||
|
||||
const rootCls = useCSSVarCls(prefixCls)
|
||||
const [wrapSharedCSSVar, hashId, cssVarCls] = useSharedStyle(prefixCls)
|
||||
const [wrapCSSVar] = useStyle(prefixCls, rootCls)
|
||||
|
||||
const { compactSize, compactItemClassnames } = useCompactItemContext(
|
||||
prefixCls,
|
||||
direction
|
||||
)
|
||||
const mergedSize = useSize((ctx) => size ?? compactSize ?? ctx)
|
||||
|
||||
const editorRef = useRef(null)
|
||||
const tagRootsRef = useRef(new Map())
|
||||
const focusedRef = useRef(false)
|
||||
const composingRef = useRef(false)
|
||||
const selectingRef = useRef(false)
|
||||
const valueRef = useRef(value ?? '')
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [internalValue, setInternalValue] = useState(value ?? '')
|
||||
|
||||
const clearTagRoots = useCallback(() => {
|
||||
tagRootsRef.current.forEach((root) => {
|
||||
queueMicrotask(() => root.unmount())
|
||||
})
|
||||
tagRootsRef.current.clear()
|
||||
}, [])
|
||||
|
||||
const unmountChip = useCallback((wrapper) => {
|
||||
const root = tagRootsRef.current.get(wrapper)
|
||||
if (root) {
|
||||
queueMicrotask(() => root.unmount())
|
||||
tagRootsRef.current.delete(wrapper)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const renderTagNode = useCallback((symbol, themeValue) => {
|
||||
const operand = OPERAND_BY_SYMBOL[symbol]
|
||||
return (
|
||||
<ConfigProvider theme={themeValue}>
|
||||
<Tag
|
||||
color={operand?.color ?? 'default'}
|
||||
bordered={false}
|
||||
style={{
|
||||
marginInlineEnd: 0,
|
||||
marginRight: 0,
|
||||
height: '18px',
|
||||
cursor: 'default',
|
||||
padding: '0 4px',
|
||||
marginTop: '-2px',
|
||||
lineHeight: 1.37,
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none',
|
||||
flexShrink: 0,
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
{operand?.label ?? symbol}
|
||||
</Tag>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}, [])
|
||||
|
||||
const renderOperandChip = useCallback(
|
||||
(symbol) => {
|
||||
const wrapper = document.createElement('span')
|
||||
wrapper.setAttribute('data-operand', symbol)
|
||||
wrapper.setAttribute('contenteditable', 'false')
|
||||
wrapper.style.display = 'inline-flex'
|
||||
wrapper.style.alignItems = 'center'
|
||||
wrapper.style.verticalAlign = 'middle'
|
||||
wrapper.style.margin = '0 2px'
|
||||
wrapper.style.userSelect = 'none'
|
||||
wrapper.style.flexShrink = '0'
|
||||
|
||||
const root = createRoot(wrapper)
|
||||
root.render(renderTagNode(symbol, themeConfigRef.current))
|
||||
tagRootsRef.current.set(wrapper, root)
|
||||
return wrapper
|
||||
},
|
||||
[renderTagNode]
|
||||
)
|
||||
|
||||
// Portal Tags sit outside the React tree — re-render them when theme changes.
|
||||
useEffect(() => {
|
||||
tagRootsRef.current.forEach((root, wrapper) => {
|
||||
root.render(
|
||||
renderTagNode(wrapper.getAttribute('data-operand'), themeConfig)
|
||||
)
|
||||
})
|
||||
}, [themeConfig, renderTagNode])
|
||||
|
||||
const emitChange = useCallback(
|
||||
(next) => {
|
||||
valueRef.current = next
|
||||
setInternalValue(next)
|
||||
onChange?.(next)
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const paint = useCallback(
|
||||
(raw, caret = null) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
const normalized = normalize(raw)
|
||||
|
||||
// Already in sync with live chip roots — skip to avoid a tag flash.
|
||||
if (
|
||||
domMatchesTokens(editor, normalized) &&
|
||||
chipsHaveLiveRoots(editor, tagRootsRef.current)
|
||||
) {
|
||||
if (caret != null && focusedRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
setCaretOffset(editor, caret)
|
||||
scrollSelectionIntoView(editor)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const scrollLeft = editor.scrollLeft
|
||||
const tokens = tokenize(normalized)
|
||||
|
||||
// Reuse operand chips that still have a mounted React root.
|
||||
const chipPool = new Map()
|
||||
for (const node of Array.from(editor.childNodes)) {
|
||||
const symbol = node.getAttribute?.('data-operand')
|
||||
if (symbol == null) continue
|
||||
if (!tagRootsRef.current.has(node)) continue
|
||||
const list = chipPool.get(symbol)
|
||||
if (list) list.push(node)
|
||||
else chipPool.set(symbol, [node])
|
||||
}
|
||||
|
||||
const takeChip = (symbol) => {
|
||||
const list = chipPool.get(symbol)
|
||||
while (list?.length) {
|
||||
const wrapper = list.shift()
|
||||
if (tagRootsRef.current.has(wrapper)) return wrapper
|
||||
}
|
||||
return renderOperandChip(symbol)
|
||||
}
|
||||
|
||||
const nextChildren = []
|
||||
if (tokens.length === 0) {
|
||||
nextChildren.push(document.createTextNode(''))
|
||||
} else {
|
||||
for (const item of tokens) {
|
||||
if (item.type === 'text') {
|
||||
nextChildren.push(document.createTextNode(item.value))
|
||||
} else {
|
||||
nextChildren.push(takeChip(item.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chipPool.forEach((list) => {
|
||||
list.forEach((wrapper) => unmountChip(wrapper))
|
||||
})
|
||||
|
||||
editor.replaceChildren(...nextChildren)
|
||||
editor.scrollLeft = scrollLeft
|
||||
|
||||
if (caret != null && focusedRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
setCaretOffset(editor, caret)
|
||||
scrollSelectionIntoView(editor)
|
||||
})
|
||||
}
|
||||
},
|
||||
[renderOperandChip, unmountChip]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const next = value ?? ''
|
||||
valueRef.current = next
|
||||
setInternalValue(next)
|
||||
if (!focusedRef.current) {
|
||||
paint(next)
|
||||
}
|
||||
}, [value, paint])
|
||||
|
||||
useEffect(() => () => clearTagRoots(), [clearTagRoots])
|
||||
|
||||
const syncFromDom = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
const caret = getCaretOffset(editor)
|
||||
const serialized = serializeNode(editor)
|
||||
const normalized = normalize(serialized)
|
||||
|
||||
if (!domMatchesTokens(editor, normalized)) {
|
||||
paint(normalized, caret)
|
||||
} else {
|
||||
scrollSelectionIntoView(editor)
|
||||
}
|
||||
|
||||
if (normalized !== valueRef.current) {
|
||||
emitChange(normalized)
|
||||
}
|
||||
}, [emitChange, paint])
|
||||
|
||||
const findOperandWrapper = (node) => {
|
||||
const editor = editorRef.current
|
||||
let current = node
|
||||
while (current && current !== editor) {
|
||||
if (current.getAttribute?.('data-operand') != null) return current
|
||||
current = current.parentNode
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleInput = () => {
|
||||
if (composingRef.current || disabled) return
|
||||
syncFromDom()
|
||||
}
|
||||
|
||||
const removeChip = (chip) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor || !chip) return
|
||||
const parent = chip.parentNode
|
||||
const index = Array.from(parent.childNodes).indexOf(chip)
|
||||
let offset = 0
|
||||
for (let i = 0; i < index; i += 1) {
|
||||
offset += nodeLength(parent.childNodes[i])
|
||||
}
|
||||
const root = tagRootsRef.current.get(chip)
|
||||
if (root) {
|
||||
queueMicrotask(() => root.unmount())
|
||||
tagRootsRef.current.delete(chip)
|
||||
}
|
||||
parent.removeChild(chip)
|
||||
setCaretOffset(editor, offset)
|
||||
syncFromDom()
|
||||
}
|
||||
|
||||
const chipBeforeCaret = () => {
|
||||
const editor = editorRef.current
|
||||
const selection = window.getSelection()
|
||||
if (!editor || !selection?.rangeCount || !selection.isCollapsed) return null
|
||||
const { startContainer, startOffset } = selection.getRangeAt(0)
|
||||
|
||||
const insideOperand = findOperandWrapper(startContainer)
|
||||
if (insideOperand) return insideOperand
|
||||
|
||||
if (startContainer === editor && startOffset > 0) {
|
||||
const prev = editor.childNodes[startOffset - 1]
|
||||
if (prev?.getAttribute?.('data-operand') != null) return prev
|
||||
}
|
||||
if (startContainer.nodeType === Node.TEXT_NODE && startOffset === 0) {
|
||||
const prev = startContainer.previousSibling
|
||||
if (prev?.getAttribute?.('data-operand') != null) return prev
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const chipAfterCaret = () => {
|
||||
const editor = editorRef.current
|
||||
const selection = window.getSelection()
|
||||
if (!editor || !selection?.rangeCount || !selection.isCollapsed) return null
|
||||
const { startContainer, startOffset } = selection.getRangeAt(0)
|
||||
|
||||
const insideOperand = findOperandWrapper(startContainer)
|
||||
if (insideOperand) return insideOperand
|
||||
|
||||
if (startContainer === editor) {
|
||||
const next = editor.childNodes[startOffset]
|
||||
if (next?.getAttribute?.('data-operand') != null) return next
|
||||
}
|
||||
if (
|
||||
startContainer.nodeType === Node.TEXT_NODE &&
|
||||
startOffset === (startContainer.textContent?.length ?? 0)
|
||||
) {
|
||||
const next = startContainer.nextSibling
|
||||
if (next?.getAttribute?.('data-operand') != null) return next
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (disabled) return
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onPressEnter?.(internalValue)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace') {
|
||||
const chip = chipBeforeCaret()
|
||||
if (chip) {
|
||||
event.preventDefault()
|
||||
removeChip(chip)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Delete') {
|
||||
const chip = chipAfterCaret()
|
||||
if (chip) {
|
||||
event.preventDefault()
|
||||
removeChip(chip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (event) => {
|
||||
if (disabled) return
|
||||
event.preventDefault()
|
||||
const text = (event.clipboardData?.getData('text/plain') ?? '').replace(
|
||||
/[\r\n]+/g,
|
||||
''
|
||||
)
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.rangeCount) return
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const start = getOffsetAt(
|
||||
editor,
|
||||
range.startContainer,
|
||||
range.startOffset,
|
||||
'start'
|
||||
)
|
||||
const end = getOffsetAt(editor, range.endContainer, range.endOffset, 'end')
|
||||
const from = Math.min(start, end)
|
||||
const to = Math.max(start, end)
|
||||
const current = serializeNode(editor)
|
||||
const next = `${current.slice(0, from)}${text}${current.slice(to)}`
|
||||
const normalized = normalize(next)
|
||||
paint(normalized, from + text.length)
|
||||
emitChange(normalized)
|
||||
}
|
||||
|
||||
const handleCopy = (event) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.rangeCount || selection.isCollapsed) return
|
||||
const text = getSelectedSerializedText(editor)
|
||||
if (text == null) return
|
||||
event.preventDefault()
|
||||
event.clipboardData?.setData('text/plain', text)
|
||||
}
|
||||
|
||||
const handleCut = (event) => {
|
||||
if (disabled) return
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.rangeCount || selection.isCollapsed) return
|
||||
|
||||
const text = getSelectedSerializedText(editor)
|
||||
if (text == null) return
|
||||
|
||||
event.preventDefault()
|
||||
event.clipboardData?.setData('text/plain', text)
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const start = getOffsetAt(
|
||||
editor,
|
||||
range.startContainer,
|
||||
range.startOffset,
|
||||
'start'
|
||||
)
|
||||
const end = getOffsetAt(editor, range.endContainer, range.endOffset, 'end')
|
||||
const from = Math.min(start, end)
|
||||
const to = Math.max(start, end)
|
||||
const current = serializeNode(editor)
|
||||
const next = `${current.slice(0, from)}${current.slice(to)}`
|
||||
const normalized = normalize(next)
|
||||
paint(normalized, from)
|
||||
emitChange(normalized)
|
||||
}
|
||||
|
||||
const handleClear = (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (disabled) return
|
||||
paint('')
|
||||
emitChange('')
|
||||
editorRef.current?.focus()
|
||||
}
|
||||
|
||||
// Auto-scroll while click-dragging a selection past the visible edges.
|
||||
useEffect(() => {
|
||||
let raf = 0
|
||||
let lastClientX = null
|
||||
|
||||
const stopRaf = () => {
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
raf = 0
|
||||
if (!selectingRef.current || !focusedRef.current || lastClientX == null) {
|
||||
return
|
||||
}
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
const rect = editor.getBoundingClientRect()
|
||||
const edge = 28
|
||||
let dx = 0
|
||||
if (lastClientX >= rect.right - edge) {
|
||||
dx =
|
||||
Math.min(28, Math.ceil((lastClientX - (rect.right - edge)) / 2) + 6)
|
||||
} else if (lastClientX <= rect.left + edge) {
|
||||
dx = -Math.min(
|
||||
28,
|
||||
Math.ceil((rect.left + edge - lastClientX) / 2) + 6
|
||||
)
|
||||
}
|
||||
|
||||
if (dx !== 0) {
|
||||
const maxScroll = editor.scrollWidth - editor.clientWidth
|
||||
const next = Math.max(0, Math.min(maxScroll, editor.scrollLeft + dx))
|
||||
if (next !== editor.scrollLeft) {
|
||||
editor.scrollLeft = next
|
||||
extendSelectionToPoint(
|
||||
editor,
|
||||
lastClientX,
|
||||
rect.top + rect.height / 2
|
||||
)
|
||||
scrollSelectionIntoView(editor)
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseMove = (event) => {
|
||||
if (!selectingRef.current || !focusedRef.current) return
|
||||
lastClientX = event.clientX
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
scrollPointIntoView(editor, event.clientX)
|
||||
if (!raf) raf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const onMouseUp = () => {
|
||||
selectingRef.current = false
|
||||
lastClientX = null
|
||||
stopRaf()
|
||||
}
|
||||
|
||||
const onSelectionChange = () => {
|
||||
if (!focusedRef.current) return
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.rangeCount || !editor.contains(selection.focusNode)) return
|
||||
scrollSelectionIntoView(editor)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('selectionchange', onSelectionChange)
|
||||
return () => {
|
||||
stopRaf()
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('selectionchange', onSelectionChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showPlaceholder = !internalValue && !focused
|
||||
const showClear = allowClear && !!internalValue && !disabled
|
||||
const affixCls = `${prefixCls}-affix-wrapper`
|
||||
const placeholderPaddingInline =
|
||||
mergedSize === 'large'
|
||||
? (token.paddingInlineLG ?? token.paddingSM + 4)
|
||||
: mergedSize === 'small'
|
||||
? (token.paddingInlineSM ?? token.paddingXS)
|
||||
: (token.paddingInline ?? token.paddingSM)
|
||||
|
||||
return wrapSharedCSSVar(
|
||||
wrapCSSVar(
|
||||
<span
|
||||
className={classNames(
|
||||
affixCls,
|
||||
{
|
||||
[`${affixCls}-focused`]: focused,
|
||||
[`${affixCls}-disabled`]: disabled,
|
||||
[`${affixCls}-sm`]: mergedSize === 'small',
|
||||
[`${affixCls}-lg`]: mergedSize === 'large',
|
||||
[`${affixCls}-rtl`]: direction === 'rtl',
|
||||
[`${prefixCls}-outlined`]: true
|
||||
},
|
||||
compactItemClassnames,
|
||||
cssVarCls,
|
||||
rootCls,
|
||||
hashId,
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
width: '100%',
|
||||
cursor: disabled ? 'not-allowed' : 'text',
|
||||
...style,
|
||||
// Affix ::before strut needs inline-flex; display:block stacks it and inflates height.
|
||||
display: 'inline-flex'
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!disabled) editorRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
.filter-input-editor::-webkit-scrollbar {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
`}</style>
|
||||
{showPlaceholder && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: placeholderPaddingInline,
|
||||
insetInlineEnd: showClear
|
||||
? placeholderPaddingInline + 22
|
||||
: placeholderPaddingInline,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: token.colorTextPlaceholder,
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
ref={editorRef}
|
||||
className={classNames(prefixCls, 'filter-input-editor')}
|
||||
role='textbox'
|
||||
aria-multiline='false'
|
||||
aria-disabled={disabled || undefined}
|
||||
contentEditable={!disabled}
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onCopy={handleCopy}
|
||||
onCut={handleCut}
|
||||
onMouseDown={() => {
|
||||
selectingRef.current = true
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false
|
||||
syncFromDom()
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
focusedRef.current = true
|
||||
setFocused(true)
|
||||
onFocus?.(event)
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
focusedRef.current = false
|
||||
selectingRef.current = false
|
||||
setFocused(false)
|
||||
const next = normalize(serializeNode(editorRef.current))
|
||||
paint(next)
|
||||
if (next !== valueRef.current) emitChange(next)
|
||||
onBlur?.(event)
|
||||
}}
|
||||
style={{
|
||||
// Match antd's `> input.ant-input` resets inside affix-wrapper
|
||||
display: 'block',
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
borderRadius: 0,
|
||||
outline: 'none',
|
||||
boxShadow: 'none',
|
||||
background: 'transparent',
|
||||
flex: 'auto',
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
color: 'inherit',
|
||||
whiteSpace: 'nowrap',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
wordBreak: 'keep-all',
|
||||
overflowWrap: 'normal',
|
||||
caretColor: token.colorText,
|
||||
cursor: disabled ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
{showClear && (
|
||||
<span className={`${prefixCls}-suffix`}>
|
||||
<span
|
||||
className={classNames(
|
||||
`${prefixCls}-clear-icon`,
|
||||
`${prefixCls}-clear-icon-has-value`
|
||||
)}
|
||||
role='button'
|
||||
tabIndex={-1}
|
||||
onMouseDown={handleClear}
|
||||
>
|
||||
<CloseCircleFilled />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
FilterInput.propTypes = {
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
placeholder: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
allowClear: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
onFocus: PropTypes.func,
|
||||
onBlur: PropTypes.func,
|
||||
onPressEnter: PropTypes.func,
|
||||
size: PropTypes.oneOf(['small', 'middle', 'large'])
|
||||
}
|
||||
|
||||
export default FilterInput
|
||||
export { OPERANDS, tokenize }
|
||||
@ -1,5 +1,6 @@
|
||||
import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Card, Select, Input, Button, Flex, Space, Dropdown } from 'antd'
|
||||
import { Card, Select, Button, Flex, Space, Dropdown } from 'antd'
|
||||
import FilterInput from './FilterInput'
|
||||
import { CloseOutlined } from '@ant-design/icons'
|
||||
import PropTypes from 'prop-types'
|
||||
import PlusIcon from '../../Icons/PlusIcon'
|
||||
@ -161,10 +162,10 @@ const FilterSidebar = ({
|
||||
style={{ minWidth: 80 }}
|
||||
allowClear={false}
|
||||
/>
|
||||
<Input
|
||||
<FilterInput
|
||||
placeholder='Value'
|
||||
value={row.value}
|
||||
onChange={(e) => changeValue(row.field, e.target.value)}
|
||||
onChange={(value) => changeValue(row.field, value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@ -18,7 +18,6 @@ import {
|
||||
Flex,
|
||||
Spin,
|
||||
Button,
|
||||
Input,
|
||||
Space,
|
||||
Tooltip,
|
||||
Form,
|
||||
@ -51,6 +50,7 @@ import ActionsIcon from '../../Icons/ActionsIcon'
|
||||
import FilterIcon from '../../Icons/FilterIcon'
|
||||
import ScrollBox from './ScrollBox'
|
||||
import SimplePropertyFilter from './SimplePropertyFilter'
|
||||
import FilterInput from './FilterInput'
|
||||
import {
|
||||
getActiveFilterValues,
|
||||
useTableStatePersistence
|
||||
@ -117,8 +117,7 @@ const ColumnFilterDropdown = ({
|
||||
setExpression(toFilterExpression(next) ?? '')
|
||||
}
|
||||
|
||||
const handleExpressionChange = (e) => {
|
||||
const text = e.target.value
|
||||
const handleExpressionChange = (text) => {
|
||||
setExpression(text)
|
||||
setDraft(fromFilterExpression(text) || [])
|
||||
}
|
||||
@ -146,12 +145,12 @@ const ColumnFilterDropdown = ({
|
||||
<div style={{ padding: 8 }}>
|
||||
<Flex vertical gap='small'>
|
||||
<Space.Compact>
|
||||
<Input
|
||||
<FilterInput
|
||||
placeholder={'Filter ' + propertyLabel}
|
||||
value={expression}
|
||||
onChange={handleExpressionChange}
|
||||
onPressEnter={applyFilter}
|
||||
style={{ width: 200, display: 'block' }}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Button onClick={resetFilter} icon={<XMarkIcon />} />
|
||||
<Button type='primary' onClick={applyFilter} icon={<CheckIcon />} />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user