From ff2d99ae707d6c927ceb8d3d5e9be3967f0c0ec5 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 8 Jun 2026 13:33:56 +0800 Subject: [PATCH] refactor(intake): extract non-integrated shared controls --- .../NonIntegratedDetailClient.tsx | 903 +----------------- .../components/FormControls.tsx | 157 +++ .../components/InlineControls.tsx | 678 +++++++++++++ .../components/WorkspaceShell.tsx | 89 ++ 4 files changed, 937 insertions(+), 890 deletions(-) create mode 100644 furniture-3d-system/src/app/intake/non-integrated/components/FormControls.tsx create mode 100644 furniture-3d-system/src/app/intake/non-integrated/components/InlineControls.tsx create mode 100644 furniture-3d-system/src/app/intake/non-integrated/components/WorkspaceShell.tsx diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx index dc6518e9..f2e43af0 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -17,7 +17,6 @@ import { Fragment, useCallback, useEffect, - useId, useLayoutEffect, useMemo, useRef, @@ -29,6 +28,19 @@ import CameraScannerModal from "@/components/mobile/CameraScannerModal"; import { ModelViewer } from "@/components/3d/ModelViewer"; import { ModelPreviewPanel } from "@/components/platform/ModelPreviewPanel"; import Button from "@/components/ui/Button"; +import { + InlineSelect, + InlineSuggestionInput, +} from "./components/InlineControls"; +import { + BasicField, + CreateFieldShell, + FieldLabel, +} from "./components/FormControls"; +import { + SectionCard, + WorkspacePreviewEmptyState, +} from "./components/WorkspaceShell"; import { calculateMenuPosition, ContextMenu, @@ -8573,807 +8585,6 @@ function createInitialDocumentWorkspaceApprovedAt( return approvedAt; } -function FieldLabel({ - label, - required, -}: { - label: string; - required?: boolean; -}) { - return ( - - ); -} - -function CreateFieldShell({ children }: { children: ReactNode }) { - return ( -
- {children} -
- ); -} - -type FloatingPanelPosition = { - left: number; - top: number; - width: number; - maxWidth: number; - maxHeight: number; -}; - -function useFloatingPanelPosition( - isOpen: boolean, - anchorRef: RefObject, - gap = 4, -) { - const [position, setPosition] = useState(null); - - const updatePosition = useCallback(() => { - const anchor = anchorRef.current; - if (!anchor || typeof window === "undefined") return; - - const rect = anchor.getBoundingClientRect(); - const viewportPadding = 12; - const left = Math.min( - Math.max(viewportPadding, rect.left), - Math.max(viewportPadding, window.innerWidth - rect.width - viewportPadding), - ); - const availableBelow = window.innerHeight - rect.bottom - gap - viewportPadding; - - setPosition({ - left, - top: rect.bottom + gap, - width: rect.width, - maxWidth: Math.max(rect.width, window.innerWidth - left - viewportPadding), - maxHeight: Math.max(64, availableBelow), - }); - }, [anchorRef, gap]); - - useLayoutEffect(() => { - if (!isOpen) { - setPosition(null); - return; - } - - updatePosition(); - window.addEventListener("resize", updatePosition); - window.addEventListener("scroll", updatePosition, true); - - return () => { - window.removeEventListener("resize", updatePosition); - window.removeEventListener("scroll", updatePosition, true); - }; - }, [isOpen, updatePosition]); - - return position; -} - -function InlineSuggestionInput({ - ariaLabel, - dataTestId, - value, - options, - placeholder, - readOnly = false, - wrapperClassName = "relative", - inputClassName, - panelClassName = "absolute left-0 right-0 top-[calc(100%+4px)] z-50 max-h-48 overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md", - optionClassName = "flex w-full items-center rounded-apple-md px-2.5 py-1.5 text-left text-xs transition", - showChevron = true, - filterOptions = false, - closeOnChange, - onPaste, - onChange, - onSelect, - onCommit, -}: { - ariaLabel: string; - dataTestId?: string; - value: string; - options?: readonly string[]; - placeholder?: string; - readOnly?: boolean; - wrapperClassName?: string; - inputClassName?: string; - panelClassName?: string; - optionClassName?: string; - showChevron?: boolean; - filterOptions?: boolean; - closeOnChange?: (value: string) => boolean; - onPaste?: (event: ClipboardEvent) => void; - onChange: (value: string) => void; - onSelect?: (value: string) => void; - onCommit?: () => void; -}) { - const suggestionListId = useId(); - const wrapperRef = useRef(null); - const panelRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); - const [hasTypedQuery, setHasTypedQuery] = useState(false); - const previousValueRef = useRef(value); - const normalizedOptions = getUniqueNonEmptyStrings(options ?? []); - const keyword = value.trim().toLowerCase(); - const visibleOptions = normalizedOptions.filter((option) => - filterOptions && hasTypedQuery && keyword - ? option.toLowerCase().includes(keyword) - : true, - ); - const hasOptions = !readOnly && normalizedOptions.length > 0; - const floatingPosition = useFloatingPanelPosition(isOpen && hasOptions, wrapperRef); - - useEffect(() => { - if (previousValueRef.current && !value) { - setIsOpen(false); - } - previousValueRef.current = value; - }, [value]); - - useEffect(() => { - if (!isOpen) return; - - function handleDocumentMouseDown(event: MouseEvent) { - const target = event.target as Node; - if ( - wrapperRef.current?.contains(target) || - panelRef.current?.contains(target) - ) { - return; - } - setIsOpen(false); - } - - document.addEventListener("mousedown", handleDocumentMouseDown); - return () => - document.removeEventListener("mousedown", handleDocumentMouseDown); - }, [isOpen]); - - const closeLater = useCallback(() => { - setIsOpen(false); - }, []); - - const handleSelect = useCallback( - (option: string) => { - onChange(option); - onSelect?.(option); - setIsOpen(false); - }, - [onChange, onSelect], - ); - - return ( -
- { - const nextValue = event.target.value; - setHasTypedQuery(true); - onChange(nextValue); - if (hasOptions) { - setIsOpen(closeOnChange?.(nextValue) ? false : true); - } - }} - onPaste={onPaste} - onFocus={() => { - setHasTypedQuery(false); - if (hasOptions) setIsOpen(true); - }} - onBlur={() => { - setHasTypedQuery(false); - closeLater(); - onCommit?.(); - }} - onKeyDown={(event) => { - if (event.key === "Escape") { - setHasTypedQuery(false); - setIsOpen(false); - return; - } - if (event.key === "Enter") { - event.preventDefault(); - onCommit?.(); - setHasTypedQuery(false); - setIsOpen(false); - } - }} - placeholder={placeholder} - readOnly={readOnly} - className={inputClassName} - /> - {hasOptions && showChevron ? ( - - ) : null} - {hasOptions && isOpen && floatingPosition - ? createPortal( -
- {visibleOptions.length > 0 ? ( - visibleOptions.map((option) => ( - - )) - ) : ( -
- 没有匹配项 -
- )} -
, - document.body, - ) - : null} -
- ); -} - -type InlineSelectOption = - | string - | { - value: string; - label?: string; - disabled?: boolean; - }; - -function normalizeInlineSelectOptions( - options: readonly InlineSelectOption[] | undefined, -): { value: string; label: string; disabled?: boolean }[] { - return (options ?? []).map((option) => - typeof option === "string" - ? { value: option, label: option } - : { value: option.value, label: option.label ?? option.value, disabled: option.disabled }, - ); -} - -function InlineSelect({ - ariaLabel, - dataTestId, - value, - options, - placeholder, - readOnly = false, - wrapperClassName = "relative", - buttonClassName, - panelClassName = "absolute left-0 right-0 top-[calc(100%+4px)] z-50 max-h-48 overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md", - optionClassName = "flex w-full items-center rounded-apple-md px-2.5 py-1.5 text-left text-xs transition", - includeEmptyOption, - emptyOptionDisabled = false, - showIndicatorWhenReadOnly = false, - onChange, -}: { - ariaLabel: string; - dataTestId?: string; - value: string; - options?: readonly InlineSelectOption[]; - placeholder?: string; - readOnly?: boolean; - wrapperClassName?: string; - buttonClassName?: string; - panelClassName?: string; - optionClassName?: string; - includeEmptyOption?: boolean; - emptyOptionDisabled?: boolean; - showIndicatorWhenReadOnly?: boolean; - onChange: (value: string) => void; -}) { - const listId = useId(); - const containerRef = useRef(null); - const panelRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); - const normalizedOptions = normalizeInlineSelectOptions(options); - const shouldIncludeEmptyOption = includeEmptyOption ?? false; - const selectOptions = shouldIncludeEmptyOption - ? [ - { - value: "", - label: placeholder ?? "", - disabled: emptyOptionDisabled, - }, - ...normalizedOptions, - ] - : normalizedOptions; - const selectedOption = selectOptions.find((option) => option.value === value); - const displayValue = selectedOption?.label ?? (value || placeholder || ""); - const hasOptions = !readOnly && selectOptions.length > 0; - const shouldShowIndicator = - hasOptions || (readOnly && showIndicatorWhenReadOnly && selectOptions.length > 0); - const floatingPosition = useFloatingPanelPosition(isOpen && hasOptions, containerRef); - - useEffect(() => { - if (!isOpen) return; - - function handleDocumentMouseDown(event: MouseEvent) { - const target = event.target as Node; - if ( - containerRef.current?.contains(target) || - panelRef.current?.contains(target) - ) { - return; - } - setIsOpen(false); - } - - document.addEventListener("mousedown", handleDocumentMouseDown); - return () => - document.removeEventListener("mousedown", handleDocumentMouseDown); - }, [isOpen]); - - return ( -
- - {hasOptions && isOpen && floatingPosition - ? createPortal( -
- {selectOptions.map((option) => ( - - ))} -
, - document.body, - ) - : null} -
- ); -} - -function SearchableInlineSelect({ - ariaLabel, - dataTestId, - value, - options, - placeholder, - readOnly = false, - wrapperClassName = "relative", - inputClassName, - panelClassName = "absolute left-0 right-0 top-[calc(100%+4px)] z-50 max-h-48 overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md", - optionClassName = "flex w-full items-center rounded-apple-md px-2.5 py-1.5 text-left text-xs transition", - includeEmptyOption, - emptyOptionDisabled = false, - onChange, -}: { - ariaLabel: string; - dataTestId?: string; - value: string; - options?: readonly InlineSelectOption[]; - placeholder?: string; - readOnly?: boolean; - wrapperClassName?: string; - inputClassName?: string; - panelClassName?: string; - optionClassName?: string; - includeEmptyOption?: boolean; - emptyOptionDisabled?: boolean; - onChange: (value: string) => void; -}) { - const listId = useId(); - const containerRef = useRef(null); - const panelRef = useRef(null); - const inputRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); - const [query, setQuery] = useState(""); - const normalizedOptions = normalizeInlineSelectOptions(options); - const shouldIncludeEmptyOption = includeEmptyOption ?? false; - const selectOptions = shouldIncludeEmptyOption - ? [ - { - value: "", - label: placeholder ?? "", - disabled: emptyOptionDisabled, - }, - ...normalizedOptions, - ] - : normalizedOptions; - const selectedOption = selectOptions.find((option) => option.value === value); - const displayValue = selectedOption?.label ?? value; - const normalizedQuery = query.trim().toLowerCase(); - const normalizedDisplayValue = displayValue.trim().toLowerCase(); - const shouldFilterOptions = - normalizedQuery.length > 0 && normalizedQuery !== normalizedDisplayValue; - const visibleOptions = selectOptions.filter((option) => { - if (!shouldFilterOptions) return true; - return ( - option.label.toLowerCase().includes(normalizedQuery) || - option.value.toLowerCase().includes(normalizedQuery) - ); - }); - const hasOptions = !readOnly && selectOptions.length > 0; - const floatingPosition = useFloatingPanelPosition(isOpen && hasOptions, containerRef); - - useEffect(() => { - if (isOpen) return; - setQuery(displayValue); - }, [displayValue, isOpen]); - - useEffect(() => { - if (!isOpen) return; - - function handleDocumentMouseDown(event: MouseEvent) { - const target = event.target as Node; - if ( - containerRef.current?.contains(target) || - panelRef.current?.contains(target) - ) { - return; - } - setIsOpen(false); - } - - document.addEventListener("mousedown", handleDocumentMouseDown); - return () => - document.removeEventListener("mousedown", handleDocumentMouseDown); - }, [isOpen]); - - const openPanel = useCallback(() => { - if (!hasOptions) return; - setQuery(displayValue); - setIsOpen(true); - window.setTimeout(() => inputRef.current?.select(), 0); - }, [displayValue, hasOptions]); - - const handleSelect = useCallback( - (option: { value: string; label: string; disabled?: boolean }) => { - if (option.disabled) return; - onChange(option.value); - setQuery(option.label); - setIsOpen(false); - }, - [onChange], - ); - - return ( -
- { - if (!hasOptions) return; - setQuery(event.target.value); - setIsOpen(true); - }} - onKeyDown={(event) => { - if (event.key === "Escape") { - setIsOpen(false); - return; - } - if (event.key === "Enter") { - event.preventDefault(); - const firstEnabledOption = visibleOptions.find( - (option) => !option.disabled, - ); - if (firstEnabledOption) { - handleSelect(firstEnabledOption); - } else { - setIsOpen(false); - } - } - }} - onBlur={() => { - setQuery(displayValue); - setIsOpen(false); - }} - className={inputClassName} - /> - {hasOptions ? ( - - ) : null} - {hasOptions && isOpen && floatingPosition - ? createPortal( -
- {visibleOptions.length > 0 ? ( - visibleOptions.map((option) => ( - - )) - ) : ( -
- 没有匹配项 -
- )} -
, - document.body, - ) - : null} -
- ); -} - -function BasicField({ - label, - required, - type = "text", - placeholder, - options, - value, - readOnly = false, - compact = false, - searchableSelect = false, - onChange, -}: { - label: string; - required?: boolean; - type?: BasicFieldType; - placeholder?: string; - options?: readonly string[]; - value: string; - readOnly?: boolean; - compact?: boolean; - searchableSelect?: boolean; - onChange: (value: string) => void; -}) { - if (type === "select") { - return ( -
- - {searchableSelect ? ( - - ) : ( - - )} -
- ); - } - - if (type === "textarea") { - return ( -
- -