fix:E-Sign 필드 이동/크기조절 UX 개선

- 기본 상태(점선): 드래그=이동만 활성, 클릭=선택
- 더블클릭: 리사이즈 모드 전환(실선+핸들 표시, 크기조절)
- ESC 또는 바깥 클릭: 리사이즈 모드 해제
- fields.blade.php, template-fields.blade.php 양쪽 동일 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-13 08:15:04 +09:00
parent 5ffabed6b4
commit 85f5aa0682
2 changed files with 170 additions and 111 deletions

View File

@@ -156,9 +156,10 @@ className={`w-full relative rounded border-2 transition-all ${p === currentPage
};
// ─── FieldOverlay (interact.js) ───
// 기본: 점선 + 드래그=이동 / 더블클릭: 실선 + 핸들=크기조절 / ESC: 모드해제
const FieldOverlay = ({ field, index, selected, signers, containerRef, onSelect, onUpdate, onDragEnd, gridEnabled }) => {
const fieldRef = useRef(null);
// Ref로 최신 field/callbacks 추적 (interact.js 재초기화 방지)
const [resizeMode, setResizeMode] = useState(false);
const latestRef = useRef({ field, index, onUpdate, onDragEnd });
latestRef.current = { field, index, onUpdate, onDragEnd };
@@ -167,7 +168,18 @@ className={`w-full relative rounded border-2 transition-all ${p === currentPage
const signerName = signers[signerIdx]?.name || '?';
const typeInfo = FIELD_TYPES.find(t => t.value === field.field_type) || FIELD_TYPES[0];
// interact.js는 gridEnabled 변경 시에만 재초기화
// 선택 해제 시 리사이즈 모드 자동 해제
useEffect(() => { if (!selected) setResizeMode(false); }, [selected]);
// ESC 키로 리사이즈 모드 해제
useEffect(() => {
if (!resizeMode) return;
const h = (e) => { if (e.key === 'Escape') { e.stopPropagation(); setResizeMode(false); } };
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [resizeMode]);
// interact.js: resizeMode에 따라 드래그 또는 리사이즈만 활성화
useEffect(() => {
const el = fieldRef.current;
if (!el) return;
@@ -182,87 +194,103 @@ className={`w-full relative rounded border-2 transition-all ${p === currentPage
}));
}
const interactable = interact(el)
.draggable({
inertia: false,
modifiers,
listeners: {
move(event) {
const { field: f, index: idx, onUpdate: update } = latestRef.current;
const rect = containerRef.current.getBoundingClientRect();
const dx = (event.dx / rect.width) * 100;
const dy = (event.dy / rect.height) * 100;
const newX = Math.max(0, Math.min(100 - f.width, f.position_x + dx));
const newY = Math.max(0, Math.min(100 - f.height, f.position_y + dy));
update(idx, { position_x: round2(newX), position_y: round2(newY) });
const interactable = interact(el);
if (resizeMode) {
interactable
.draggable({ enabled: false })
.resizable({
edges: { left: true, right: true, top: true, bottom: true },
modifiers: [
interact.modifiers.restrictEdges({ outer: 'parent' }),
interact.modifiers.restrictSize({ min: { width: 30, height: 20 } }),
],
listeners: {
move(event) {
const { field: f, index: idx, onUpdate: update } = latestRef.current;
const rect = containerRef.current.getBoundingClientRect();
const newX = f.position_x + (event.deltaRect.left / rect.width) * 100;
const newY = f.position_y + (event.deltaRect.top / rect.height) * 100;
const newW = (event.rect.width / rect.width) * 100;
const newH = (event.rect.height / rect.height) * 100;
update(idx, {
position_x: round2(Math.max(0, newX)),
position_y: round2(Math.max(0, newY)),
width: round2(Math.max(2, newW)),
height: round2(Math.max(1, newH)),
});
},
end() { latestRef.current.onDragEnd(); },
},
end() { latestRef.current.onDragEnd(); },
},
})
.resizable({
edges: { left: true, right: true, top: true, bottom: true },
modifiers: [
interact.modifiers.restrictEdges({ outer: 'parent' }),
interact.modifiers.restrictSize({ min: { width: 30, height: 20 } }),
],
listeners: {
move(event) {
const { field: f, index: idx, onUpdate: update } = latestRef.current;
const rect = containerRef.current.getBoundingClientRect();
const newX = f.position_x + (event.deltaRect.left / rect.width) * 100;
const newY = f.position_y + (event.deltaRect.top / rect.height) * 100;
const newW = (event.rect.width / rect.width) * 100;
const newH = (event.rect.height / rect.height) * 100;
update(idx, {
position_x: round2(Math.max(0, newX)),
position_y: round2(Math.max(0, newY)),
width: round2(Math.max(2, newW)),
height: round2(Math.max(1, newH)),
});
});
} else {
interactable
.resizable({ enabled: false })
.draggable({
inertia: false,
modifiers,
listeners: {
move(event) {
const { field: f, index: idx, onUpdate: update } = latestRef.current;
const rect = containerRef.current.getBoundingClientRect();
const dx = (event.dx / rect.width) * 100;
const dy = (event.dy / rect.height) * 100;
const newX = Math.max(0, Math.min(100 - f.width, f.position_x + dx));
const newY = Math.max(0, Math.min(100 - f.height, f.position_y + dy));
update(idx, { position_x: round2(newX), position_y: round2(newY) });
},
end() { latestRef.current.onDragEnd(); },
},
end() { latestRef.current.onDragEnd(); },
},
});
});
}
return () => interactable.unset();
}, [gridEnabled]);
}, [gridEnabled, resizeMode]);
return (
<div ref={fieldRef}
onClick={(e) => { e.stopPropagation(); onSelect(index); }}
onDoubleClick={(e) => { e.stopPropagation(); onSelect(index); setResizeMode(prev => !prev); }}
style={{
position: 'absolute',
left: `${field.position_x}%`, top: `${field.position_y}%`,
width: `${field.width}%`, height: `${field.height}%`,
border: selected ? `2px solid ${color}` : `2px dashed ${color}`,
backgroundColor: selected ? `${color}30` : `${color}15`,
cursor: 'move', borderRadius: '4px',
border: resizeMode ? `2px solid ${color}` : `2px dashed ${color}`,
backgroundColor: resizeMode ? `${color}30` : (selected ? `${color}20` : `${color}10`),
cursor: resizeMode ? 'default' : 'move',
borderRadius: '4px',
zIndex: selected ? 20 : 10,
touchAction: 'none',
boxShadow: selected ? `0 0 0 1px ${color}, 0 2px 8px rgba(0,0,0,0.15)` : 'none',
boxShadow: resizeMode ? `0 0 0 2px ${color}, 0 2px 12px rgba(0,0,0,0.2)` : (selected ? `0 0 0 1px ${color}, 0 2px 8px rgba(0,0,0,0.15)` : 'none'),
}}
className="flex items-center justify-center select-none group transition-shadow">
<div className="flex items-center gap-0.5 text-[10px] font-medium truncate px-1" style={{ color }}>
<span>{typeInfo.icon}</span>
<span className="truncate">{field.field_variable ? (field.field_value || `{{${field.field_variable}}}`) : (field.field_label || signerName)}</span>
</div>
{/* 리사이즈 핸들 (선택 시) */}
{selected && <>
{/* 리사이즈 모드 안내 툴팁 */}
{resizeMode && (
<div style={{ position: 'absolute', top: -20, left: 0, fontSize: 9, backgroundColor: '#374151', color: 'white', padding: '1px 6px', borderRadius: 3, whiteSpace: 'nowrap', zIndex: 40, lineHeight: '16px' }}>
크기 조절 · ESC 해제
</div>
)}
{/* 리사이즈 핸들 (리사이즈 모드에서만 표시) */}
{resizeMode && <>
{['nw','n','ne','w','e','sw','s','se'].map(dir => {
const styles = {
nw: { top: -4, left: -4, cursor: 'nw-resize' },
n: { top: -4, left: '50%', transform: 'translateX(-50%)', cursor: 'n-resize' },
ne: { top: -4, right: -4, cursor: 'ne-resize' },
w: { top: '50%', left: -4, transform: 'translateY(-50%)', cursor: 'w-resize' },
e: { top: '50%', right: -4, transform: 'translateY(-50%)', cursor: 'e-resize' },
sw: { bottom: -4, left: -4, cursor: 'sw-resize' },
s: { bottom: -4, left: '50%', transform: 'translateX(-50%)', cursor: 's-resize' },
se: { bottom: -4, right: -4, cursor: 'se-resize' },
nw: { top: -5, left: -5, cursor: 'nw-resize' },
n: { top: -5, left: '50%', transform: 'translateX(-50%)', cursor: 'n-resize' },
ne: { top: -5, right: -5, cursor: 'ne-resize' },
w: { top: '50%', left: -5, transform: 'translateY(-50%)', cursor: 'w-resize' },
e: { top: '50%', right: -5, transform: 'translateY(-50%)', cursor: 'e-resize' },
sw: { bottom: -5, left: -5, cursor: 'sw-resize' },
s: { bottom: -5, left: '50%', transform: 'translateX(-50%)', cursor: 's-resize' },
se: { bottom: -5, right: -5, cursor: 'se-resize' },
};
return (
<div key={dir} style={{
...styles[dir], position: 'absolute',
width: 8, height: 8, backgroundColor: 'white',
width: 10, height: 10, backgroundColor: 'white',
border: `2px solid ${color}`, borderRadius: 2,
zIndex: 30,
}} />

View File

@@ -130,8 +130,10 @@ className={`w-full relative rounded border-2 transition-all ${p === currentPage
};
// ─── FieldOverlay (interact.js) ───
// 기본: 점선 + 드래그=이동 / 더블클릭: 실선 + 핸들=크기조절 / ESC: 모드해제
const FieldOverlay = ({ field, index, selected, signerCount, containerRef, onSelect, onUpdate, onDragEnd, gridEnabled }) => {
const fieldRef = useRef(null);
const [resizeMode, setResizeMode] = useState(false);
const latestRef = useRef({ field, index, onUpdate, onDragEnd });
latestRef.current = { field, index, onUpdate, onDragEnd };
@@ -140,6 +142,18 @@ className={`w-full relative rounded border-2 transition-all ${p === currentPage
const signerName = SIGNER_LABELS[signerIdx] || `서명자 ${field.signer_order}`;
const typeInfo = FIELD_TYPES.find(t => t.value === field.field_type) || FIELD_TYPES[0];
// 선택 해제 시 리사이즈 모드 자동 해제
useEffect(() => { if (!selected) setResizeMode(false); }, [selected]);
// ESC 키로 리사이즈 모드 해제
useEffect(() => {
if (!resizeMode) return;
const h = (e) => { if (e.key === 'Escape') { e.stopPropagation(); setResizeMode(false); } };
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [resizeMode]);
// interact.js: resizeMode에 따라 드래그 또는 리사이즈만 활성화
useEffect(() => {
const el = fieldRef.current;
if (!el) return;
@@ -154,86 +168,103 @@ className={`w-full relative rounded border-2 transition-all ${p === currentPage
}));
}
const interactable = interact(el)
.draggable({
inertia: false,
modifiers,
listeners: {
move(event) {
const { field: f, index: idx, onUpdate: update } = latestRef.current;
const rect = containerRef.current.getBoundingClientRect();
const dx = (event.dx / rect.width) * 100;
const dy = (event.dy / rect.height) * 100;
const newX = Math.max(0, Math.min(100 - f.width, f.position_x + dx));
const newY = Math.max(0, Math.min(100 - f.height, f.position_y + dy));
update(idx, { position_x: round2(newX), position_y: round2(newY) });
const interactable = interact(el);
if (resizeMode) {
interactable
.draggable({ enabled: false })
.resizable({
edges: { left: true, right: true, top: true, bottom: true },
modifiers: [
interact.modifiers.restrictEdges({ outer: 'parent' }),
interact.modifiers.restrictSize({ min: { width: 30, height: 20 } }),
],
listeners: {
move(event) {
const { field: f, index: idx, onUpdate: update } = latestRef.current;
const rect = containerRef.current.getBoundingClientRect();
const newX = f.position_x + (event.deltaRect.left / rect.width) * 100;
const newY = f.position_y + (event.deltaRect.top / rect.height) * 100;
const newW = (event.rect.width / rect.width) * 100;
const newH = (event.rect.height / rect.height) * 100;
update(idx, {
position_x: round2(Math.max(0, newX)),
position_y: round2(Math.max(0, newY)),
width: round2(Math.max(2, newW)),
height: round2(Math.max(1, newH)),
});
},
end() { latestRef.current.onDragEnd(); },
},
end() { latestRef.current.onDragEnd(); },
},
})
.resizable({
edges: { left: true, right: true, top: true, bottom: true },
modifiers: [
interact.modifiers.restrictEdges({ outer: 'parent' }),
interact.modifiers.restrictSize({ min: { width: 30, height: 20 } }),
],
listeners: {
move(event) {
const { field: f, index: idx, onUpdate: update } = latestRef.current;
const rect = containerRef.current.getBoundingClientRect();
const newX = f.position_x + (event.deltaRect.left / rect.width) * 100;
const newY = f.position_y + (event.deltaRect.top / rect.height) * 100;
const newW = (event.rect.width / rect.width) * 100;
const newH = (event.rect.height / rect.height) * 100;
update(idx, {
position_x: round2(Math.max(0, newX)),
position_y: round2(Math.max(0, newY)),
width: round2(Math.max(2, newW)),
height: round2(Math.max(1, newH)),
});
});
} else {
interactable
.resizable({ enabled: false })
.draggable({
inertia: false,
modifiers,
listeners: {
move(event) {
const { field: f, index: idx, onUpdate: update } = latestRef.current;
const rect = containerRef.current.getBoundingClientRect();
const dx = (event.dx / rect.width) * 100;
const dy = (event.dy / rect.height) * 100;
const newX = Math.max(0, Math.min(100 - f.width, f.position_x + dx));
const newY = Math.max(0, Math.min(100 - f.height, f.position_y + dy));
update(idx, { position_x: round2(newX), position_y: round2(newY) });
},
end() { latestRef.current.onDragEnd(); },
},
end() { latestRef.current.onDragEnd(); },
},
});
});
}
return () => interactable.unset();
}, [gridEnabled]);
}, [gridEnabled, resizeMode]);
return (
<div ref={fieldRef}
onClick={(e) => { e.stopPropagation(); onSelect(index); }}
onDoubleClick={(e) => { e.stopPropagation(); onSelect(index); setResizeMode(prev => !prev); }}
style={{
position: 'absolute',
left: `${field.position_x}%`, top: `${field.position_y}%`,
width: `${field.width}%`, height: `${field.height}%`,
border: selected ? `2px solid ${color}` : `2px dashed ${color}`,
backgroundColor: selected ? `${color}30` : `${color}15`,
cursor: 'move', borderRadius: '4px',
border: resizeMode ? `2px solid ${color}` : `2px dashed ${color}`,
backgroundColor: resizeMode ? `${color}30` : (selected ? `${color}20` : `${color}10`),
cursor: resizeMode ? 'default' : 'move',
borderRadius: '4px',
zIndex: selected ? 20 : 10,
touchAction: 'none',
boxShadow: selected ? `0 0 0 1px ${color}, 0 2px 8px rgba(0,0,0,0.15)` : 'none',
boxShadow: resizeMode ? `0 0 0 2px ${color}, 0 2px 12px rgba(0,0,0,0.2)` : (selected ? `0 0 0 1px ${color}, 0 2px 8px rgba(0,0,0,0.15)` : 'none'),
}}
className="flex items-center justify-center select-none group transition-shadow">
<div className="flex items-center gap-0.5 text-[10px] font-medium truncate px-1" style={{ color }}>
<span>{typeInfo.icon}</span>
<span className="truncate">{field.field_variable ? `{{${field.field_variable}}}` : (field.field_label || signerName)}</span>
</div>
{selected && <>
{/* 리사이즈 모드 안내 툴팁 */}
{resizeMode && (
<div style={{ position: 'absolute', top: -20, left: 0, fontSize: 9, backgroundColor: '#374151', color: 'white', padding: '1px 6px', borderRadius: 3, whiteSpace: 'nowrap', zIndex: 40, lineHeight: '16px' }}>
크기 조절 · ESC 해제
</div>
)}
{/* 리사이즈 핸들 (리사이즈 모드에서만 표시) */}
{resizeMode && <>
{['nw','n','ne','w','e','sw','s','se'].map(dir => {
const styles = {
nw: { top: -4, left: -4, cursor: 'nw-resize' },
n: { top: -4, left: '50%', transform: 'translateX(-50%)', cursor: 'n-resize' },
ne: { top: -4, right: -4, cursor: 'ne-resize' },
w: { top: '50%', left: -4, transform: 'translateY(-50%)', cursor: 'w-resize' },
e: { top: '50%', right: -4, transform: 'translateY(-50%)', cursor: 'e-resize' },
sw: { bottom: -4, left: -4, cursor: 'sw-resize' },
s: { bottom: -4, left: '50%', transform: 'translateX(-50%)', cursor: 's-resize' },
se: { bottom: -4, right: -4, cursor: 'se-resize' },
nw: { top: -5, left: -5, cursor: 'nw-resize' },
n: { top: -5, left: '50%', transform: 'translateX(-50%)', cursor: 'n-resize' },
ne: { top: -5, right: -5, cursor: 'ne-resize' },
w: { top: '50%', left: -5, transform: 'translateY(-50%)', cursor: 'w-resize' },
e: { top: '50%', right: -5, transform: 'translateY(-50%)', cursor: 'e-resize' },
sw: { bottom: -5, left: -5, cursor: 'sw-resize' },
s: { bottom: -5, left: '50%', transform: 'translateX(-50%)', cursor: 's-resize' },
se: { bottom: -5, right: -5, cursor: 'se-resize' },
};
return (
<div key={dir} style={{
...styles[dir], position: 'absolute',
width: 8, height: 8, backgroundColor: 'white',
width: 10, height: 10, backgroundColor: 'white',
border: `2px solid ${color}`, borderRadius: 2,
zIndex: 30,
}} />