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:
@@ -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,
|
||||
}} />
|
||||
|
||||
@@ -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,
|
||||
}} />
|
||||
|
||||
Reference in New Issue
Block a user