feat: [planning-design] 블록 자유 배치 캔버스 (PPT 스타일)
- 블록을 드래그하여 자유롭게 위치 이동 - 오른쪽/아래/대각선 리사이즈 핸들로 크기 조절 - 더블클릭으로 편집 모드 진입 - 그리드 도트 배경으로 위치 인지 용이 - 선택 시 크기 표시 (w × h) - 블록 기본 크기를 유형별로 최적화 - 템플릿 삽입 시 자동 세로 배치
This commit is contained in:
@@ -634,39 +634,49 @@
|
|||||||
}
|
}
|
||||||
.sb-block-toolbar-btn:hover { border-color: var(--pc-indigo); color: var(--pc-indigo); background: #eef2ff; }
|
.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-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-blocks-area {
|
||||||
|
flex: 1; position: relative; overflow: auto; min-height: 400px;
|
||||||
|
background: #fff url("data:image/svg+xml,%3Csvg width='20' height='20' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='1' cy='1' r='.5' fill='%23e2e8f0'/%3E%3C/svg%3E") repeat;
|
||||||
|
}
|
||||||
.sb-block {
|
.sb-block {
|
||||||
position: relative; border: 1.5px solid transparent; border-radius: 6px;
|
position: absolute; border: 1.5px solid transparent; border-radius: 6px;
|
||||||
margin-bottom: 2px; padding: 0; transition: border-color .12s;
|
padding: 0; transition: border-color .1s, box-shadow .1s;
|
||||||
group: true;
|
background: #fff; cursor: move; user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sb-block:hover { border-color: #c7d2fe; }
|
.sb-block:hover { border-color: #c7d2fe; box-shadow: 0 2px 8px rgba(99,102,241,0.08); }
|
||||||
.sb-block.selected { border-color: var(--pc-indigo); background: #fafafe; }
|
.sb-block.selected { border-color: var(--pc-indigo); box-shadow: 0 0 0 2px rgba(99,102,241,0.15); }
|
||||||
.sb-block-handle {
|
.sb-block.sb-block-editing { cursor: default; user-select: text; overflow: visible; }
|
||||||
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 {
|
.sb-block-actions {
|
||||||
position: absolute; right: 4px; top: 4px; display: flex; gap: 2px;
|
position: absolute; right: 4px; top: -26px; display: flex; gap: 2px;
|
||||||
opacity: 0; transition: opacity .12s;
|
opacity: 0; transition: opacity .12s; z-index: 10;
|
||||||
|
background: #fff; border: 1px solid #e2e8f0; border-radius: 6px; padding: 2px 4px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||||
}
|
}
|
||||||
.sb-block:hover .sb-block-actions { opacity: 1; }
|
.sb-block:hover .sb-block-actions, .sb-block.selected .sb-block-actions { opacity: 1; }
|
||||||
.sb-block-action-btn {
|
.sb-block-action-btn {
|
||||||
width: 20px; height: 20px; border: none; background: #f1f5f9; color: #94a3b8;
|
width: 20px; height: 20px; border: none; background: transparent; color: #94a3b8;
|
||||||
border-radius: 4px; cursor: pointer; font-size: 11px; display: flex;
|
border-radius: 4px; cursor: pointer; font-size: 11px; display: flex;
|
||||||
align-items: center; justify-content: center;
|
align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
.sb-block-action-btn:hover { background: #e2e8f0; color: #475569; }
|
.sb-block-action-btn:hover { background: #f1f5f9; color: #475569; }
|
||||||
.sb-block-action-btn.danger:hover { background: #fef2f2; color: #ef4444; }
|
.sb-block-action-btn.danger:hover { background: #fef2f2; color: #ef4444; }
|
||||||
.sb-block-drop-indicator {
|
/* Resize handles */
|
||||||
height: 3px; background: var(--pc-indigo); border-radius: 2px; margin: -2px 0;
|
.sb-resize-handle {
|
||||||
transition: opacity .12s; opacity: 0;
|
position: absolute; background: #fff; border: 1.5px solid var(--pc-indigo);
|
||||||
|
border-radius: 2px; z-index: 5; opacity: 0; transition: opacity .1s;
|
||||||
}
|
}
|
||||||
.sb-block-drop-indicator.active { opacity: 1; }
|
.sb-block.selected .sb-resize-handle { opacity: 1; }
|
||||||
|
.sb-resize-handle.sb-rh-r { width: 6px; height: 20px; right: -4px; top: 50%; transform: translateY(-50%); cursor: e-resize; }
|
||||||
|
.sb-resize-handle.sb-rh-b { width: 20px; height: 6px; bottom: -4px; left: 50%; transform: translateX(-50%); cursor: s-resize; }
|
||||||
|
.sb-resize-handle.sb-rh-br { width: 10px; height: 10px; right: -5px; bottom: -5px; cursor: se-resize; border-radius: 3px; }
|
||||||
|
/* Size label */
|
||||||
|
.sb-block-size {
|
||||||
|
position: absolute; bottom: -20px; left: 50%; transform: translateX(-50%);
|
||||||
|
font-size: 9px; color: #94a3b8; white-space: nowrap; pointer-events: none;
|
||||||
|
opacity: 0; transition: opacity .1s;
|
||||||
|
}
|
||||||
|
.sb-block.selected .sb-block-size { opacity: 1; }
|
||||||
|
|
||||||
/* Block type styles */
|
/* 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 { padding: 6px 8px; font-size: 13px; line-height: 1.7; min-height: 24px; outline: none; color: #334155; }
|
||||||
@@ -1484,29 +1494,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Block Editor Area --}}
|
{{-- Block Editor Area (Free Canvas) --}}
|
||||||
<div class="sb-blocks-area" style="padding-left:32px;">
|
<div class="sb-blocks-area" x-ref="sbCanvas"
|
||||||
|
@mousedown="sbCanvasMouseDown($event)"
|
||||||
|
@mousemove="sbCanvasMouseMove($event)"
|
||||||
|
@mouseup="sbCanvasMouseUp($event)"
|
||||||
|
@click.self="sbSelectedBlock = null"
|
||||||
|
style="min-height: 600px;">
|
||||||
<template x-if="!sbPageBlocks.length">
|
<template x-if="!sbPageBlocks.length">
|
||||||
<div class="sb-blk-empty-area" @click="sbAddBlock('text')">
|
<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>
|
<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>블록을 추가하여 화면을 구성하세요</span>
|
||||||
<span style="font-size:10px; color:#cbd5e1;">상단 툴바에서 블록 유형을 선택하거나 여기를 클릭하세요</span>
|
<span style="font-size:10px; color:#cbd5e1;">상단 툴바에서 블록 유형을 선택하거나 여기를 클릭하세요</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-for="(blk, bi) in sbPageBlocks" :key="blk.id">
|
<template x-for="(blk, bi) in sbPageBlocks" :key="blk.id">
|
||||||
<div class="sb-block" :class="{ selected: sbSelectedBlock === blk.id }" @click="sbSelectedBlock = blk.id"
|
<div class="sb-block"
|
||||||
draggable="true"
|
:class="{ selected: sbSelectedBlock === blk.id, 'sb-block-editing': sbEditingBlock === blk.id }"
|
||||||
@dragstart="sbBlockDragStart(bi, $event)"
|
:style="'left:' + (blk.x||0) + 'px; top:' + (blk.y||0) + 'px; width:' + (blk.w||240) + 'px; min-height:' + (blk.h||40) + 'px;'"
|
||||||
@dragover.prevent="sbBlockDragOver(bi, $event)"
|
@mousedown.stop="sbBlockMouseDown(blk, bi, $event)"
|
||||||
@drop="sbBlockDrop(bi, $event)"
|
@dblclick="sbEditingBlock = blk.id">
|
||||||
@dragend="sbBlockDragEnd()">
|
|
||||||
<span class="sb-block-handle">⠿</span>
|
|
||||||
<div class="sb-block-actions">
|
<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="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>
|
<button class="sb-block-action-btn danger" @click.stop="sbRemoveBlock(bi)" title="삭제">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
{{-- Resize handles --}}
|
||||||
|
<div class="sb-resize-handle sb-rh-r" @mousedown.stop="sbResizeStart(blk, 'r', $event)"></div>
|
||||||
|
<div class="sb-resize-handle sb-rh-b" @mousedown.stop="sbResizeStart(blk, 'b', $event)"></div>
|
||||||
|
<div class="sb-resize-handle sb-rh-br" @mousedown.stop="sbResizeStart(blk, 'br', $event)"></div>
|
||||||
|
<div class="sb-block-size" x-text="(blk.w||240) + ' × ' + (blk.h||40)"></div>
|
||||||
|
|
||||||
{{-- Text --}}
|
{{-- Text --}}
|
||||||
<template x-if="blk.type === 'text'">
|
<template x-if="blk.type === 'text'">
|
||||||
@@ -1940,8 +1956,11 @@ function planningCanvas() {
|
|||||||
sbMenuDraft: [],
|
sbMenuDraft: [],
|
||||||
_sbMenuDrag: null,
|
_sbMenuDrag: null,
|
||||||
sbSelectedBlock: null,
|
sbSelectedBlock: null,
|
||||||
|
sbEditingBlock: null,
|
||||||
sbBlockImageTarget: null,
|
sbBlockImageTarget: null,
|
||||||
_sbBlockDragIdx: null,
|
_sbBlockDragIdx: null,
|
||||||
|
_sbDrag: null, // { blk, startX, startY, origX, origY }
|
||||||
|
_sbResize: null, // { blk, dir, startX, startY, origW, origH }
|
||||||
sbTplOpen: false,
|
sbTplOpen: false,
|
||||||
sbTplTab: 'preset',
|
sbTplTab: 'preset',
|
||||||
sbTplSearch: '',
|
sbTplSearch: '',
|
||||||
@@ -2829,7 +2848,16 @@ function planningCanvas() {
|
|||||||
// ===== Block Editor =====
|
// ===== Block Editor =====
|
||||||
sbNewBlock(type) {
|
sbNewBlock(type) {
|
||||||
const id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
|
const id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
|
||||||
const base = { id, type, content: '' };
|
// 자동 배치: 기존 블록 아래에 겹치지 않게
|
||||||
|
const page = this.sbCurrentPage;
|
||||||
|
let autoY = 16;
|
||||||
|
if (page && page.blocks && page.blocks.length > 0) {
|
||||||
|
const maxBottom = Math.max(...page.blocks.map(b => (b.y || 0) + (b.h || 40)));
|
||||||
|
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 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 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 };
|
||||||
case 'heading2': return { ...base };
|
case 'heading2': return { ...base };
|
||||||
@@ -2888,51 +2916,78 @@ function planningCanvas() {
|
|||||||
if (!page || !page.blocks) return;
|
if (!page || !page.blocks) return;
|
||||||
const copy = JSON.parse(JSON.stringify(page.blocks[idx]));
|
const copy = JSON.parse(JSON.stringify(page.blocks[idx]));
|
||||||
copy.id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
|
copy.id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
|
||||||
|
copy.x = (copy.x || 0) + 20;
|
||||||
|
copy.y = (copy.y || 0) + 20;
|
||||||
page.blocks.splice(idx + 1, 0, copy);
|
page.blocks.splice(idx + 1, 0, copy);
|
||||||
this.sbSelectedBlock = copy.id;
|
this.sbSelectedBlock = copy.id;
|
||||||
this.autoSave();
|
this.autoSave();
|
||||||
},
|
},
|
||||||
|
|
||||||
sbMoveBlockUp(idx) {
|
// ===== Free Canvas: Drag & Resize =====
|
||||||
const page = this.sbCurrentPage;
|
sbBlockMouseDown(blk, bi, e) {
|
||||||
if (!page || idx <= 0) return;
|
// 더블클릭 편집 모드에서는 드래그 안 함
|
||||||
const [item] = page.blocks.splice(idx, 1);
|
if (this.sbEditingBlock === blk.id) return;
|
||||||
page.blocks.splice(idx - 1, 0, item);
|
this.sbSelectedBlock = blk.id;
|
||||||
this.autoSave();
|
this._sbDrag = {
|
||||||
|
blk,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
origX: blk.x || 0,
|
||||||
|
origY: blk.y || 0,
|
||||||
|
};
|
||||||
|
e.preventDefault();
|
||||||
},
|
},
|
||||||
|
|
||||||
sbMoveBlockDown(idx) {
|
sbResizeStart(blk, dir, e) {
|
||||||
const page = this.sbCurrentPage;
|
this.sbSelectedBlock = blk.id;
|
||||||
if (!page || idx >= page.blocks.length - 1) return;
|
this._sbResize = {
|
||||||
const [item] = page.blocks.splice(idx, 1);
|
blk,
|
||||||
page.blocks.splice(idx + 1, 0, item);
|
dir,
|
||||||
this.autoSave();
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
origW: blk.w || 240,
|
||||||
|
origH: blk.h || 40,
|
||||||
|
};
|
||||||
|
e.preventDefault();
|
||||||
},
|
},
|
||||||
|
|
||||||
sbBlockDragStart(idx, e) {
|
sbCanvasMouseDown(e) {
|
||||||
this._sbBlockDragIdx = idx;
|
// 빈 캔버스 클릭 시 편집 모드 해제
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
if (e.target === this.$refs.sbCanvas) {
|
||||||
|
this.sbEditingBlock = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sbBlockDragOver(idx, e) {
|
sbCanvasMouseMove(e) {
|
||||||
if (this._sbBlockDragIdx === null) return;
|
if (this._sbDrag) {
|
||||||
e.dataTransfer.dropEffect = 'move';
|
const d = this._sbDrag;
|
||||||
|
const dx = e.clientX - d.startX;
|
||||||
|
const dy = e.clientY - d.startY;
|
||||||
|
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;
|
||||||
|
const dy = e.clientY - r.startY;
|
||||||
|
if (r.dir === 'r' || r.dir === 'br') {
|
||||||
|
r.blk.w = Math.max(60, r.origW + dx);
|
||||||
|
}
|
||||||
|
if (r.dir === 'b' || r.dir === 'br') {
|
||||||
|
r.blk.h = Math.max(24, r.origH + dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sbBlockDrop(idx, e) {
|
sbCanvasMouseUp(e) {
|
||||||
if (this._sbBlockDragIdx === null) return;
|
if (this._sbDrag) {
|
||||||
const from = this._sbBlockDragIdx;
|
this._sbDrag = null;
|
||||||
this._sbBlockDragIdx = null;
|
this.autoSave();
|
||||||
if (from === idx) return;
|
}
|
||||||
const page = this.sbCurrentPage;
|
if (this._sbResize) {
|
||||||
if (!page) return;
|
this._sbResize = null;
|
||||||
const [item] = page.blocks.splice(from, 1);
|
this.autoSave();
|
||||||
page.blocks.splice(idx, 0, item);
|
}
|
||||||
this.autoSave();
|
|
||||||
},
|
|
||||||
|
|
||||||
sbBlockDragEnd() {
|
|
||||||
this._sbBlockDragIdx = null;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
sbTableAddRow(blk) {
|
sbTableAddRow(blk) {
|
||||||
@@ -2967,8 +3022,19 @@ function planningCanvas() {
|
|||||||
if (!page) return;
|
if (!page) return;
|
||||||
if (!page.blocks) page.blocks = [];
|
if (!page.blocks) page.blocks = [];
|
||||||
const blocks = JSON.parse(JSON.stringify(tpl.blocks));
|
const blocks = JSON.parse(JSON.stringify(tpl.blocks));
|
||||||
|
// 기존 블록 아래에 배치
|
||||||
|
let curY = 16;
|
||||||
|
if (page.blocks.length > 0) {
|
||||||
|
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 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 };
|
||||||
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.y === undefined) { blk.y = curY; curY += (blk.h || defH[blk.type] || 40) + 8; }
|
||||||
|
if (blk.w === undefined) blk.w = defW[blk.type] || 240;
|
||||||
|
if (blk.h === undefined) blk.h = defH[blk.type] || 40;
|
||||||
});
|
});
|
||||||
page.blocks.push(...blocks);
|
page.blocks.push(...blocks);
|
||||||
this.autoSave();
|
this.autoSave();
|
||||||
|
|||||||
Reference in New Issue
Block a user