feat: [planning-design] 블록 서식 툴바 + 우클릭 컨텍스트 메뉴 추가
- 블록 선택 시 Notion 스타일 플로팅 서식 툴바 표시 - 글자색, 배경색, 글자 크기, 굵게, 기울임, 정렬 설정 - 앞/뒤로 보내기 (z-index), 서식 초기화 - 우클릭 컨텍스트 메뉴: 복제/잘라내기/삭제/서식/레이어 - 서브메뉴로 글자색/배경색 직접 선택 가능 - 블록별 style 속성 저장 (localStorage 영속) - HTML 내보내기/인쇄에 서식 반영
This commit is contained in:
@@ -712,6 +712,78 @@
|
||||
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 */
|
||||
.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; }
|
||||
@@ -1555,7 +1627,8 @@
|
||||
@mousedown="sbCanvasMouseDown($event)"
|
||||
@mousemove="sbCanvasMouseMove($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'"
|
||||
@drop.prevent="sbDropMarker($event)"
|
||||
style="min-height: 600px; flex: 1;">
|
||||
@@ -1572,9 +1645,20 @@
|
||||
<template x-for="(blk, bi) in sbPageBlocks" :key="blk.id">
|
||||
<div class="sb-block"
|
||||
: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)"
|
||||
@dblclick="sbEditingBlock = blk.id">
|
||||
@dblclick="sbEditingBlock = blk.id"
|
||||
@contextmenu.prevent="sbShowCtxMenu(blk, bi, $event)">
|
||||
<div class="sb-block-actions">
|
||||
<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>
|
||||
@@ -1777,6 +1861,168 @@
|
||||
</template>
|
||||
</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 --}}
|
||||
<div class="sb-desc-resizer"
|
||||
@mousedown.prevent="_sbDescResize = { startY: $event.clientY, startH: sbDescHeight }; $event.target.classList.add('active')"></div>
|
||||
@@ -2046,6 +2292,11 @@ function planningCanvas() {
|
||||
_sbHistory: [],
|
||||
_sbHistoryIdx: -1,
|
||||
_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,
|
||||
sbTplTab: 'preset',
|
||||
sbTplSearch: '',
|
||||
@@ -3125,6 +3376,8 @@ function planningCanvas() {
|
||||
// 단일 선택
|
||||
this.sbMultiSelected = [];
|
||||
this.sbSelectedBlock = blk.id;
|
||||
this.sbCtxMenu = null; // 컨텍스트 메뉴 닫기
|
||||
this.sbFmtDropdown = null;
|
||||
this._sbDrag = {
|
||||
blk,
|
||||
startX: e.clientX,
|
||||
@@ -3132,6 +3385,7 @@ function planningCanvas() {
|
||||
origX: blk.x || 0,
|
||||
origY: blk.y || 0,
|
||||
};
|
||||
this.sbUpdateFormatBar();
|
||||
e.preventDefault();
|
||||
},
|
||||
|
||||
@@ -3166,6 +3420,10 @@ function planningCanvas() {
|
||||
},
|
||||
|
||||
sbCanvasMouseMove(e) {
|
||||
// 드래그/리사이즈 중에는 서식 툴바 숨기기
|
||||
if (this._sbDrag || this._sbResize || this._sbMultiDrag || this._sbLasso) {
|
||||
this.sbFormatBar = null;
|
||||
}
|
||||
// 올가미 드래그
|
||||
if (this._sbLasso) {
|
||||
const rect = this.$refs.sbCanvas.getBoundingClientRect();
|
||||
@@ -3253,10 +3511,12 @@ function planningCanvas() {
|
||||
if (this._sbDrag) {
|
||||
this._sbDrag = null;
|
||||
this.autoSave();
|
||||
this.sbUpdateFormatBar();
|
||||
}
|
||||
if (this._sbResize) {
|
||||
this._sbResize = null;
|
||||
this.autoSave();
|
||||
this.sbUpdateFormatBar();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3391,6 +3651,172 @@ function planningCanvas() {
|
||||
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) {
|
||||
const colCount = blk.cols.length;
|
||||
blk.rows.push(Array(colCount).fill(''));
|
||||
@@ -3719,10 +4145,21 @@ function planningCanvas() {
|
||||
|
||||
sbExportBlock(blk) {
|
||||
const esc = (s) => (s || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
// 블록 스타일 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) {
|
||||
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 '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;' + sty + '">' + esc(blk.content) + '</h3>';
|
||||
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 '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>';
|
||||
|
||||
Reference in New Issue
Block a user