import { useState, useEffect, useMemo, useRef } from '@wordpress/element'; import { useControlSettingSet } from '../_hooks'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable'; // 要素内のみで制限するようにする import { restrictToParentElement } from "@dnd-kit/modifiers"; import SortableItem from './SortableItem'; import { __ } from '@wordpress/i18n'; // locked_items DSL の型定義 type LockedItemSpec = | string | { item: string; depends_on: string; value: string | string[] }; interface Choice { value: string; label: string; subControls?: string[]; } type Props = { values: SortItems, title: string, description?: string, onChange: (newValues: string[]) => void, disabledSort?: string[], lockedItems?: LockedItemSpec[], choices?: Choice[], } /** * rawLockedItems から現在値を評価し、ロック対象 ID の Set を返す. * wp.customize が未定義の場合でも optional chaining で安全に処理する. */ function computeLockedSet( specs: LockedItemSpec[] ): Set { const set = new Set(); specs.forEach( ( spec ) => { if ( typeof spec === 'string' ) { set.add( spec ); return; } const setting = ( window as any ).wp?.customize?.( spec.depends_on ); if ( ! setting ) { return; } const current = setting.get(); const matched = Array.isArray( spec.value ) ? spec.value.includes( current ) : current === spec.value; if ( matched ) { set.add( spec.item ); } } ); return set; } export default function ApuraSortable({ values, title, description, onChange, disabledSort, lockedItems, choices }: Props) { const rawLocked = lockedItems ?? []; const [sortItems, setSortItems] = useState(values); const [isInitialized, setIsInitialized] = useState(false); const [lockedSet, setLockedSet] = useState>( () => computeLockedSet( rawLocked ) ); const initTimeoutRef = useRef | null>(null); // センサーの初期化を遅延実行 const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, // 8px移動してからドラッグ開始 }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); // コンポーネントマウント後の遅延初期化 useEffect(() => { // iframe環境での初期化を少し遅延させる initTimeoutRef.current = setTimeout(() => { setIsInitialized(true); }, 100); return () => { if (initTimeoutRef.current) { clearTimeout(initTimeoutRef.current); } }; }, []); // wp.customize の設定変化を購読して lockedSet を動的更新する. // cleanup で必ず unbind し、メモリリークを防ぐ. useEffect( () => { const unsubscribes: Array<() => void> = []; rawLocked.forEach( ( spec ) => { if ( typeof spec === 'string' ) { return; } const setting = ( window as any ).wp?.customize?.( spec.depends_on ); if ( ! setting ) { return; } const handler = () => { setLockedSet( computeLockedSet( rawLocked ) ); }; setting.bind( handler ); unsubscribes.push( () => setting.unbind( handler ) ); } ); return () => { unsubscribes.forEach( ( fn ) => fn() ); }; // rawLocked は PHP params 由来でレンダー間で安定しているため deps から除外する // eslint-disable-next-line react-hooks/exhaustive-deps }, [] ); // valuesが変更された時の処理 useEffect(() => { setSortItems(values); }, [values]); // lockedSet または values が変化したとき表示用 items を正規化する. // ロック上塗り(isActive 強制 true + locked フラグ)は UI 表示に限定し、 // 実際の保存値は sortItems 側のユーザー設定を維持する. const normalizedItems = useMemo( () => { // ベース items: sortItems を上書きコピー const result = sortItems.map( ( item ) => { if ( lockedSet.has( item.id ) ) { return { ...item, isActive: true, locked: true }; } return { ...item, locked: false }; } ); // 保存値配列に存在しないが locked 対象の項目を UI 上だけ末尾に追加 lockedSet.forEach( ( lockedId ) => { const exists = result.find( ( i ) => i.id === lockedId ); if ( exists ) { return; } // choices prop または params.choices から該当エントリを探す const choiceList: Choice[] = choices ?? []; const choice = choiceList.find( ( c ) => c.value === lockedId ); if ( choice ) { result.push( { id: choice.value, label: choice.label, value: choice.value, isActive: true, locked: true, subControls: choice.subControls ?? null, } ); } } ); return result; // lockedSet は設定購読時に新しい Set として更新されるため依存に入れる. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ sortItems, lockedSet ] ); function handleDragEnd(event: DragEndEvent) { const {active, over} = event; if (active.id !== over?.id) { setSortItems((items) => { const oldIndex = items.findIndex( (item) => item.id === active.id ); const newIndex = items.findIndex( (item) => item.id === over.id ); if ( oldIndex < 0 || newIndex < 0 ) { return items; } return arrayMove(items, oldIndex, newIndex); }); } } function handleItemActive(id:string) { // locked 項目はトグル不可(念のため二重防衛) if ( lockedSet.has( id ) ) { return; } const newValues = sortItems.map((_item) =>{ if (_item.id === id) { return { ..._item, isActive: !_item.isActive, }; } else { return _item; } }); setSortItems(newValues); } // sortItems が変化したとき onChange で保存値を更新する. // normalizedItems は Cover 用の強制表示を含むため、保存値には使わない. useEffect(() => { const result = sortItems.flatMap(obj => obj.isActive ? obj.id : []); onChange(result); }, [sortItems, onChange]); return (
{title}
{description &&
{description}
} {isInitialized ? ( {normalizedItems.map(item => { return ( handleItemActive(item.id)} /> ) })} ) : ( // 初期化中の表示(DndContextなし)
{normalizedItems.map(item => ( handleItemActive(item.id)} /> ))}
)}
); }