feat: [planning-design] 올가미(마퀴) 다중 선택 + 그룹 이동/복사/삭제 기능 추가
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user