feat: [planning-design] 블록 서식 툴바 + 우클릭 컨텍스트 메뉴 추가

- 블록 선택 시 Notion 스타일 플로팅 서식 툴바 표시
- 글자색, 배경색, 글자 크기, 굵게, 기울임, 정렬 설정
- 앞/뒤로 보내기 (z-index), 서식 초기화
- 우클릭 컨텍스트 메뉴: 복제/잘라내기/삭제/서식/레이어
- 서브메뉴로 글자색/배경색 직접 선택 가능
- 블록별 style 속성 저장 (localStorage 영속)
- HTML 내보내기/인쇄에 서식 반영
This commit is contained in:
김보곤
2026-03-08 01:22:06 +09:00
parent ad98929978
commit 997ae6f46c

View File

@@ -712,6 +712,78 @@
pointer-events: none; z-index: 100; pointer-events: none; z-index: 100;
} }
/* Floating Format Toolbar (Notion-style) */
.sb-format-bar {
position: fixed; z-index: 9999;
display: flex; align-items: center; gap: 2px;
padding: 4px 6px; background: #1e293b; border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
animation: sbFormatFadeIn 0.12s ease;
}
@keyframes sbFormatFadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.sb-fmt-btn {
width: 28px; height: 28px; border: none; background: transparent; color: #94a3b8;
border-radius: 5px; cursor: pointer; font-size: 12px;
display: flex; align-items: center; justify-content: center; transition: all 0.1s;
position: relative;
}
.sb-fmt-btn:hover { background: rgba(255,255,255,0.12); color: #e2e8f0; }
.sb-fmt-btn.active { background: rgba(99,102,241,0.3); color: #a5b4fc; }
.sb-fmt-sep { width: 1px; height: 20px; background: rgba(255,255,255,0.12); margin: 0 2px; }
.sb-fmt-color-dot {
width: 14px; height: 14px; border-radius: 50%; border: 2px solid transparent;
cursor: pointer; transition: all 0.1s;
}
.sb-fmt-color-dot:hover { transform: scale(1.2); }
.sb-fmt-color-dot.active { border-color: #fff; }
.sb-fmt-dropdown {
position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
margin-top: 6px; background: #1e293b; border-radius: 8px; padding: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.3); min-width: 140px; z-index: 10;
}
.sb-fmt-dropdown-title { font-size: 9px; color: #64748b; font-weight: 700; text-transform: uppercase; margin-bottom: 6px; letter-spacing: 0.5px; }
.sb-fmt-color-grid { display: flex; flex-wrap: wrap; gap: 4px; }
.sb-fmt-size-option {
padding: 4px 8px; border-radius: 4px; font-size: 11px; color: #cbd5e1;
cursor: pointer; transition: all 0.1s; display: flex; align-items: center; justify-content: space-between;
}
.sb-fmt-size-option:hover { background: rgba(255,255,255,0.1); }
.sb-fmt-size-option.active { background: rgba(99,102,241,0.3); color: #a5b4fc; }
/* Right-Click Context Menu */
.sb-ctx-menu {
position: fixed; z-index: 10000;
background: #fff; border: 1px solid #e2e8f0; border-radius: 10px;
box-shadow: 0 8px 30px rgba(0,0,0,0.15); padding: 4px 0;
min-width: 200px; animation: sbCtxFadeIn 0.1s ease;
}
@keyframes sbCtxFadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
.sb-ctx-item {
display: flex; align-items: center; gap: 8px; padding: 7px 14px;
font-size: 12px; color: #334155; cursor: pointer; transition: background 0.08s;
}
.sb-ctx-item:hover { background: #f1f5f9; }
.sb-ctx-item.danger { color: #ef4444; }
.sb-ctx-item.danger:hover { background: #fef2f2; }
.sb-ctx-icon { width: 18px; text-align: center; font-size: 13px; flex-shrink: 0; }
.sb-ctx-label { flex: 1; }
.sb-ctx-shortcut { font-size: 10px; color: #94a3b8; }
.sb-ctx-sep { height: 1px; background: #e2e8f0; margin: 4px 0; }
.sb-ctx-sub { position: relative; }
.sb-ctx-sub-panel {
position: absolute; left: 100%; top: -4px;
background: #fff; border: 1px solid #e2e8f0; border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12); padding: 8px; min-width: 160px;
}
.sb-ctx-sub-panel .sb-ctx-item { padding: 5px 10px; }
.sb-ctx-color-grid { display: flex; flex-wrap: wrap; gap: 5px; padding: 4px; }
.sb-ctx-color-swatch {
width: 22px; height: 22px; border-radius: 5px; cursor: pointer;
border: 2px solid transparent; transition: all 0.1s;
}
.sb-ctx-color-swatch:hover { transform: scale(1.15); }
.sb-ctx-color-swatch.active { border-color: #4338ca; }
/* 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; }
.sb-blk-text:empty::before { content: attr(data-placeholder); color: #c8d0da; } .sb-blk-text:empty::before { content: attr(data-placeholder); color: #c8d0da; }
@@ -1555,7 +1627,8 @@
@mousedown="sbCanvasMouseDown($event)" @mousedown="sbCanvasMouseDown($event)"
@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 = []; sbFormatBar = null; sbCtxMenu = null; sbFmtDropdown = null; } _sbLassoDone = false;"
@contextmenu.self.prevent="sbCtxMenu = null; sbCtxSub = null;"
@dragover.prevent="$event.dataTransfer.dropEffect = 'copy'" @dragover.prevent="$event.dataTransfer.dropEffect = 'copy'"
@drop.prevent="sbDropMarker($event)" @drop.prevent="sbDropMarker($event)"
style="min-height: 600px; flex: 1;"> style="min-height: 600px; flex: 1;">
@@ -1572,9 +1645,20 @@
<template x-for="(blk, bi) in sbPageBlocks" :key="blk.id"> <template x-for="(blk, bi) in sbPageBlocks" :key="blk.id">
<div class="sb-block" <div class="sb-block"
:class="{ selected: sbSelectedBlock === blk.id, 'sb-multi-selected': sbMultiSelected.includes(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;'" :style="'left:' + (blk.x||0) + 'px; top:' + (blk.y||0) + 'px; width:' + (blk.w||240) + 'px; min-height:' + (blk.h||40) + 'px;'
+ (blk.style?.fontColor ? 'color:' + blk.style.fontColor + ';' : '')
+ (blk.style?.bgColor ? 'background:' + blk.style.bgColor + ';' : '')
+ (blk.style?.fontSize ? 'font-size:' + blk.style.fontSize + 'px;' : '')
+ (blk.style?.bold ? 'font-weight:700;' : '')
+ (blk.style?.italic ? 'font-style:italic;' : '')
+ (blk.style?.textAlign ? 'text-align:' + blk.style.textAlign + ';' : '')
+ (blk.style?.zIndex ? 'z-index:' + blk.style.zIndex + ';' : '')
+ (blk.style?.opacity ? 'opacity:' + blk.style.opacity + ';' : '')
+ (blk.style?.borderColor ? 'border-color:' + blk.style.borderColor + ';' : '')
+ (blk.style?.borderRadius ? 'border-radius:' + blk.style.borderRadius + 'px;' : '')"
@mousedown.stop="sbBlockMouseDown(blk, bi, $event)" @mousedown.stop="sbBlockMouseDown(blk, bi, $event)"
@dblclick="sbEditingBlock = blk.id"> @dblclick="sbEditingBlock = blk.id"
@contextmenu.prevent="sbShowCtxMenu(blk, bi, $event)">
<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 danger" @click.stop="sbRemoveBlock(bi)" title="삭제">×</button> <button class="sb-block-action-btn danger" @click.stop="sbRemoveBlock(bi)" title="삭제">×</button>
@@ -1777,6 +1861,168 @@
</template> </template>
</div> </div>
{{-- Floating Format Toolbar (블록 선택 위에 나타남) --}}
<template x-if="sbFormatBar && sbSelectedBlock">
<div class="sb-format-bar" :style="'left:' + sbFormatBar.x + 'px; top:' + sbFormatBar.y + 'px;'"
@mousedown.stop @click.stop>
{{-- Bold / Italic --}}
<button class="sb-fmt-btn" :class="{ active: sbGetBlk()?.style?.bold }"
@click="sbToggleStyle('bold')" title="굵게 (B)"><b>B</b></button>
<button class="sb-fmt-btn" :class="{ active: sbGetBlk()?.style?.italic }"
@click="sbToggleStyle('italic')" title="기울임 (I)"><i>I</i></button>
<div class="sb-fmt-sep"></div>
{{-- Text Align --}}
<button class="sb-fmt-btn" :class="{ active: !sbGetBlk()?.style?.textAlign || sbGetBlk()?.style?.textAlign === 'left' }"
@click="sbSetStyle('textAlign', 'left')" title="왼쪽 정렬"></button>
<button class="sb-fmt-btn" :class="{ active: sbGetBlk()?.style?.textAlign === 'center' }"
@click="sbSetStyle('textAlign', 'center')" title="가운데 정렬"></button>
<button class="sb-fmt-btn" :class="{ active: sbGetBlk()?.style?.textAlign === 'right' }"
@click="sbSetStyle('textAlign', 'right')" title="오른쪽 정렬"></button>
<div class="sb-fmt-sep"></div>
{{-- Font Color --}}
<div style="position:relative;">
<button class="sb-fmt-btn" @click.stop="sbFmtDropdown = sbFmtDropdown === 'color' ? null : 'color'" title="글자색">
<span style="font-weight:700;">A</span>
<span style="position:absolute;bottom:3px;left:6px;right:6px;height:3px;border-radius:1px;"
:style="'background:' + (sbGetBlk()?.style?.fontColor || '#334155')"></span>
</button>
<div class="sb-fmt-dropdown" x-show="sbFmtDropdown === 'color'" x-cloak @click.stop>
<div class="sb-fmt-dropdown-title">글자색</div>
<div class="sb-fmt-color-grid">
<template x-for="c in ['#1e293b','#334155','#64748b','#ef4444','#f97316','#eab308','#22c55e','#3b82f6','#8b5cf6','#ec4899','#06b6d4','#fff']" :key="'fc'+c">
<div class="sb-fmt-color-dot" :class="{ active: sbGetBlk()?.style?.fontColor === c }"
:style="'background:' + c + (c==='#fff' ? ';border:1px solid #e2e8f0' : '')"
@click="sbSetStyle('fontColor', c); sbFmtDropdown = null;"></div>
</template>
</div>
</div>
</div>
{{-- Background Color --}}
<div style="position:relative;">
<button class="sb-fmt-btn" @click.stop="sbFmtDropdown = sbFmtDropdown === 'bgColor' ? null : 'bgColor'" title="배경색">
<span style="width:16px;height:14px;border-radius:3px;display:inline-block;"
:style="'background:' + (sbGetBlk()?.style?.bgColor || '#fff') + ';border:1px solid #475569;'"></span>
</button>
<div class="sb-fmt-dropdown" x-show="sbFmtDropdown === 'bgColor'" x-cloak @click.stop>
<div class="sb-fmt-dropdown-title">배경색</div>
<div class="sb-fmt-color-grid">
<template x-for="c in ['#fff','#f8fafc','#f1f5f9','#fef2f2','#fff7ed','#fefce8','#ecfdf5','#eff6ff','#f5f3ff','#fdf2f8','#ecfeff','transparent']" :key="'bg'+c">
<div class="sb-fmt-color-dot"
:class="{ active: sbGetBlk()?.style?.bgColor === c }"
:style="'background:' + (c === 'transparent' ? 'repeating-conic-gradient(#ddd 0% 25%, #fff 0% 50%) 50%/12px 12px' : c) + ';border:1px solid #e2e8f0;'"
@click="sbSetStyle('bgColor', c === 'transparent' ? '' : c); sbFmtDropdown = null;"></div>
</template>
</div>
</div>
</div>
<div class="sb-fmt-sep"></div>
{{-- Font Size --}}
<div style="position:relative;">
<button class="sb-fmt-btn" @click.stop="sbFmtDropdown = sbFmtDropdown === 'fontSize' ? null : 'fontSize'" title="글자 크기"
style="width:auto;padding:0 6px;font-size:10px;color:#cbd5e1;">
<span x-text="(sbGetBlk()?.style?.fontSize || 13) + 'px'"></span>
</button>
<div class="sb-fmt-dropdown" x-show="sbFmtDropdown === 'fontSize'" x-cloak @click.stop style="min-width:100px;">
<div class="sb-fmt-dropdown-title">글자 크기</div>
<template x-for="s in [10,11,12,13,14,16,18,20,24]" :key="'fs'+s">
<div class="sb-fmt-size-option" :class="{ active: sbGetBlk()?.style?.fontSize === s }"
@click="sbSetStyle('fontSize', s); sbFmtDropdown = null;">
<span x-text="s + 'px'"></span>
<span style="font-size:9px;color:#64748b;" x-text="s <= 11 ? '작게' : s <= 13 ? '보통' : s <= 16 ? '크게' : '특대'"></span>
</div>
</template>
</div>
</div>
<div class="sb-fmt-sep"></div>
{{-- Layer (z-index) --}}
<button class="sb-fmt-btn" @click="sbBringForward()" title="앞으로 가져오기" style="font-size:10px;"></button>
<button class="sb-fmt-btn" @click="sbSendBackward()" title="뒤로 보내기" style="font-size:10px;"></button>
<div class="sb-fmt-sep"></div>
{{-- Reset --}}
<button class="sb-fmt-btn" @click="sbResetStyle()" title="서식 초기화" style="font-size:11px;"></button>
</div>
</template>
{{-- Right-Click Context Menu --}}
<template x-if="sbCtxMenu">
<div class="sb-ctx-menu" :style="'left:' + sbCtxMenu.x + 'px; top:' + sbCtxMenu.y + 'px;'"
@click.outside="sbCtxMenu = null; sbCtxSub = null;"
@contextmenu.prevent>
<div class="sb-ctx-item" @click="sbCtxCopy()">
<span class="sb-ctx-icon"></span><span class="sb-ctx-label">복제</span><span class="sb-ctx-shortcut">Ctrl+C V</span>
</div>
<div class="sb-ctx-item" @click="sbCtxCut()">
<span class="sb-ctx-icon"></span><span class="sb-ctx-label">잘라내기</span><span class="sb-ctx-shortcut">Ctrl+X</span>
</div>
<div class="sb-ctx-item danger" @click="sbCtxDelete()">
<span class="sb-ctx-icon">🗑</span><span class="sb-ctx-label">삭제</span><span class="sb-ctx-shortcut">Del</span>
</div>
<div class="sb-ctx-sep"></div>
{{-- 글자색 서브메뉴 --}}
<div class="sb-ctx-sub" @mouseenter="sbCtxSub = 'color'" @mouseleave="sbCtxSub = null">
<div class="sb-ctx-item">
<span class="sb-ctx-icon">🎨</span><span class="sb-ctx-label">글자색</span><span class="sb-ctx-shortcut"></span>
</div>
<div class="sb-ctx-sub-panel" x-show="sbCtxSub === 'color'" x-cloak>
<div class="sb-ctx-color-grid">
<template x-for="c in ['#1e293b','#334155','#64748b','#ef4444','#f97316','#eab308','#22c55e','#3b82f6','#8b5cf6','#ec4899','#06b6d4','#fff']" :key="'ctx-fc-'+c">
<div class="sb-ctx-color-swatch" :class="{ active: sbCtxGetBlk()?.style?.fontColor === c }"
:style="'background:' + c + (c==='#fff' ? ';border:1px solid #e2e8f0' : '')"
@click="sbCtxSetStyle('fontColor', c)"></div>
</template>
</div>
</div>
</div>
{{-- 배경색 서브메뉴 --}}
<div class="sb-ctx-sub" @mouseenter="sbCtxSub = 'bgColor'" @mouseleave="sbCtxSub = null">
<div class="sb-ctx-item">
<span class="sb-ctx-icon">🖌</span><span class="sb-ctx-label">배경색</span><span class="sb-ctx-shortcut"></span>
</div>
<div class="sb-ctx-sub-panel" x-show="sbCtxSub === 'bgColor'" x-cloak>
<div class="sb-ctx-color-grid">
<template x-for="c in ['#fff','#f8fafc','#f1f5f9','#fef2f2','#fff7ed','#fefce8','#ecfdf5','#eff6ff','#f5f3ff','#fdf2f8','#ecfeff','transparent']" :key="'ctx-bg-'+c">
<div class="sb-ctx-color-swatch"
:class="{ active: sbCtxGetBlk()?.style?.bgColor === c }"
:style="'background:' + (c === 'transparent' ? 'repeating-conic-gradient(#ddd 0% 25%, #fff 0% 50%) 50%/12px 12px' : c) + ';border:1px solid #e2e8f0;'"
@click="sbCtxSetStyle('bgColor', c === 'transparent' ? '' : c)"></div>
</template>
</div>
</div>
</div>
<div class="sb-ctx-sep"></div>
{{-- 정렬 --}}
<div class="sb-ctx-item" @click="sbCtxSetStyle('textAlign', 'left')">
<span class="sb-ctx-icon"></span><span class="sb-ctx-label">왼쪽 정렬</span>
</div>
<div class="sb-ctx-item" @click="sbCtxSetStyle('textAlign', 'center')">
<span class="sb-ctx-icon"></span><span class="sb-ctx-label">가운데 정렬</span>
</div>
<div class="sb-ctx-item" @click="sbCtxSetStyle('textAlign', 'right')">
<span class="sb-ctx-icon"></span><span class="sb-ctx-label">오른쪽 정렬</span>
</div>
<div class="sb-ctx-sep"></div>
{{-- 레이어 순서 --}}
<div class="sb-ctx-item" @click="sbCtxBringForward()">
<span class="sb-ctx-icon"></span><span class="sb-ctx-label">앞으로 가져오기</span>
</div>
<div class="sb-ctx-item" @click="sbCtxSendBackward()">
<span class="sb-ctx-icon"></span><span class="sb-ctx-label">뒤로 보내기</span>
</div>
<div class="sb-ctx-sep"></div>
{{-- 서식 --}}
<div class="sb-ctx-item" @click="sbCtxToggleStyle('bold')">
<span class="sb-ctx-icon"><b>B</b></span><span class="sb-ctx-label" x-text="sbCtxGetBlk()?.style?.bold ? '굵게 해제' : '굵게'"></span>
</div>
<div class="sb-ctx-item" @click="sbCtxToggleStyle('italic')">
<span class="sb-ctx-icon"><i>I</i></span><span class="sb-ctx-label" x-text="sbCtxGetBlk()?.style?.italic ? '기울임 해제' : '기울임'"></span>
</div>
<div class="sb-ctx-sep"></div>
<div class="sb-ctx-item" @click="sbCtxResetStyle()">
<span class="sb-ctx-icon"></span><span class="sb-ctx-label">서식 초기화</span>
</div>
</div>
</template>
{{-- Desc Resizer --}} {{-- Desc Resizer --}}
<div class="sb-desc-resizer" <div class="sb-desc-resizer"
@mousedown.prevent="_sbDescResize = { startY: $event.clientY, startH: sbDescHeight }; $event.target.classList.add('active')"></div> @mousedown.prevent="_sbDescResize = { startY: $event.clientY, startH: sbDescHeight }; $event.target.classList.add('active')"></div>
@@ -2046,6 +2292,11 @@ function planningCanvas() {
_sbHistory: [], _sbHistory: [],
_sbHistoryIdx: -1, _sbHistoryIdx: -1,
_sbHistoryPaused: false, _sbHistoryPaused: false,
// 서식 툴바 / 컨텍스트 메뉴
sbFormatBar: null, // { x, y } — 선택된 블록 위에 표시
sbFmtDropdown: null, // 'color' | 'bgColor' | 'fontSize' | null
sbCtxMenu: null, // { x, y, blockId, blockIdx } — 우클릭 메뉴
sbCtxSub: null, // 'color' | 'bgColor' | 'align' | null — 하위 메뉴
sbTplOpen: false, sbTplOpen: false,
sbTplTab: 'preset', sbTplTab: 'preset',
sbTplSearch: '', sbTplSearch: '',
@@ -3125,6 +3376,8 @@ function planningCanvas() {
// 단일 선택 // 단일 선택
this.sbMultiSelected = []; this.sbMultiSelected = [];
this.sbSelectedBlock = blk.id; this.sbSelectedBlock = blk.id;
this.sbCtxMenu = null; // 컨텍스트 메뉴 닫기
this.sbFmtDropdown = null;
this._sbDrag = { this._sbDrag = {
blk, blk,
startX: e.clientX, startX: e.clientX,
@@ -3132,6 +3385,7 @@ function planningCanvas() {
origX: blk.x || 0, origX: blk.x || 0,
origY: blk.y || 0, origY: blk.y || 0,
}; };
this.sbUpdateFormatBar();
e.preventDefault(); e.preventDefault();
}, },
@@ -3166,6 +3420,10 @@ function planningCanvas() {
}, },
sbCanvasMouseMove(e) { sbCanvasMouseMove(e) {
// 드래그/리사이즈 중에는 서식 툴바 숨기기
if (this._sbDrag || this._sbResize || this._sbMultiDrag || this._sbLasso) {
this.sbFormatBar = null;
}
// 올가미 드래그 // 올가미 드래그
if (this._sbLasso) { if (this._sbLasso) {
const rect = this.$refs.sbCanvas.getBoundingClientRect(); const rect = this.$refs.sbCanvas.getBoundingClientRect();
@@ -3253,10 +3511,12 @@ function planningCanvas() {
if (this._sbDrag) { if (this._sbDrag) {
this._sbDrag = null; this._sbDrag = null;
this.autoSave(); this.autoSave();
this.sbUpdateFormatBar();
} }
if (this._sbResize) { if (this._sbResize) {
this._sbResize = null; this._sbResize = null;
this.autoSave(); this.autoSave();
this.sbUpdateFormatBar();
} }
}, },
@@ -3391,6 +3651,172 @@ function planningCanvas() {
this.autoSave(); this.autoSave();
}, },
// ===== Format Toolbar & Context Menu =====
sbGetBlk() {
const page = this.sbCurrentPage;
if (!page || !page.blocks) return null;
return page.blocks.find(b => b.id === this.sbSelectedBlock) || null;
},
sbCtxGetBlk() {
if (!this.sbCtxMenu) return null;
const page = this.sbCurrentPage;
if (!page || !page.blocks) return null;
return page.blocks.find(b => b.id === this.sbCtxMenu.blockId) || null;
},
sbUpdateFormatBar() {
if (!this.sbSelectedBlock || this.sbMultiSelected.length > 0) {
this.sbFormatBar = null;
return;
}
this.$nextTick(() => {
const canvas = this.$refs.sbCanvas;
if (!canvas) return;
const blockEl = canvas.querySelector('.sb-block.selected');
if (!blockEl) { this.sbFormatBar = null; return; }
const rect = blockEl.getBoundingClientRect();
// 블록 위 중앙에 배치
const barW = 440;
let x = rect.left + rect.width / 2 - barW / 2;
let y = rect.top - 44;
// 화면 밖 보정
if (x < 8) x = 8;
if (x + barW > window.innerWidth - 8) x = window.innerWidth - barW - 8;
if (y < 8) y = rect.bottom + 8; // 위에 공간 없으면 아래에 표시
this.sbFormatBar = { x, y };
});
},
sbShowCtxMenu(blk, bi, e) {
this.sbSelectedBlock = blk.id;
this.sbMultiSelected = [];
this.sbFormatBar = null;
this.sbFmtDropdown = null;
// 화면 밖 보정
let x = e.clientX, y = e.clientY;
const mw = 220, mh = 480;
if (x + mw > window.innerWidth) x = window.innerWidth - mw - 8;
if (y + mh > window.innerHeight) y = window.innerHeight - mh - 8;
this.sbCtxMenu = { x, y, blockId: blk.id, blockIdx: bi };
this.sbCtxSub = null;
},
sbEnsureStyle(blk) {
if (!blk) return;
if (!blk.style) blk.style = {};
},
sbSetStyle(key, value) {
const blk = this.sbGetBlk();
if (!blk) return;
this.sbPushHistory();
this.sbEnsureStyle(blk);
blk.style[key] = value;
this.autoSave();
},
sbToggleStyle(key) {
const blk = this.sbGetBlk();
if (!blk) return;
this.sbPushHistory();
this.sbEnsureStyle(blk);
blk.style[key] = !blk.style[key];
this.autoSave();
},
sbResetStyle() {
const blk = this.sbGetBlk();
if (!blk) return;
this.sbPushHistory();
blk.style = {};
this.sbFmtDropdown = null;
this.autoSave();
},
sbBringForward() {
const page = this.sbCurrentPage;
const blk = this.sbGetBlk();
if (!page || !blk) return;
this.sbPushHistory();
this.sbEnsureStyle(blk);
blk.style.zIndex = (blk.style.zIndex || 10) + 1;
this.autoSave();
},
sbSendBackward() {
const page = this.sbCurrentPage;
const blk = this.sbGetBlk();
if (!page || !blk) return;
this.sbPushHistory();
this.sbEnsureStyle(blk);
blk.style.zIndex = Math.max(1, (blk.style.zIndex || 10) - 1);
this.autoSave();
},
// Context menu actions
sbCtxCopy() {
this.sbSelectedBlock = this.sbCtxMenu.blockId;
this.sbCopyBlock();
this.sbPasteBlock();
this.sbCtxMenu = null;
},
sbCtxCut() {
this.sbSelectedBlock = this.sbCtxMenu.blockId;
this.sbCutBlock();
this.sbCtxMenu = null;
},
sbCtxDelete() {
this.sbSelectedBlock = this.sbCtxMenu.blockId;
this.sbDeleteSelectedBlock();
this.sbCtxMenu = null;
},
sbCtxSetStyle(key, value) {
const blk = this.sbCtxGetBlk();
if (!blk) return;
this.sbPushHistory();
this.sbEnsureStyle(blk);
blk.style[key] = value;
this.sbCtxMenu = null;
this.sbCtxSub = null;
this.autoSave();
},
sbCtxToggleStyle(key) {
const blk = this.sbCtxGetBlk();
if (!blk) return;
this.sbPushHistory();
this.sbEnsureStyle(blk);
blk.style[key] = !blk.style[key];
this.sbCtxMenu = null;
this.autoSave();
},
sbCtxResetStyle() {
const blk = this.sbCtxGetBlk();
if (!blk) return;
this.sbPushHistory();
blk.style = {};
this.sbCtxMenu = null;
this.autoSave();
},
sbCtxBringForward() {
const blk = this.sbCtxGetBlk();
if (!blk) return;
this.sbPushHistory();
this.sbEnsureStyle(blk);
blk.style.zIndex = (blk.style.zIndex || 10) + 1;
this.sbCtxMenu = null;
this.autoSave();
},
sbCtxSendBackward() {
const blk = this.sbCtxGetBlk();
if (!blk) return;
this.sbPushHistory();
this.sbEnsureStyle(blk);
blk.style.zIndex = Math.max(1, (blk.style.zIndex || 10) - 1);
this.sbCtxMenu = null;
this.autoSave();
},
sbTableAddRow(blk) { sbTableAddRow(blk) {
const colCount = blk.cols.length; const colCount = blk.cols.length;
blk.rows.push(Array(colCount).fill('')); blk.rows.push(Array(colCount).fill(''));
@@ -3719,10 +4145,21 @@ function planningCanvas() {
sbExportBlock(blk) { sbExportBlock(blk) {
const esc = (s) => (s || '').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const esc = (s) => (s || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// 블록 스타일 CSS 문자열 생성
let sty = '';
if (blk.style) {
if (blk.style.fontColor) sty += 'color:' + blk.style.fontColor + ';';
if (blk.style.bgColor) sty += 'background:' + blk.style.bgColor + ';';
if (blk.style.fontSize) sty += 'font-size:' + blk.style.fontSize + 'px;';
if (blk.style.bold) sty += 'font-weight:700;';
if (blk.style.italic) sty += 'font-style:italic;';
if (blk.style.textAlign) sty += 'text-align:' + blk.style.textAlign + ';';
}
const wrapStyle = sty ? ' style="' + sty + '"' : '';
switch (blk.type) { 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 'heading': return '<h2 style="font-size:18px;font-weight:700;color:#1e293b;margin:8px 0 4px;' + sty + '">' + 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 'heading2': return '<h3 style="font-size:15px;font-weight:600;color:#1e293b;margin:6px 0 4px;' + sty + '">' + 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 'text': return '<p style="font-size:13px;line-height:1.7;color:#334155;margin:4px 0;' + sty + '">' + esc(blk.content).replace(/\n/g, '<br>') + '</p>';
case 'divider': return '<hr style="border:none;border-top:1px solid #e2e8f0;margin:8px 0;">'; 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 '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 '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>';