feat: [planning-design] Description 패널 리사이즈 + 번호 뱃지 마커 블록 (드래그&드롭/툴바)

This commit is contained in:
김보곤
2026-03-08 00:41:32 +09:00
parent fb9c6e1de5
commit 08dbab9912

View File

@@ -598,9 +598,14 @@
} }
.sb-wireframe-content:focus { border-color: var(--pc-indigo); } .sb-wireframe-content:focus { border-color: var(--pc-indigo); }
.sb-wireframe-img { max-width: 100%; border-radius: 8px; } .sb-wireframe-img { max-width: 100%; border-radius: 8px; }
.sb-desc-resizer {
height: 5px; flex-shrink: 0; cursor: row-resize; background: #1e293b;
transition: background .15s;
}
.sb-desc-resizer:hover, .sb-desc-resizer.active { background: #818cf8; }
.sb-desc-panel { .sb-desc-panel {
border-top: 2px solid #1e293b; padding: 12px 16px; background: #fafbfc; padding: 12px 16px; background: #fafbfc;
max-height: 260px; overflow-y: auto; overflow-y: auto; flex-shrink: 0;
} }
.sb-desc-title { .sb-desc-title {
font-size: 10px; font-weight: 700; color: #1e293b; margin-bottom: 8px; font-size: 10px; font-weight: 700; color: #1e293b; margin-bottom: 8px;
@@ -633,6 +638,14 @@
align-items: center; justify-content: center; flex-shrink: 0; align-items: center; justify-content: center; flex-shrink: 0;
} }
.sb-desc-remove:hover { color: #ef4444; background: #fef2f2; } .sb-desc-remove:hover { color: #ef4444; background: #fef2f2; }
.sb-desc-num { cursor: grab; }
.sb-desc-num:active { cursor: grabbing; }
/* Marker (description number badge on canvas) */
.sb-blk-marker {
width: 28px; height: 28px; border-radius: 50%; background: #1e293b; color: #fff;
font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center;
user-select: none; cursor: move;
}
/* Block Editor (Wireframe) */ /* Block Editor (Wireframe) */
.sb-block-toolbar { .sb-block-toolbar {
@@ -1366,6 +1379,16 @@
<button class="sb-block-toolbar-btn" @click="sbAddBlock('image')">🖼 이미지</button> <button class="sb-block-toolbar-btn" @click="sbAddBlock('image')">🖼 이미지</button>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('divider')"> 구분선</button> <button class="sb-block-toolbar-btn" @click="sbAddBlock('divider')"> 구분선</button>
<div class="sb-block-toolbar-sep"></div> <div class="sb-block-toolbar-sep"></div>
{{-- Description Marker --}}
<div style="display:flex; align-items:center; gap:3px;">
<input type="text" x-model="sbMarkerNum" placeholder="01" maxlength="3"
style="width:32px; padding:3px 4px; border:1px solid #e2e8f0; border-radius:5px; font-size:10px; text-align:center; font-weight:700;">
<button class="sb-block-toolbar-btn" @click="sbAddMarkerBlock()" title="번호 뱃지를 캔버스에 추가">
<span style="display:inline-flex;width:16px;height:16px;border-radius:50%;background:#1e293b;color:#fff;font-size:8px;font-weight:700;align-items:center;justify-content:center;" x-text="sbMarkerNum || '01'"></span>
번호
</button>
</div>
<div class="sb-block-toolbar-sep"></div>
{{-- Template Dropdown --}} {{-- Template Dropdown --}}
<div class="sb-tpl-dropdown" @click.outside="sbTplOpen = false"> <div class="sb-tpl-dropdown" @click.outside="sbTplOpen = false">
<button class="sb-tpl-trigger" x-ref="sbTplBtn" @click="sbToggleTplPanel()">📋 템플릿 <span style="font-size:8px;"></span></button> <button class="sb-tpl-trigger" x-ref="sbTplBtn" @click="sbToggleTplPanel()">📋 템플릿 <span style="font-size:8px;"></span></button>
@@ -1498,8 +1521,8 @@
</div> </div>
<div class="sb-page-body" <div class="sb-page-body"
@mousemove.window="if(_sbMenuResize){ sbMenuWidth = Math.max(80, Math.min(400, _sbMenuResize.startW + $event.clientX - _sbMenuResize.startX)); }" @mousemove.window="if(_sbMenuResize){ sbMenuWidth = Math.max(80, Math.min(400, _sbMenuResize.startW + $event.clientX - _sbMenuResize.startX)); } if(_sbDescResize){ sbDescHeight = Math.max(60, Math.min(500, _sbDescResize.startH - ($event.clientY - _sbDescResize.startY))); }"
@mouseup.window="if(_sbMenuResize){ _sbMenuResize = null; document.querySelector('.sb-menu-resizer.active')?.classList.remove('active'); }"> @mouseup.window="if(_sbMenuResize){ _sbMenuResize = null; document.querySelector('.sb-menu-resizer.active')?.classList.remove('active'); } if(_sbDescResize){ _sbDescResize = null; document.querySelector('.sb-desc-resizer.active')?.classList.remove('active'); }">
{{-- Left Menu --}} {{-- Left Menu --}}
<div class="sb-menu-panel" :style="'width:' + sbMenuWidth + 'px'"> <div class="sb-menu-panel" :style="'width:' + sbMenuWidth + 'px'">
<div class="sb-menu-logo" x-text="sb.docInfo.projectName || 'LOGO'"></div> <div class="sb-menu-logo" x-text="sb.docInfo.projectName || 'LOGO'"></div>
@@ -1531,7 +1554,9 @@
@mousemove="sbCanvasMouseMove($event)" @mousemove="sbCanvasMouseMove($event)"
@mouseup="sbCanvasMouseUp($event)" @mouseup="sbCanvasMouseUp($event)"
@click.self="if(!_sbLassoDone){ sbSelectedBlock = null; sbMultiSelected = []; } _sbLassoDone = false;" @click.self="if(!_sbLassoDone){ sbSelectedBlock = null; sbMultiSelected = []; } _sbLassoDone = false;"
style="min-height: 600px;"> @dragover.prevent="$event.dataTransfer.dropEffect = 'copy'"
@drop.prevent="sbDropMarker($event)"
style="min-height: 600px; flex: 1;">
{{-- Lasso rectangle --}} {{-- Lasso rectangle --}}
<div class="sb-lasso-rect" x-show="_sbLasso" x-cloak <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> :style="_sbLasso ? 'left:'+_sbLasso.rx+'px;top:'+_sbLasso.ry+'px;width:'+_sbLasso.rw+'px;height:'+_sbLasso.rh+'px' : ''"></div>
@@ -1741,16 +1766,27 @@
</template> </template>
</div> </div>
</template> </template>
{{-- Marker (description number) --}}
<template x-if="blk.type === 'marker'">
<div class="sb-blk-marker" x-text="blk.content || '01'"></div>
</template>
</div> </div>
</template> </template>
</div> </div>
{{-- Desc Resizer --}}
<div class="sb-desc-resizer"
@mousedown.prevent="_sbDescResize = { startY: $event.clientY, startH: sbDescHeight }; $event.target.classList.add('active')"></div>
{{-- Description Panel --}} {{-- Description Panel --}}
<div class="sb-desc-panel"> <div class="sb-desc-panel" :style="'height:' + sbDescHeight + 'px'">
<div class="sb-desc-title">Description</div> <div class="sb-desc-title">Description</div>
<template x-for="(desc, idx) in (sbCurrentPage.descriptions || [])" :key="idx"> <template x-for="(desc, idx) in (sbCurrentPage.descriptions || [])" :key="idx">
<div class="sb-desc-item"> <div class="sb-desc-item">
<div class="sb-desc-num" x-text="String(idx + 1).padStart(2, '0')"></div> <div class="sb-desc-num"
x-text="String(idx + 1).padStart(2, '0')"
draggable="true"
@dragstart="$event.dataTransfer.setData('text/plain', 'marker:' + String(idx + 1).padStart(2, '0')); $event.dataTransfer.effectAllowed = 'copy';"></div>
<div class="sb-desc-text"> <div class="sb-desc-text">
<textarea x-model="desc.text" rows="2" placeholder="기능 설명을 입력하세요..." @input="autoSave()"></textarea> <textarea x-model="desc.text" rows="2" placeholder="기능 설명을 입력하세요..." @input="autoSave()"></textarea>
</div> </div>
@@ -1997,7 +2033,10 @@ function planningCanvas() {
_sbResize: null, // { blk, dir, startX, startY, origW, origH } _sbResize: null, // { blk, dir, startX, startY, origW, origH }
_sbClipboard: null, // copied block data _sbClipboard: null, // copied block data
sbMenuWidth: 160, sbMenuWidth: 160,
sbDescHeight: 200,
sbMarkerNum: '01',
_sbMenuResize: null, _sbMenuResize: null,
_sbDescResize: null,
sbMultiSelected: [], // 다중 선택 블록 id 배열 sbMultiSelected: [], // 다중 선택 블록 id 배열
_sbLasso: null, // { startX, startY, rx, ry, rw, rh } _sbLasso: null, // { startX, startY, rx, ry, rw, rh }
_sbLassoDone: false, // 올가미 완료 직후 click.self 방지 플래그 _sbLassoDone: false, // 올가미 완료 직후 click.self 방지 플래그
@@ -2956,8 +2995,8 @@ function planningCanvas() {
const maxBottom = Math.max(...page.blocks.map(b => (b.y || 0) + (b.h || 40))); const maxBottom = Math.max(...page.blocks.map(b => (b.y || 0) + (b.h || 40)));
autoY = maxBottom + 12; autoY = maxBottom + 12;
} }
const defW = { heading: 400, heading2: 350, text: 340, divider: 400, table: 500, card: 300, code: 400, badges: 350, todo: 300, image: 400 }; const defW = { heading: 400, heading2: 350, text: 340, divider: 400, table: 500, card: 300, code: 400, badges: 350, todo: 300, image: 400, marker: 32 };
const defH = { heading: 40, heading2: 36, text: 50, divider: 20, table: 140, card: 90, button: 50, input: 70, select: 70, callout: 60, code: 80, badges: 50, todo: 80, image: 200 }; const defH = { heading: 40, heading2: 36, text: 50, divider: 20, table: 140, card: 90, button: 50, input: 70, select: 70, callout: 60, code: 80, badges: 50, todo: 80, image: 200, marker: 32 };
const base = { id, type, content: '', x: 16, y: autoY, w: defW[type] || 240, h: defH[type] || 40 }; const base = { id, type, content: '', x: 16, y: autoY, w: defW[type] || 240, h: defH[type] || 40 };
switch (type) { switch (type) {
case 'heading': return { ...base }; case 'heading': return { ...base };
@@ -2981,6 +3020,7 @@ function planningCanvas() {
{ text: '항목 2', checked: false }, { text: '항목 2', checked: false },
]}; ]};
case 'image': return { ...base, src: '' }; case 'image': return { ...base, src: '' };
case 'marker': return { ...base, content: base.content || '01' };
default: return base; default: return base;
} }
}, },
@@ -2996,6 +3036,42 @@ function planningCanvas() {
this.autoSave(); this.autoSave();
}, },
sbAddMarkerBlock() {
const num = this.sbMarkerNum || '01';
const page = this.sbCurrentPage;
if (!page) return;
if (!page.blocks) page.blocks = [];
this.sbPushHistory();
const blk = this.sbNewBlock('marker');
blk.content = num;
page.blocks.push(blk);
this.sbSelectedBlock = blk.id;
// 다음 번호 자동 증가
const n = parseInt(num, 10);
if (!isNaN(n)) this.sbMarkerNum = String(n + 1).padStart(2, '0');
this.autoSave();
},
sbDropMarker(e) {
const data = e.dataTransfer.getData('text/plain');
if (!data.startsWith('marker:')) return;
const num = data.slice(7);
const page = this.sbCurrentPage;
if (!page) return;
if (!page.blocks) page.blocks = [];
this.sbPushHistory();
const rect = this.$refs.sbCanvas.getBoundingClientRect();
const x = e.clientX - rect.left + this.$refs.sbCanvas.scrollLeft;
const y = e.clientY - rect.top + this.$refs.sbCanvas.scrollTop;
const blk = this.sbNewBlock('marker');
blk.content = num;
blk.x = Math.max(0, x - 14);
blk.y = Math.max(0, y - 14);
page.blocks.push(blk);
this.sbSelectedBlock = blk.id;
this.autoSave();
},
sbAddBlockAfter(idx, type) { sbAddBlockAfter(idx, type) {
const page = this.sbCurrentPage; const page = this.sbCurrentPage;
if (!page || !page.blocks) return; if (!page || !page.blocks) return;
@@ -3351,8 +3427,8 @@ function planningCanvas() {
if (page.blocks.length > 0) { if (page.blocks.length > 0) {
curY = Math.max(...page.blocks.map(b => (b.y || 0) + (b.h || 40))) + 16; curY = Math.max(...page.blocks.map(b => (b.y || 0) + (b.h || 40))) + 16;
} }
const defW = { heading: 400, heading2: 350, text: 340, divider: 400, table: 500, card: 300, code: 400, badges: 350, todo: 300, image: 400 }; const defW = { heading: 400, heading2: 350, text: 340, divider: 400, table: 500, card: 300, code: 400, badges: 350, todo: 300, image: 400, marker: 32 };
const defH = { heading: 40, heading2: 36, text: 50, divider: 20, table: 140, card: 90, button: 50, input: 70, select: 70, callout: 60, code: 80, badges: 50, todo: 80, image: 200 }; const defH = { heading: 40, heading2: 36, text: 50, divider: 20, table: 140, card: 90, button: 50, input: 70, select: 70, callout: 60, code: 80, badges: 50, todo: 80, image: 200, marker: 32 };
blocks.forEach(blk => { blocks.forEach(blk => {
blk.id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5); blk.id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
if (blk.x === undefined) blk.x = 16; if (blk.x === undefined) blk.x = 16;
@@ -3602,6 +3678,7 @@ function planningCanvas() {
return t + '</div>'; return t + '</div>';
} }
case 'image': return blk.src ? '<div style="margin:4px 0;text-align:center;"><img src="' + blk.src + '" style="max-width:100%;border-radius:6px;"></div>' : ''; case 'image': return blk.src ? '<div style="margin:4px 0;text-align:center;"><img src="' + blk.src + '" style="max-width:100%;border-radius:6px;"></div>' : '';
case 'marker': return '<div style="display:inline-flex;width:28px;height:28px;border-radius:50%;background:#1e293b;color:#fff;font-size:11px;font-weight:700;align-items:center;justify-content:center;">' + esc(blk.content) + '</div>';
default: return '<p>' + esc(blk.content) + '</p>'; default: return '<p>' + esc(blk.content) + '</p>';
} }
}, },