feat: [planning-design] 올가미(마퀴) 다중 선택 + 그룹 이동/복사/삭제 기능 추가

This commit is contained in:
김보곤
2026-03-08 00:26:26 +09:00
parent 20e5ab784e
commit 7785dfed98

View File

@@ -690,6 +690,14 @@
opacity: 0; transition: opacity .1s;
}
.sb-block.selected .sb-block-size { opacity: 1; }
/* Multi-select (lasso) */
.sb-block.sb-multi-selected { border-color: #f59e0b; box-shadow: 0 0 0 2px rgba(245,158,11,0.2); }
.sb-block.sb-multi-selected .sb-block-actions { opacity: 0; }
.sb-block.sb-multi-selected .sb-resize-handle { opacity: 0; }
.sb-lasso-rect {
position: absolute; border: 1.5px dashed #6366f1; background: rgba(99,102,241,0.06);
pointer-events: none; z-index: 100;
}
/* Block type styles */
.sb-blk-text { padding: 6px 8px; font-size: 13px; line-height: 1.7; min-height: 24px; outline: none; color: #334155; }
@@ -1522,8 +1530,11 @@
@mousedown="sbCanvasMouseDown($event)"
@mousemove="sbCanvasMouseMove($event)"
@mouseup="sbCanvasMouseUp($event)"
@click.self="sbSelectedBlock = null"
@click.self="sbSelectedBlock = null; sbMultiSelected = []"
style="min-height: 600px;">
{{-- Lasso rectangle --}}
<div class="sb-lasso-rect" x-show="_sbLasso" x-cloak
:style="_sbLasso ? 'left:'+_sbLasso.rx+'px;top:'+_sbLasso.ry+'px;width:'+_sbLasso.rw+'px;height:'+_sbLasso.rh+'px' : ''"></div>
<template x-if="!sbPageBlocks.length">
<div class="sb-blk-empty-area" style="position:absolute;inset:40px;" @click="sbAddBlock('text')">
<svg style="width:28px;height:28px;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4v16m8-8H4"/></svg>
@@ -1533,7 +1544,7 @@
</template>
<template x-for="(blk, bi) in sbPageBlocks" :key="blk.id">
<div class="sb-block"
:class="{ selected: sbSelectedBlock === blk.id, 'sb-block-editing': sbEditingBlock === blk.id }"
:class="{ selected: sbSelectedBlock === blk.id, 'sb-multi-selected': sbMultiSelected.includes(blk.id), 'sb-block-editing': sbEditingBlock === blk.id }"
:style="'left:' + (blk.x||0) + 'px; top:' + (blk.y||0) + 'px; width:' + (blk.w||240) + 'px; min-height:' + (blk.h||40) + 'px;'"
@mousedown.stop="sbBlockMouseDown(blk, bi, $event)"
@dblclick="sbEditingBlock = blk.id">
@@ -1987,6 +1998,9 @@ function planningCanvas() {
_sbClipboard: null, // copied block data
sbMenuWidth: 160,
_sbMenuResize: null,
sbMultiSelected: [], // 다중 선택 블록 id 배열
_sbLasso: null, // { startX, startY, rx, ry, rw, rh }
_sbMultiDrag: null, // { startX, startY, origins: [{id, x, y}] }
_sbHistory: [],
_sbHistoryIdx: -1,
_sbHistoryPaused: false,
@@ -2789,8 +2803,8 @@ function planningCanvas() {
if ((e.ctrlKey || e.metaKey) && e.key === 'y') { e.preventDefault(); this.viewMode === 'storyboard' ? this.sbRedo() : this.redoAction(); return; }
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); this.saveProject(); }
if ((e.ctrlKey || e.metaKey) && e.key === 'd') { e.preventDefault(); this.duplicateNode(); }
// 스토리보드 블록 Ctrl+C / Ctrl+V / Delete
if (this.viewMode === 'storyboard' && this.sbSelectedBlock) {
// 스토리보드 블록 Ctrl+C / Ctrl+V / Delete (단일 + 다중)
if (this.viewMode === 'storyboard' && (this.sbSelectedBlock || this.sbMultiSelected.length > 0)) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C')) {
e.preventDefault();
this.sbCopyBlock();
@@ -2807,13 +2821,23 @@ function planningCanvas() {
return;
}
}
if (this.viewMode === 'storyboard' && !this.sbSelectedBlock) {
if (this.viewMode === 'storyboard' && !this.sbSelectedBlock && this.sbMultiSelected.length === 0) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V') && this._sbClipboard) {
e.preventDefault();
this.sbPasteBlock();
return;
}
}
// Ctrl+A 전체 선택
if (this.viewMode === 'storyboard' && (e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
const page = this.sbCurrentPage;
if (page && page.blocks && page.blocks.length > 0) {
e.preventDefault();
this.sbSelectedBlock = null;
this.sbMultiSelected = page.blocks.map(b => b.id);
return;
}
}
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
if (this.viewMode === 'kanban' || this.viewMode === 'list') {
e.preventDefault();
@@ -3002,8 +3026,21 @@ function planningCanvas() {
sbBlockMouseDown(blk, bi, e) {
// 더블클릭 편집 모드에서는 드래그 안 함
if (this.sbEditingBlock === blk.id) return;
this.sbSelectedBlock = blk.id;
this.sbPushHistory();
// 다중 선택된 블록 중 하나를 클릭하면 그룹 드래그
if (this.sbMultiSelected.includes(blk.id)) {
const page = this.sbCurrentPage;
const origins = this.sbMultiSelected.map(id => {
const b = page.blocks.find(x => x.id === id);
return b ? { id: b.id, x: b.x || 0, y: b.y || 0 } : null;
}).filter(Boolean);
this._sbMultiDrag = { startX: e.clientX, startY: e.clientY, origins };
e.preventDefault();
return;
}
// 단일 선택
this.sbMultiSelected = [];
this.sbSelectedBlock = blk.id;
this._sbDrag = {
blk,
startX: e.clientX,
@@ -3029,13 +3066,53 @@ function planningCanvas() {
},
sbCanvasMouseDown(e) {
// 빈 캔버스 클릭 시 편집 모드 해제
if (e.target === this.$refs.sbCanvas) {
// 빈 캔버스 클릭 시 올가미 시작
if (e.target === this.$refs.sbCanvas || e.target.classList.contains('sb-blk-empty-area')) {
this.sbEditingBlock = null;
const rect = this.$refs.sbCanvas.getBoundingClientRect();
const scrollL = this.$refs.sbCanvas.scrollLeft;
const scrollT = this.$refs.sbCanvas.scrollTop;
const sx = e.clientX - rect.left + scrollL;
const sy = e.clientY - rect.top + scrollT;
this._sbLasso = { startX: sx, startY: sy, rx: sx, ry: sy, rw: 0, rh: 0 };
this.sbMultiSelected = [];
this.sbSelectedBlock = null;
}
},
sbCanvasMouseMove(e) {
// 올가미 드래그
if (this._sbLasso) {
const rect = this.$refs.sbCanvas.getBoundingClientRect();
const scrollL = this.$refs.sbCanvas.scrollLeft;
const scrollT = this.$refs.sbCanvas.scrollTop;
const cx = e.clientX - rect.left + scrollL;
const cy = e.clientY - rect.top + scrollT;
const l = this._sbLasso;
l.rx = Math.min(l.startX, cx);
l.ry = Math.min(l.startY, cy);
l.rw = Math.abs(cx - l.startX);
l.rh = Math.abs(cy - l.startY);
return;
}
// 다중 블록 드래그
if (this._sbMultiDrag) {
const md = this._sbMultiDrag;
const dx = e.clientX - md.startX;
const dy = e.clientY - md.startY;
const page = this.sbCurrentPage;
if (page && page.blocks) {
md.origins.forEach(o => {
const blk = page.blocks.find(b => b.id === o.id);
if (blk) {
blk.x = Math.max(0, o.x + dx);
blk.y = Math.max(0, o.y + dy);
}
});
}
return;
}
// 단일 블록 드래그
if (this._sbDrag) {
const d = this._sbDrag;
const dx = e.clientX - d.startX;
@@ -3043,6 +3120,7 @@ function planningCanvas() {
d.blk.x = Math.max(0, d.origX + dx);
d.blk.y = Math.max(0, d.origY + dy);
}
// 리사이즈
if (this._sbResize) {
const r = this._sbResize;
const dx = e.clientX - r.startX;
@@ -3057,6 +3135,35 @@ function planningCanvas() {
},
sbCanvasMouseUp(e) {
// 올가미 완료 → 범위 내 블록 선택
if (this._sbLasso) {
const l = this._sbLasso;
if (l.rw > 5 && l.rh > 5) {
const page = this.sbCurrentPage;
if (page && page.blocks) {
this.sbMultiSelected = page.blocks.filter(blk => {
const bx = blk.x || 0, by = blk.y || 0;
const bw = blk.w || 240, bh = blk.h || 40;
// 블록이 올가미 사각형과 겹치는지 체크
return bx + bw > l.rx && bx < l.rx + l.rw &&
by + bh > l.ry && by < l.ry + l.rh;
}).map(b => b.id);
}
// 1개만 선택되면 단일 선택으로 전환
if (this.sbMultiSelected.length === 1) {
this.sbSelectedBlock = this.sbMultiSelected[0];
this.sbMultiSelected = [];
}
}
this._sbLasso = null;
return;
}
// 다중 드래그 완료
if (this._sbMultiDrag) {
this._sbMultiDrag = null;
this.autoSave();
return;
}
if (this._sbDrag) {
this._sbDrag = null;
this.autoSave();
@@ -3070,6 +3177,15 @@ function planningCanvas() {
sbCopyBlock() {
const page = this.sbCurrentPage;
if (!page || !page.blocks) return;
// 다중 선택 복사
if (this.sbMultiSelected.length > 0) {
this._sbClipboard = this.sbMultiSelected.map(id => {
const b = page.blocks.find(x => x.id === id);
return b ? JSON.parse(JSON.stringify(b)) : null;
}).filter(Boolean);
return;
}
// 단일 복사
const blk = page.blocks.find(b => b.id === this.sbSelectedBlock);
if (!blk) return;
this._sbClipboard = JSON.parse(JSON.stringify(blk));
@@ -3081,6 +3197,25 @@ function planningCanvas() {
if (!page) return;
if (!page.blocks) page.blocks = [];
this.sbPushHistory();
// 다중 붙여넣기
if (Array.isArray(this._sbClipboard)) {
const newIds = [];
const copies = this._sbClipboard.map(b => {
const copy = JSON.parse(JSON.stringify(b));
copy.id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
copy.x = (copy.x || 0) + 24;
copy.y = (copy.y || 0) + 24;
newIds.push(copy.id);
return copy;
});
page.blocks.push(...copies);
this.sbSelectedBlock = null;
this.sbMultiSelected = newIds;
this._sbClipboard = copies; // 연속 오프셋 누적
this.autoSave();
return;
}
// 단일 붙여넣기
const copy = JSON.parse(JSON.stringify(this._sbClipboard));
copy.id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
copy.x = (copy.x || 0) + 24;
@@ -3094,9 +3229,18 @@ function planningCanvas() {
sbDeleteSelectedBlock() {
const page = this.sbCurrentPage;
if (!page || !page.blocks) return;
this.sbPushHistory();
// 다중 삭제
if (this.sbMultiSelected.length > 0) {
page.blocks = page.blocks.filter(b => !this.sbMultiSelected.includes(b.id));
this.sbMultiSelected = [];
this.sbSelectedBlock = null;
this.autoSave();
return;
}
// 단일 삭제
const idx = page.blocks.findIndex(b => b.id === this.sbSelectedBlock);
if (idx < 0) return;
this.sbPushHistory();
page.blocks.splice(idx, 1);
this.sbSelectedBlock = null;
this.autoSave();