Newer
Older
mobile.raikyakun.app / node / src / components / CustomMessageEditor.tsx
nutrition on 24 Jul 2024 6 KB first commit
import { useEffect, useState, useCallback } from 'react'
import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, TextField, IconButton, Box, Grid } from '@mui/material'
import { Dehaze, Save as SaveIcon, Clear as ClearIcon } from '@mui/icons-material'
import { useForm, useFieldArray, UseFormRegister, FieldErrors, SubmitHandler } from 'react-hook-form'
import { DndContext, closestCenter, DragEndEvent, DragMoveEvent, useSensor, useSensors, TouchSensor, MouseSensor, DragStartEvent, useDroppable, UniqueIdentifier } from '@dnd-kit/core'
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'

export interface MessageFormInputs {
	messages: { id: number, value: string }[]
}

export default function CustomMessageEditor() {
	const [isOpen, setIsOpen] = useState(false)
	const [activeId, setActiveId] = useState<string | null>(null)
	const [isFocused, setIsFocused] = useState(false)
	const [idOfOverDeleteArea, setIdOfOverDeleteArea] = useState<string | null>(null)

	const { control, register, handleSubmit, watch, setValue, reset, formState: { isDirty, errors, isValid } } = useForm<MessageFormInputs>({ mode: 'all'})
	const { fields, append, remove, move } = useFieldArray({ control, name: 'messages' })

	const sensors = useSensors(
		useSensor(MouseSensor),
		useSensor(TouchSensor, {
			activationConstraint: {
				delay: 0,
				tolerance: 10,
			},
		})
	)

	const handleDragStart = (event: DragStartEvent) => {
		setActiveId(event.active.id as string)
	}

	const handleDragMove = (event: DragMoveEvent) => {
		const { active, over } = event
		if (over && over.id === 'delete-area') {
			setIdOfOverDeleteArea(active.id as string)
		} else {
			setIdOfOverDeleteArea(null)
		}
	}

	const handleDragEnd = (event: DragEndEvent) => {
		const { active, over } = event

		if (!over) {
			setActiveId(null)
			setIdOfOverDeleteArea(null)
			return
		}

		if (over.id === 'delete-area') {
			const index = fields.findIndex(field => field.id === active.id)
			remove(index)
		} else if (active.id !== over.id) {
			const oldIndex = fields.findIndex(field => field.id === active.id)
			const newIndex = fields.findIndex(field => field.id === over.id)
			move(oldIndex, newIndex)
		}

		setActiveId(null)
		setIdOfOverDeleteArea(null)
	}

	const load = () => {
		const messagesJSON = localStorage.getItem('messages')
		if(messagesJSON) {
			const messages = (JSON.parse(messagesJSON) as string[]).map((value, id) => ({id, value}))
			return {messages} as MessageFormInputs
		}
		return {messages: []} as MessageFormInputs
	}

	useEffect(()=>{
		reset(load())
	}, [])

	const save: SubmitHandler<MessageFormInputs> = useCallback(async(form) => {
		const json = JSON.stringify(form.messages.map(message => message.value))
		localStorage.setItem('messages', json)
		setIsOpen(false)
	}, [])

	const cancel = () => {
		setIsOpen(false)
		setTimeout(()=> reset(load()), 200)
	}

	return (
		<>
			<Button onClick={() => setIsOpen(true)}>テンプレートメッセージ設定</Button>

			<Dialog open={isOpen} onClose={() => setIsOpen(false)}>
				<DialogTitle>
					テンプレートメッセージ設定
				</DialogTitle>
				
				<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragMove={handleDragMove}>
					<DialogContent sx={{pb: 0, overflow: activeId ? 'hidden' : 'auto'}}>
						<DialogContentText>
							タブレットに表示するオプションメッセージ表示をカスタマイズ出来ます。空欄は使用できません。
						</DialogContentText>
						<SortableContext items={fields.map(field => field.id)} strategy={verticalListSortingStrategy}>
							{fields.map((field, index) => (
								<SortableItem key={field.id} id={field.id} index={index} register={register} value={field.value} isFocused={isFocused} setIsFocused={setIsFocused} isOverDeleteArea={idOfOverDeleteArea == field.id} isError={!!errors.messages?.[index]} />
							))}
						</SortableContext>
					</DialogContent>
					<DeleteArea isDragging={!!activeId} onClick={() => append({ id: fields.length, value: '' })} />
				</DndContext>
				
				<DialogActions>
					<Button onClick={cancel} autoFocus>キャンセル</Button>
					<Button onClick={handleSubmit(save)} variant="contained" disabled={!isValid || !isDirty}>保存して終了</Button>
				</DialogActions>
			</Dialog>
		</>
	)
}

interface SortableItemProps {
	id: string
	index: number
	register: UseFormRegister<MessageFormInputs>
	value: string
	isFocused: boolean
	setIsFocused: (focused: boolean) => void
	isOverDeleteArea: boolean
	isError: boolean
}

function SortableItem({ id, index, register, value, isFocused, setIsFocused, isOverDeleteArea, isError }: SortableItemProps) {
	const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })

	const style = {
		transform: CSS.Transform.toString({
			x: 0, // 水平方向の移動を制限
			y: transform?.y ?? 0, // yがundefinedの場合に0を代入
			scaleX: transform?.scaleX ?? 1,
			scaleY: transform?.scaleY ?? 1,
		}),
		transition,
		marginBottom: '8px',
	}

	const { onBlur, ...inputProps } = register(`messages.${index}.value`, { required: true })

	return (
		<div ref={setNodeRef} style={style} {...attributes}>
			<Box display="flex" alignItems="center">
				<TextField
					fullWidth
					sx={{ mt: 1, ml: 1, '& .MuiOutlinedInput-root': {
						'& fieldset': {
							borderColor: isOverDeleteArea || isError ? 'red' : 'grey', // 未フォーカス時のボーダーラインの色変更
						},
					}}}
					InputProps={{style: isOverDeleteArea ? {color: 'red'}:{}}}
					{...inputProps}
					onFocus={() => setIsFocused(true)}
					onBlur={(e) => {
						onBlur(e)
						setIsFocused(false)
					}}
					defaultValue={value}
					error={isError}
				/>
				<IconButton {...(isFocused ? {} : listeners)} sx={{ color: isFocused ? 'lightgray' : 'inherit', touchAction: 'none', mt:1 }}>
					<Dehaze sx={isOverDeleteArea ? {color: 'red'}:{}} />
				</IconButton>
			</Box>
		</div>
	)
}

function DeleteArea({isDragging, onClick}: {isDragging: boolean, onClick: () => void}) {
	const { setNodeRef, isOver } = useDroppable({
		id: 'delete-area'
	})

	return (
		<Box ref={setNodeRef} display="flex" justifyContent="space-between" sx={{mr: 4, ml: 4, mt: 1, mb: 2}} >
			<Button onClick={onClick} variant="outlined">追加</Button>
			<Box sx={{textAlign: 'center', border: `1px ${isOver ? 'solid' : 'dashed'} ${isOver ? 'red' : isDragging ? 'gray' : 'lightgrey'}`, padding: '5px 20px', color: isOver ? 'red' : isDragging ? 'gray' : 'lightgrey', borderRadius: 2}}>
				削除
			</Box>
		</Box>
		
	)
}