feat: [planning-design] 스토리보드 블록 편집기 구현

- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
  코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
This commit is contained in:
김보곤
2026-03-07 23:19:16 +09:00
parent 90c03b3f14
commit 2f9ef0d0a2

View File

@@ -621,6 +621,121 @@
}
.sb-desc-remove:hover { color: #ef4444; background: #fef2f2; }
/* Block Editor (Wireframe) */
.sb-block-toolbar {
display: flex; align-items: center; gap: 4px; padding: 6px 12px;
border-bottom: 1px solid #e2e8f0; background: #fafbfc; flex-wrap: wrap;
}
.sb-block-toolbar-btn {
padding: 3px 8px; border: 1px solid #e2e8f0; border-radius: 5px;
font-size: 10px; cursor: pointer; background: #fff; color: #475569;
display: flex; align-items: center; gap: 3px; white-space: nowrap;
transition: all .12s;
}
.sb-block-toolbar-btn:hover { border-color: var(--pc-indigo); color: var(--pc-indigo); background: #eef2ff; }
.sb-block-toolbar-sep { width: 1px; height: 18px; background: #e2e8f0; margin: 0 2px; }
.sb-blocks-area { flex: 1; padding: 16px; overflow-y: auto; min-height: 300px; }
.sb-block {
position: relative; border: 1.5px solid transparent; border-radius: 6px;
margin-bottom: 2px; padding: 0; transition: border-color .12s;
group: true;
}
.sb-block:hover { border-color: #c7d2fe; }
.sb-block.selected { border-color: var(--pc-indigo); background: #fafafe; }
.sb-block-handle {
position: absolute; left: -22px; top: 50%; transform: translateY(-50%);
width: 18px; height: 18px; display: flex; align-items: center; justify-content: center;
color: #cbd5e1; cursor: grab; font-size: 12px; opacity: 0; transition: opacity .12s;
user-select: none;
}
.sb-block:hover .sb-block-handle { opacity: 1; }
.sb-block-handle:active { cursor: grabbing; color: #6366f1; }
.sb-block-actions {
position: absolute; right: 4px; top: 4px; display: flex; gap: 2px;
opacity: 0; transition: opacity .12s;
}
.sb-block:hover .sb-block-actions { opacity: 1; }
.sb-block-action-btn {
width: 20px; height: 20px; border: none; background: #f1f5f9; color: #94a3b8;
border-radius: 4px; cursor: pointer; font-size: 11px; display: flex;
align-items: center; justify-content: center;
}
.sb-block-action-btn:hover { background: #e2e8f0; color: #475569; }
.sb-block-action-btn.danger:hover { background: #fef2f2; color: #ef4444; }
.sb-block-drop-indicator {
height: 3px; background: var(--pc-indigo); border-radius: 2px; margin: -2px 0;
transition: opacity .12s; opacity: 0;
}
.sb-block-drop-indicator.active { opacity: 1; }
/* Block type styles */
.sb-blk-text { padding: 6px 8px; font-size: 13px; line-height: 1.7; min-height: 24px; outline: none; color: #334155; }
.sb-blk-text:empty::before { content: attr(data-placeholder); color: #cbd5e1; }
.sb-blk-heading { padding: 8px 8px 4px; font-size: 18px; font-weight: 700; line-height: 1.4; outline: none; color: #1e293b; }
.sb-blk-heading:empty::before { content: '제목을 입력하세요'; color: #cbd5e1; }
.sb-blk-h2 { font-size: 15px; font-weight: 600; }
.sb-blk-divider { border: none; border-top: 1px solid #e2e8f0; margin: 8px 0; }
.sb-blk-callout {
display: flex; gap: 8px; padding: 10px 12px; background: #eff6ff;
border-radius: 6px; border-left: 3px solid #3b82f6;
}
.sb-blk-callout-icon { font-size: 16px; flex-shrink: 0; }
.sb-blk-callout-text { flex: 1; font-size: 12px; line-height: 1.6; outline: none; color: #334155; }
.sb-blk-callout-text:empty::before { content: '콜아웃 내용을 입력하세요'; color: #93c5fd; }
.sb-blk-table-wrap { overflow-x: auto; padding: 4px; }
.sb-blk-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.sb-blk-table th { background: #f1f5f9; font-weight: 600; color: #475569; text-align: left; }
.sb-blk-table th, .sb-blk-table td {
border: 1px solid #e2e8f0; padding: 6px 8px; min-width: 60px; outline: none;
}
.sb-blk-table td:focus { background: #f8fafc; }
.sb-blk-mockup { padding: 8px; }
.sb-blk-mockup-label { font-size: 9px; color: #94a3b8; font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
.sb-blk-mock-btn {
display: inline-block; padding: 6px 16px; border-radius: 6px; font-size: 12px;
font-weight: 600; cursor: default;
}
.sb-blk-mock-input {
width: 100%; padding: 7px 10px; border: 1px solid #d1d5db; border-radius: 6px;
font-size: 12px; color: #6b7280; background: #f9fafb;
}
.sb-blk-mock-select {
padding: 7px 10px; border: 1px solid #d1d5db; border-radius: 6px;
font-size: 12px; color: #6b7280; background: #f9fafb;
}
.sb-blk-mock-card {
border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; background: #fff;
}
.sb-blk-mock-card-title { font-size: 13px; font-weight: 600; color: #1e293b; margin-bottom: 4px; outline: none; }
.sb-blk-mock-card-body { font-size: 11px; color: #64748b; line-height: 1.5; outline: none; }
.sb-blk-image-wrap { text-align: center; }
.sb-blk-image-wrap img { max-width: 100%; border-radius: 6px; }
.sb-blk-image-placeholder {
border: 2px dashed #d1d5db; border-radius: 6px; padding: 24px;
color: #94a3b8; font-size: 12px; cursor: pointer; text-align: center;
}
.sb-blk-image-placeholder:hover { border-color: var(--pc-indigo); }
.sb-blk-todo { display: flex; align-items: flex-start; gap: 6px; padding: 4px 8px; }
.sb-blk-todo input[type=checkbox] { margin-top: 4px; accent-color: var(--pc-indigo); }
.sb-blk-todo-text { flex: 1; font-size: 12px; outline: none; line-height: 1.6; }
.sb-blk-todo-text:empty::before { content: '할 일을 입력하세요'; color: #cbd5e1; }
.sb-blk-badge-row { display: flex; gap: 6px; flex-wrap: wrap; padding: 4px 8px; }
.sb-blk-badge {
padding: 3px 10px; border-radius: 12px; font-size: 10px; font-weight: 600;
}
.sb-blk-code {
padding: 10px 12px; background: #1e293b; border-radius: 6px; color: #e2e8f0;
font-family: 'Fira Code', monospace; font-size: 12px; line-height: 1.6;
outline: none; white-space: pre-wrap;
}
.sb-blk-code:empty::before { content: '코드를 입력하세요'; color: #64748b; }
.sb-blk-empty-area {
border: 2px dashed #e2e8f0; border-radius: 8px; min-height: 200px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #94a3b8; font-size: 12px; gap: 8px; cursor: pointer; transition: all .15s;
}
.sb-blk-empty-area:hover { border-color: var(--pc-indigo); background: #fafafe; }
/* Menu Tree Editor Modal */
.sb-menu-modal-overlay {
position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.4);
@@ -1223,40 +1338,237 @@
{{-- Content + Description --}}
<div class="sb-content-area">
{{-- Wireframe Area --}}
<div class="sb-wireframe">
<template x-if="!sbCurrentPage.wireframeContent && !sbCurrentPage.wireframeImage">
<div class="sb-wireframe-placeholder" @click="$refs.sbImageInput?.click()">
<svg style="width:32px;height:32px;color:#cbd5e1;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<span>이미지를 업로드하거나 클릭하여 텍스트 입력</span>
<div style="display:flex; gap:8px; margin-top:8px;">
<button style="padding:4px 12px; border:1px solid var(--pc-indigo); border-radius:6px; font-size:11px; color:var(--pc-indigo); background:#fff; cursor:pointer;"
@click.stop="sbCurrentPage.wireframeContent = ' '">텍스트로 작성</button>
<button style="padding:4px 12px; border:1px solid var(--pc-indigo); border-radius:6px; font-size:11px; color:#fff; background:var(--pc-indigo); cursor:pointer;"
@click.stop="$refs.sbImageInput.click()">이미지 업로드</button>
{{-- Block Toolbar --}}
<div class="sb-block-toolbar">
<button class="sb-block-toolbar-btn" @click="sbAddBlock('heading')">H1</button>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('heading2')">H2</button>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('text')">T 텍스트</button>
<div class="sb-block-toolbar-sep"></div>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('table')"> 테이블</button>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('callout')">💡 콜아웃</button>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('todo')"> 체크리스트</button>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('code')">{ } 코드</button>
<div class="sb-block-toolbar-sep"></div>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('button')">🔘 버튼</button>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('input')"> 입력필드</button>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('select')"> 셀렉트</button>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('card')"> 카드</button>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('badges')"> 뱃지</button>
<div class="sb-block-toolbar-sep"></div>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('image')">🖼 이미지</button>
<button class="sb-block-toolbar-btn" @click="sbAddBlock('divider')"> 구분선</button>
<input type="file" accept="image/*" x-ref="sbBlockImageInput" style="display:none;" @change="sbBlockUploadImage($event)">
</div>
<input type="file" accept="image/*" x-ref="sbImageInput" style="display:none;" @change="sbUploadImage($event)">
{{-- Block Editor Area --}}
<div class="sb-blocks-area" style="padding-left:32px;">
<template x-if="!sbPageBlocks.length">
<div class="sb-blk-empty-area" @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>
<span>블록을 추가하여 화면을 구성하세요</span>
<span style="font-size:10px; color:#cbd5e1;">상단 툴바에서 블록 유형을 선택하거나 여기를 클릭하세요</span>
</div>
</template>
<template x-if="sbCurrentPage.wireframeImage">
<div style="position:relative;">
<img class="sb-wireframe-img" :src="sbCurrentPage.wireframeImage" alt="wireframe">
<button style="position:absolute; top:4px; right:4px; width:24px; height:24px; border-radius:50%; border:none; background:rgba(0,0,0,0.5); color:#fff; cursor:pointer; font-size:12px;"
@click="sbCurrentPage.wireframeImage = ''; autoSave();">&times;</button>
<template x-for="(blk, bi) in sbPageBlocks" :key="blk.id">
<div class="sb-block" :class="{ selected: sbSelectedBlock === blk.id }" @click="sbSelectedBlock = blk.id"
draggable="true"
@dragstart="sbBlockDragStart(bi, $event)"
@dragover.prevent="sbBlockDragOver(bi, $event)"
@drop="sbBlockDrop(bi, $event)"
@dragend="sbBlockDragEnd()">
<span class="sb-block-handle"></span>
<div class="sb-block-actions">
<button class="sb-block-action-btn" @click.stop="sbDuplicateBlock(bi)" title="복제"></button>
<button class="sb-block-action-btn" @click.stop="sbMoveBlockUp(bi)" title="위로" x-show="bi > 0"></button>
<button class="sb-block-action-btn" @click.stop="sbMoveBlockDown(bi)" title="아래로" x-show="bi < sbPageBlocks.length - 1"></button>
<button class="sb-block-action-btn danger" @click.stop="sbRemoveBlock(bi)" title="삭제">×</button>
</div>
{{-- Text --}}
<template x-if="blk.type === 'text'">
<div class="sb-blk-text" contenteditable="true" data-placeholder="텍스트를 입력하세요..."
@blur="blk.content = $event.target.innerText; autoSave();"
@keydown.enter="if(!$event.shiftKey){ $event.preventDefault(); sbAddBlockAfter(bi,'text'); }"
x-text="blk.content"></div>
</template>
{{-- Heading --}}
<template x-if="blk.type === 'heading'">
<div class="sb-blk-heading" contenteditable="true"
@blur="blk.content = $event.target.innerText; autoSave();"
x-text="blk.content"></div>
</template>
<template x-if="blk.type === 'heading2'">
<div class="sb-blk-heading sb-blk-h2" contenteditable="true" data-placeholder="소제목을 입력하세요"
@blur="blk.content = $event.target.innerText; autoSave();"
x-text="blk.content"></div>
</template>
{{-- Divider --}}
<template x-if="blk.type === 'divider'">
<hr class="sb-blk-divider">
</template>
{{-- Callout --}}
<template x-if="blk.type === 'callout'">
<div class="sb-blk-callout">
<span class="sb-blk-callout-icon" contenteditable="true" @blur="blk.icon = $event.target.innerText; autoSave();" x-text="blk.icon || '💡'"></span>
<div class="sb-blk-callout-text" contenteditable="true"
@blur="blk.content = $event.target.innerText; autoSave();"
x-text="blk.content"></div>
</div>
</template>
<template x-if="sbCurrentPage.wireframeContent && !sbCurrentPage.wireframeImage">
<div>
<div class="sb-wireframe-content" contenteditable="true"
@blur="sbCurrentPage.wireframeContent = $event.target.innerHTML; autoSave();"
x-html="sbCurrentPage.wireframeContent"></div>
{{-- Table --}}
<template x-if="blk.type === 'table'">
<div class="sb-blk-table-wrap">
<table class="sb-blk-table">
<thead>
<tr>
<template x-for="(col, ci) in blk.cols" :key="ci">
<th contenteditable="true" @blur="blk.cols[ci] = $event.target.innerText; autoSave();" x-text="col"></th>
</template>
<th style="width:24px; background:transparent; border:none; padding:0;">
<button class="sb-block-action-btn" @click.stop="sbTableAddCol(blk)" style="width:20px; height:20px;">+</button>
</th>
</tr>
</thead>
<tbody>
<template x-for="(row, ri) in blk.rows" :key="ri">
<tr>
<template x-for="(cell, ci) in row" :key="ci">
<td contenteditable="true" @blur="blk.rows[ri][ci] = $event.target.innerText; autoSave();" x-text="cell"></td>
</template>
<td style="border:none; padding:0;">
<button class="sb-block-action-btn danger" @click.stop="blk.rows.splice(ri, 1); autoSave();" style="width:18px; height:18px; font-size:10px;">×</button>
</td>
</tr>
</template>
</tbody>
</table>
<div style="display:flex; gap:4px; margin-top:4px;">
<button style="font-size:10px; color:#94a3b8; cursor:pointer; border:none; background:none;"
@click="$refs.sbImageInput2?.click()">이미지로 교체</button>
<input type="file" accept="image/*" x-ref="sbImageInput2" style="display:none;" @change="sbUploadImage($event)">
<button class="sb-block-toolbar-btn" @click.stop="sbTableAddRow(blk)" style="font-size:9px;">+ 추가</button>
</div>
</div>
</template>
{{-- Mock: Button --}}
<template x-if="blk.type === 'button'">
<div class="sb-blk-mockup">
<div class="sb-blk-mockup-label">Button</div>
<span class="sb-blk-mock-btn" contenteditable="true"
:style="'background:' + (blk.color || '#4338ca') + '; color:#fff;'"
@blur="blk.content = $event.target.innerText; autoSave();"
x-text="blk.content || '버튼'"></span>
<div style="display:flex; gap:4px; margin-top:6px;">
<template x-for="c in ['#4338ca','#10b981','#ef4444','#f59e0b','#64748b']" :key="c">
<span @click="blk.color = c; autoSave();" style="width:16px; height:16px; border-radius:50%; cursor:pointer; display:inline-block;"
:style="'background:' + c + '; border:2px solid ' + (blk.color === c ? '#1e293b' : 'transparent')"></span>
</template>
</div>
</div>
</template>
{{-- Mock: Input --}}
<template x-if="blk.type === 'input'">
<div class="sb-blk-mockup">
<div class="sb-blk-mockup-label" contenteditable="true" @blur="blk.label = $event.target.innerText; autoSave();" x-text="blk.label || 'Label'"></div>
<input class="sb-blk-mock-input" type="text" :placeholder="blk.placeholder || '입력 필드'"
@input="blk.placeholder = $event.target.placeholder" disabled>
<input type="text" style="margin-top:4px; font-size:10px; border:none; outline:none; color:#94a3b8; width:100%;"
x-model="blk.placeholder" placeholder="placeholder 텍스트 편집" @input="autoSave()">
</div>
</template>
{{-- Mock: Select --}}
<template x-if="blk.type === 'select'">
<div class="sb-blk-mockup">
<div class="sb-blk-mockup-label" contenteditable="true" @blur="blk.label = $event.target.innerText; autoSave();" x-text="blk.label || 'Label'"></div>
<select class="sb-blk-mock-select" disabled>
<option x-text="blk.placeholder || '선택하세요'"></option>
</select>
<input type="text" style="margin-top:4px; font-size:10px; border:none; outline:none; color:#94a3b8; width:100%;"
x-model="blk.placeholder" placeholder="placeholder 텍스트 편집" @input="autoSave()">
</div>
</template>
{{-- Mock: Card --}}
<template x-if="blk.type === 'card'">
<div class="sb-blk-mockup">
<div class="sb-blk-mockup-label">Card</div>
<div class="sb-blk-mock-card">
<div class="sb-blk-mock-card-title" contenteditable="true"
@blur="blk.title = $event.target.innerText; autoSave();"
x-text="blk.title || '카드 제목'"></div>
<div class="sb-blk-mock-card-body" contenteditable="true"
@blur="blk.content = $event.target.innerText; autoSave();"
x-text="blk.content || '카드 내용을 입력하세요'"></div>
</div>
</div>
</template>
{{-- Badges --}}
<template x-if="blk.type === 'badges'">
<div class="sb-blk-mockup">
<div class="sb-blk-mockup-label">Badges</div>
<div class="sb-blk-badge-row">
<template x-for="(badge, bdi) in blk.items" :key="bdi">
<span class="sb-blk-badge" :style="'background:' + (badge.color || '#e0e7ff') + '; color:' + (badge.textColor || '#4338ca') + ';'"
contenteditable="true"
@blur="badge.text = $event.target.innerText; autoSave();"
x-text="badge.text"></span>
</template>
<button class="sb-block-action-btn" @click.stop="blk.items.push({ text: '뱃지', color: '#e0e7ff', textColor: '#4338ca' }); autoSave();">+</button>
</div>
</div>
</template>
{{-- Todo --}}
<template x-if="blk.type === 'todo'">
<div>
<template x-for="(item, ti) in blk.items" :key="ti">
<div class="sb-blk-todo">
<input type="checkbox" x-model="item.checked" @change="autoSave()">
<div class="sb-blk-todo-text" contenteditable="true"
:style="item.checked ? 'text-decoration: line-through; color:#94a3b8;' : ''"
@blur="item.text = $event.target.innerText; autoSave();"
@keydown.enter.prevent="blk.items.splice(ti+1, 0, { text: '', checked: false }); autoSave(); $nextTick(() => { $event.target.parentElement.nextElementSibling?.querySelector('[contenteditable]')?.focus(); })"
x-text="item.text"></div>
<button class="sb-block-action-btn danger" @click.stop="blk.items.splice(ti, 1); autoSave();" style="width:16px;height:16px;font-size:10px;">×</button>
</div>
</template>
<div style="padding:4px 8px;">
<button class="sb-block-toolbar-btn" @click.stop="blk.items.push({ text: '', checked: false }); autoSave();" style="font-size:9px;">+ 항목 추가</button>
</div>
</div>
</template>
{{-- Code --}}
<template x-if="blk.type === 'code'">
<div class="sb-blk-code" contenteditable="true"
@blur="blk.content = $event.target.innerText; autoSave();"
x-text="blk.content"></div>
</template>
{{-- Image --}}
<template x-if="blk.type === 'image'">
<div class="sb-blk-image-wrap">
<template x-if="blk.src">
<div style="position:relative; display:inline-block;">
<img :src="blk.src" style="max-width:100%; border-radius:6px;">
<button style="position:absolute; top:4px; right:4px; width:22px; height:22px; border-radius:50%; border:none; background:rgba(0,0,0,0.5); color:#fff; cursor:pointer; font-size:11px;"
@click.stop="blk.src = ''; autoSave();">×</button>
</div>
</template>
<template x-if="!blk.src">
<div class="sb-blk-image-placeholder" @click.stop="sbBlockImageTarget = blk; $refs.sbBlockImageInput.click();">
클릭하여 이미지 업로드
</div>
</template>
</div>
</template>
</div>
</template>
</div>
{{-- Description Panel --}}
@@ -1503,6 +1815,9 @@ function planningCanvas() {
sbMenuEditorOpen: false,
sbMenuDraft: [],
_sbMenuDrag: null,
sbSelectedBlock: null,
sbBlockImageTarget: null,
_sbBlockDragIdx: null,
sb: {
docInfo: { projectName: '', unitTask: '', version: 'D1.0' },
menuTree: [
@@ -1598,6 +1913,13 @@ function planningCanvas() {
return this.sb.pages[this.sb.currentPageIndex] || null;
},
get sbPageBlocks() {
const page = this.sbCurrentPage;
if (!page) return [];
if (!page.blocks) page.blocks = [];
return page.blocks;
},
get allPaletteItems() {
return [
...this.paletteItems.planning,
@@ -2193,6 +2515,7 @@ function planningCanvas() {
screenId: '',
wireframeContent: '',
wireframeImage: '',
blocks: [],
descriptions: [],
};
},
@@ -2230,20 +2553,139 @@ function planningCanvas() {
this.autoSave();
},
sbUploadImage(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const page = this.sbCurrentPage;
if (page) {
page.wireframeImage = reader.result;
page.wireframeContent = '';
this.autoSave();
// ===== Block Editor =====
sbNewBlock(type) {
const id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
const base = { id, type, content: '' };
switch (type) {
case 'heading': return { ...base };
case 'heading2': return { ...base };
case 'text': return { ...base };
case 'divider': return { ...base };
case 'callout': return { ...base, icon: '💡' };
case 'code': return { ...base };
case 'table': return { ...base, cols: ['항목', '내용', '비고'], rows: [['', '', ''], ['', '', '']] };
case 'button': return { ...base, content: '버튼', color: '#4338ca' };
case 'input': return { ...base, label: 'Label', placeholder: '값을 입력하세요' };
case 'select': return { ...base, label: 'Label', placeholder: '선택하세요' };
case 'card': return { ...base, title: '카드 제목', content: '카드 내용을 입력하세요' };
case 'badges': return { ...base, items: [
{ text: '신규', color: '#dbeafe', textColor: '#2563eb' },
{ text: '진행중', color: '#dcfce7', textColor: '#16a34a' },
{ text: '완료', color: '#e0e7ff', textColor: '#4338ca' },
]};
case 'todo': return { ...base, items: [
{ text: '항목 1', checked: false },
{ text: '항목 2', checked: false },
]};
case 'image': return { ...base, src: '' };
default: return base;
}
},
sbAddBlock(type) {
const page = this.sbCurrentPage;
if (!page) return;
if (!page.blocks) page.blocks = [];
const blk = this.sbNewBlock(type);
page.blocks.push(blk);
this.sbSelectedBlock = blk.id;
this.autoSave();
},
sbAddBlockAfter(idx, type) {
const page = this.sbCurrentPage;
if (!page || !page.blocks) return;
const blk = this.sbNewBlock(type);
page.blocks.splice(idx + 1, 0, blk);
this.sbSelectedBlock = blk.id;
this.autoSave();
},
sbRemoveBlock(idx) {
const page = this.sbCurrentPage;
if (!page || !page.blocks) return;
page.blocks.splice(idx, 1);
this.sbSelectedBlock = null;
this.autoSave();
},
sbDuplicateBlock(idx) {
const page = this.sbCurrentPage;
if (!page || !page.blocks) return;
const copy = JSON.parse(JSON.stringify(page.blocks[idx]));
copy.id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
page.blocks.splice(idx + 1, 0, copy);
this.sbSelectedBlock = copy.id;
this.autoSave();
},
sbMoveBlockUp(idx) {
const page = this.sbCurrentPage;
if (!page || idx <= 0) return;
const [item] = page.blocks.splice(idx, 1);
page.blocks.splice(idx - 1, 0, item);
this.autoSave();
},
sbMoveBlockDown(idx) {
const page = this.sbCurrentPage;
if (!page || idx >= page.blocks.length - 1) return;
const [item] = page.blocks.splice(idx, 1);
page.blocks.splice(idx + 1, 0, item);
this.autoSave();
},
sbBlockDragStart(idx, e) {
this._sbBlockDragIdx = idx;
e.dataTransfer.effectAllowed = 'move';
},
sbBlockDragOver(idx, e) {
if (this._sbBlockDragIdx === null) return;
e.dataTransfer.dropEffect = 'move';
},
sbBlockDrop(idx, e) {
if (this._sbBlockDragIdx === null) return;
const from = this._sbBlockDragIdx;
this._sbBlockDragIdx = null;
if (from === idx) return;
const page = this.sbCurrentPage;
if (!page) return;
const [item] = page.blocks.splice(from, 1);
page.blocks.splice(idx, 0, item);
this.autoSave();
},
sbBlockDragEnd() {
this._sbBlockDragIdx = null;
},
sbTableAddRow(blk) {
const colCount = blk.cols.length;
blk.rows.push(Array(colCount).fill(''));
this.autoSave();
},
sbTableAddCol(blk) {
blk.cols.push('컬럼');
blk.rows.forEach(row => row.push(''));
this.autoSave();
},
sbBlockUploadImage(e) {
const file = e.target.files[0];
if (!file || !this.sbBlockImageTarget) return;
const reader = new FileReader();
const target = this.sbBlockImageTarget;
reader.onload = () => {
target.src = reader.result;
this.autoSave();
};
reader.readAsDataURL(file);
e.target.value = '';
this.sbBlockImageTarget = null;
},
sbEditMenu() {
@@ -2365,9 +2807,15 @@ function planningCanvas() {
});
});
html += '</div><div class="content"><div class="wf">';
if (pg.wireframeImage) html += '<img src="' + pg.wireframeImage + '">';
else if (pg.wireframeContent) html += '<div class="wf-text">' + pg.wireframeContent + '</div>';
else html += '<div class="wf-text" style="color:#94a3b8;">와이어프레임 영역</div>';
if (pg.blocks && pg.blocks.length > 0) {
pg.blocks.forEach(blk => { html += this.sbExportBlock(blk); });
} else if (pg.wireframeImage) {
html += '<img src="' + pg.wireframeImage + '">';
} else if (pg.wireframeContent) {
html += '<div class="wf-text">' + pg.wireframeContent + '</div>';
} else {
html += '<div class="wf-text" style="color:#94a3b8;">와이어프레임 영역</div>';
}
html += '</div>';
if (pg.descriptions && pg.descriptions.length > 0) {
html += '<div class="desc"><div class="desc-title">Description</div>';
@@ -2390,6 +2838,51 @@ function planningCanvas() {
URL.revokeObjectURL(url);
},
sbExportBlock(blk) {
const esc = (s) => (s || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
switch (blk.type) {
case 'heading': return '<h2 style="font-size:18px;font-weight:700;color:#1e293b;margin:8px 0 4px;">' + esc(blk.content) + '</h2>';
case 'heading2': return '<h3 style="font-size:15px;font-weight:600;color:#1e293b;margin:6px 0 4px;">' + esc(blk.content) + '</h3>';
case 'text': return '<p style="font-size:13px;line-height:1.7;color:#334155;margin:4px 0;">' + esc(blk.content).replace(/\n/g, '<br>') + '</p>';
case 'divider': return '<hr style="border:none;border-top:1px solid #e2e8f0;margin:8px 0;">';
case 'callout': return '<div style="display:flex;gap:8px;padding:10px 12px;background:#eff6ff;border-radius:6px;border-left:3px solid #3b82f6;margin:4px 0;"><span style="font-size:16px;">' + esc(blk.icon || '💡') + '</span><span style="font-size:12px;line-height:1.6;color:#334155;">' + esc(blk.content) + '</span></div>';
case 'code': return '<pre style="padding:10px 12px;background:#1e293b;border-radius:6px;color:#e2e8f0;font-size:12px;line-height:1.6;margin:4px 0;">' + esc(blk.content) + '</pre>';
case 'table': {
let t = '<table style="width:100%;border-collapse:collapse;font-size:12px;margin:4px 0;"><thead><tr>';
(blk.cols || []).forEach(c => { t += '<th style="background:#f1f5f9;font-weight:600;color:#475569;text-align:left;border:1px solid #e2e8f0;padding:6px 8px;">' + esc(c) + '</th>'; });
t += '</tr></thead><tbody>';
(blk.rows || []).forEach(row => {
t += '<tr>';
row.forEach(cell => { t += '<td style="border:1px solid #e2e8f0;padding:6px 8px;">' + esc(cell) + '</td>'; });
t += '</tr>';
});
return t + '</tbody></table>';
}
case 'button': return '<div style="margin:4px 0;"><span style="display:inline-block;padding:6px 16px;border-radius:6px;font-size:12px;font-weight:600;background:' + (blk.color || '#4338ca') + ';color:#fff;">' + esc(blk.content) + '</span></div>';
case 'input': return '<div style="margin:4px 0;"><div style="font-size:9px;color:#94a3b8;font-weight:600;margin-bottom:2px;">' + esc(blk.label) + '</div><div style="padding:7px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:12px;color:#6b7280;background:#f9fafb;">' + esc(blk.placeholder) + '</div></div>';
case 'select': return '<div style="margin:4px 0;"><div style="font-size:9px;color:#94a3b8;font-weight:600;margin-bottom:2px;">' + esc(blk.label) + '</div><div style="padding:7px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:12px;color:#6b7280;background:#f9fafb;">' + esc(blk.placeholder) + ' ▾</div></div>';
case 'card': return '<div style="border:1px solid #e2e8f0;border-radius:8px;padding:12px;background:#fff;margin:4px 0;"><div style="font-size:13px;font-weight:600;color:#1e293b;margin-bottom:4px;">' + esc(blk.title) + '</div><div style="font-size:11px;color:#64748b;line-height:1.5;">' + esc(blk.content) + '</div></div>';
case 'badges': {
let b = '<div style="display:flex;gap:6px;flex-wrap:wrap;margin:4px 0;">';
(blk.items || []).forEach(badge => {
b += '<span style="padding:3px 10px;border-radius:12px;font-size:10px;font-weight:600;background:' + (badge.color || '#e0e7ff') + ';color:' + (badge.textColor || '#4338ca') + ';">' + esc(badge.text) + '</span>';
});
return b + '</div>';
}
case 'todo': {
let t = '<div style="margin:4px 0;">';
(blk.items || []).forEach(item => {
t += '<div style="display:flex;align-items:center;gap:6px;padding:2px 0;font-size:12px;">';
t += item.checked ? '☑' : '☐';
t += ' <span' + (item.checked ? ' style="text-decoration:line-through;color:#94a3b8;"' : '') + '>' + esc(item.text) + '</span></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>' : '';
default: return '<p>' + esc(blk.content) + '</p>';
}
},
// ===== Context Menu =====
showContextMenu(e) {
this.contextMenuPos = { x: e.clientX, y: e.clientY };