Files
sam-manage/resources/views/rd/planning-design/index.blade.php
김보곤 997ae6f46c feat: [planning-design] 블록 서식 툴바 + 우클릭 컨텍스트 메뉴 추가
- 블록 선택 시 Notion 스타일 플로팅 서식 툴바 표시
- 글자색, 배경색, 글자 크기, 굵게, 기울임, 정렬 설정
- 앞/뒤로 보내기 (z-index), 서식 초기화
- 우클릭 컨텍스트 메뉴: 복제/잘라내기/삭제/서식/레이어
- 서브메뉴로 글자색/배경색 직접 선택 가능
- 블록별 style 속성 저장 (localStorage 영속)
- HTML 내보내기/인쇄에 서식 반영
2026-03-08 01:22:28 +09:00

4343 lines
232 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@extends('layouts.app')
@section('title', '기획디자인')
@section('content')
<style>
/* ===== Planning Canvas Core ===== */
:root {
--pc-sidebar: 280px;
--pc-toolbar: 48px;
--pc-blue: #3b82f6;
--pc-indigo: #6366f1;
--pc-violet: #8b5cf6;
--pc-emerald: #10b981;
--pc-amber: #f59e0b;
--pc-rose: #f43f5e;
--pc-slate: #64748b;
}
.pc-wrap {
display: flex;
flex-direction: column;
height: calc(100vh - 64px);
background: #f8fafc;
overflow: hidden;
}
/* Top Toolbar */
.pc-toolbar {
height: var(--pc-toolbar);
background: #1e293b;
display: flex;
align-items: center;
padding: 0 12px;
gap: 4px;
flex-shrink: 0;
z-index: 30;
}
.pc-toolbar-group {
display: flex;
align-items: center;
gap: 2px;
padding: 0 8px;
border-right: 1px solid rgba(255,255,255,0.1);
}
.pc-toolbar-group:last-child { border-right: none; }
.pc-tb-btn {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border-radius: 6px; border: none; background: transparent;
color: #94a3b8; cursor: pointer; transition: all 0.15s;
font-size: 11px;
}
.pc-tb-btn:hover { background: rgba(255,255,255,0.1); color: #e2e8f0; }
.pc-tb-btn.active { background: var(--pc-blue); color: #fff; }
.pc-tb-btn svg { width: 16px; height: 16px; }
.pc-tb-title {
flex: 1; text-align: center;
font-size: 13px; font-weight: 600; color: #e2e8f0;
letter-spacing: -0.3px;
}
.pc-tb-title input {
background: transparent; border: none; color: #e2e8f0;
font-size: 13px; font-weight: 600; text-align: center;
outline: none; width: 300px;
}
.pc-tb-title input:focus { border-bottom: 1px solid var(--pc-blue); }
.pc-tb-badge {
font-size: 10px; padding: 2px 6px; border-radius: 9999px;
background: rgba(99,102,241,0.2); color: #a5b4fc; font-weight: 500;
}
.pc-tb-sep { width: 1px; height: 24px; background: rgba(255,255,255,0.1); margin: 0 4px; }
/* Body: Sidebar + Canvas */
.pc-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Left Sidebar — Node Palette + Properties */
.pc-sidebar {
width: var(--pc-sidebar);
background: #fff;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
transition: width 0.2s;
}
.pc-sidebar.collapsed { width: 0; border-right: none; }
.pc-sidebar-tabs {
display: flex; border-bottom: 1px solid #e2e8f0; flex-shrink: 0;
}
.pc-sidebar-tab {
flex: 1; padding: 8px 0; text-align: center;
font-size: 11px; font-weight: 600; color: #94a3b8;
cursor: pointer; border-bottom: 2px solid transparent;
transition: all 0.15s; background: none; border-top: none; border-left: none; border-right: none;
}
.pc-sidebar-tab.active { color: var(--pc-indigo); border-bottom-color: var(--pc-indigo); }
.pc-sidebar-panel {
flex: 1; overflow-y: auto; padding: 12px;
display: none;
}
.pc-sidebar-panel.active { display: block; }
/* Node Palette Items */
.pc-palette-section { margin-bottom: 16px; }
.pc-palette-section h4 {
font-size: 10px; font-weight: 700; text-transform: uppercase;
color: #94a3b8; margin-bottom: 8px; letter-spacing: 0.5px;
}
.pc-palette-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
}
.pc-palette-item {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 10px 6px; border-radius: 8px; border: 1px solid #e2e8f0;
background: #fff; cursor: grab; transition: all 0.15s;
font-size: 10px; color: #64748b; user-select: none;
}
.pc-palette-item:hover { border-color: var(--pc-indigo); background: #f5f3ff; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(99,102,241,0.1); }
.pc-palette-item:active { cursor: grabbing; }
.pc-palette-icon {
width: 36px; height: 36px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 16px;
}
/* Canvas Area */
.pc-canvas-wrap {
flex: 1; position: relative; overflow: hidden;
background:
radial-gradient(circle at 1px 1px, #e2e8f0 1px, transparent 1px);
background-size: 24px 24px;
}
.pc-canvas {
position: absolute;
top: 0; left: 0;
width: 6000px; height: 4000px;
transform-origin: 0 0;
}
/* Canvas Nodes */
.pc-node {
position: absolute;
min-width: 160px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.04);
border: 2px solid transparent;
cursor: move;
user-select: none;
transition: box-shadow 0.15s, border-color 0.15s;
z-index: 10;
}
.pc-node:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
.pc-node.selected { border-color: var(--pc-indigo); box-shadow: 0 0 0 3px rgba(99,102,241,0.15), 0 4px 16px rgba(0,0,0,0.1); z-index: 20; }
.pc-node-header {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px 6px;
border-radius: 12px 12px 0 0;
}
.pc-node-icon {
width: 28px; height: 28px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; flex-shrink: 0;
}
.pc-node-title {
font-size: 12px; font-weight: 700; color: #1e293b;
flex: 1; min-width: 0;
outline: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pc-node-title:focus {
white-space: normal; word-break: break-all;
}
.pc-node-body {
padding: 4px 12px 10px;
font-size: 11px; color: #64748b; line-height: 1.5;
}
.pc-node-body [contenteditable] {
outline: none; min-height: 16px;
}
.pc-node-tags {
display: flex; flex-wrap: wrap; gap: 3px;
padding: 0 12px 8px;
}
.pc-node-tag {
font-size: 9px; padding: 1px 6px; border-radius: 9999px;
background: #f1f5f9; color: #64748b; font-weight: 500;
}
.pc-node-footer {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 12px; border-top: 1px solid #f1f5f9;
font-size: 10px; color: #94a3b8;
}
/* Connection Ports */
.pc-port {
position: absolute; width: 10px; height: 10px;
background: #fff; border: 2px solid #cbd5e1; border-radius: 50%;
cursor: crosshair; z-index: 15; transition: all 0.15s;
}
.pc-port:hover { border-color: var(--pc-indigo); background: var(--pc-indigo); transform: scale(1.3); }
.pc-port-top { top: -5px; left: 50%; margin-left: -5px; }
.pc-port-bottom { bottom: -5px; left: 50%; margin-left: -5px; }
.pc-port-left { left: -5px; top: 50%; margin-top: -5px; }
.pc-port-right { right: -5px; top: 50%; margin-top: -5px; }
/* SVG Connections */
.pc-connections {
position: absolute; top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none; z-index: 5;
}
.pc-conn { stroke: #94a3b8; stroke-width: 2; fill: none; }
.pc-conn:hover { stroke: var(--pc-indigo); stroke-width: 3; }
.pc-conn-active { stroke: var(--pc-indigo); stroke-width: 2; stroke-dasharray: 6 3; }
/* Minimap */
.pc-minimap {
position: absolute; bottom: 12px; right: 12px;
width: 180px; height: 120px;
background: rgba(255,255,255,0.95); border: 1px solid #e2e8f0;
border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
z-index: 25; overflow: hidden;
}
.pc-minimap-viewport {
position: absolute; border: 2px solid var(--pc-blue);
background: rgba(59,130,246,0.05); border-radius: 2px;
}
/* Zoom Controls */
.pc-zoom-controls {
position: absolute; bottom: 12px; left: 12px;
display: flex; gap: 4px; z-index: 25;
}
.pc-zoom-btn {
width: 32px; height: 32px; border-radius: 8px;
background: #fff; border: 1px solid #e2e8f0;
display: flex; align-items: center; justify-content: center;
cursor: pointer; color: #64748b; font-size: 14px; font-weight: 700;
transition: all 0.15s;
}
.pc-zoom-btn:hover { background: #f1f5f9; color: #1e293b; }
.pc-zoom-label {
height: 32px; padding: 0 10px; border-radius: 8px;
background: #fff; border: 1px solid #e2e8f0;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; color: #64748b;
}
/* Properties Panel */
.pc-props-empty {
display: flex; flex-direction: column; align-items: center;
justify-content: center; height: 100%; color: #94a3b8;
font-size: 12px; gap: 8px;
}
.pc-prop-group { margin-bottom: 12px; }
.pc-prop-label {
font-size: 10px; font-weight: 700; color: #94a3b8;
text-transform: uppercase; letter-spacing: 0.5px;
margin-bottom: 4px;
}
.pc-prop-input {
width: 100%; padding: 6px 8px; border: 1px solid #e2e8f0;
border-radius: 6px; font-size: 12px; outline: none;
transition: border-color 0.15s;
}
.pc-prop-input:focus { border-color: var(--pc-indigo); }
.pc-prop-textarea {
width: 100%; padding: 6px 8px; border: 1px solid #e2e8f0;
border-radius: 6px; font-size: 12px; outline: none;
resize: vertical; min-height: 60px;
}
.pc-color-swatches {
display: flex; gap: 4px; flex-wrap: wrap;
}
.pc-color-swatch {
width: 24px; height: 24px; border-radius: 6px;
cursor: pointer; border: 2px solid transparent;
transition: all 0.15s;
}
.pc-color-swatch.active { border-color: #1e293b; transform: scale(1.15); }
.pc-color-swatch:hover { transform: scale(1.1); }
/* View Mode Tabs */
.pc-view-tabs {
display: flex; gap: 2px;
}
.pc-view-tab {
padding: 4px 10px; border-radius: 6px;
font-size: 11px; font-weight: 600; color: #94a3b8;
cursor: pointer; border: none; background: transparent;
transition: all 0.15s;
}
.pc-view-tab.active { background: rgba(255,255,255,0.15); color: #fff; }
.pc-view-tab:hover:not(.active) { color: #e2e8f0; }
/* Context Menu */
.pc-context-menu {
position: fixed; z-index: 100;
background: #fff; border: 1px solid #e2e8f0;
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.12);
padding: 4px; min-width: 160px; display: none;
}
.pc-context-menu.show { display: block; }
.pc-cm-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border-radius: 6px;
font-size: 12px; color: #374151; cursor: pointer;
transition: background 0.1s;
}
.pc-cm-item:hover { background: #f3f4f6; }
.pc-cm-item.danger { color: #ef4444; }
.pc-cm-item.danger:hover { background: #fef2f2; }
.pc-cm-sep { height: 1px; background: #e5e7eb; margin: 4px 0; }
/* ===== Kanban Board ===== */
.pc-kanban {
display: flex; gap: 12px; flex: 1; padding: 16px; overflow-x: auto; background: #f1f5f9;
}
.pc-kanban-col {
min-width: 260px; width: 260px; flex-shrink: 0;
background: #e8ecf1; border-radius: 10px; display: flex; flex-direction: column; max-height: 100%;
}
.pc-kanban-col-header {
display: flex; align-items: center; gap: 8px; padding: 10px 12px; flex-shrink: 0;
}
.pc-kanban-col-dot {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
}
.pc-kanban-col-title {
font-size: 12px; font-weight: 700; color: #374151; flex: 1;
}
.pc-kanban-col-count {
font-size: 10px; font-weight: 600; color: #94a3b8; background: #fff;
padding: 1px 7px; border-radius: 9999px;
}
.pc-kanban-col-body {
flex: 1; overflow-y: auto; padding: 4px 8px 8px; display: flex; flex-direction: column; gap: 6px;
min-height: 60px;
}
.pc-kanban-col-body.drag-over { background: rgba(99,102,241,0.08); border-radius: 0 0 10px 10px; }
.pc-kanban-card {
background: #fff; border-radius: 8px; padding: 10px 12px; cursor: grab;
border: 1px solid #e2e8f0; transition: box-shadow 0.15s, border-color 0.15s;
border-left: 3px solid var(--card-color, #6366f1);
}
.pc-kanban-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.pc-kanban-card.dragging { opacity: 0.4; }
.pc-kanban-card-title {
font-size: 12px; font-weight: 600; color: #1e293b; margin-bottom: 4px;
display: flex; align-items: center; gap: 6px;
}
.pc-kanban-card-title .emoji { font-size: 14px; }
.pc-kanban-card-desc {
font-size: 11px; color: #64748b; line-height: 1.4;
overflow: hidden; max-height: 36px; text-overflow: ellipsis;
margin-bottom: 6px;
}
.pc-kanban-card-meta {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
}
.pc-kanban-card-tag {
font-size: 9px; padding: 1px 6px; border-radius: 9999px;
background: #f1f5f9; color: #64748b; font-weight: 500;
}
.pc-kanban-card-priority {
font-size: 9px; padding: 1px 6px; border-radius: 9999px; font-weight: 600;
margin-left: auto;
}
.pc-kanban-card-priority.high { background: #fef2f2; color: #ef4444; }
.pc-kanban-card-priority.critical { background: #fef2f2; color: #dc2626; }
.pc-kanban-card-priority.medium { background: #fffbeb; color: #d97706; }
.pc-kanban-card-priority.low { background: #f0fdf4; color: #22c55e; }
/* ===== Node Detail Modal ===== */
.pc-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 200;
display: flex; align-items: center; justify-content: center;
}
.pc-modal {
background: #fff; border-radius: 12px; width: 640px; max-width: 92vw;
max-height: 85vh; overflow-y: auto; box-shadow: 0 24px 48px rgba(0,0,0,0.2);
}
.pc-modal-header {
display: flex; align-items: center; gap: 12px; padding: 20px 24px 12px;
border-bottom: 1px solid #f1f5f9;
}
.pc-modal-header .emoji { font-size: 24px; }
.pc-modal-header input {
flex: 1; font-size: 18px; font-weight: 700; border: none; outline: none; color: #1e293b;
}
.pc-modal-header input::placeholder { color: #cbd5e1; }
.pc-modal-close {
width: 32px; height: 32px; border-radius: 8px; border: none; background: transparent;
color: #94a3b8; cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.pc-modal-close:hover { background: #f1f5f9; color: #374151; }
.pc-modal-body { padding: 16px 24px 24px; }
.pc-modal-row { display: flex; gap: 12px; margin-bottom: 14px; }
.pc-modal-label {
width: 72px; flex-shrink: 0; font-size: 11px; font-weight: 600; color: #94a3b8;
padding-top: 7px; text-align: right;
}
.pc-modal-field { flex: 1; }
.pc-modal-field input, .pc-modal-field select, .pc-modal-field textarea {
width: 100%; padding: 6px 10px; border: 1px solid #e2e8f0; border-radius: 8px;
font-size: 13px; outline: none; transition: border-color 0.15s;
}
.pc-modal-field input:focus, .pc-modal-field select:focus, .pc-modal-field textarea:focus {
border-color: var(--pc-indigo);
}
.pc-modal-field textarea { resize: vertical; min-height: 80px; line-height: 1.6; }
/* Checklist */
.pc-checklist { list-style: none; padding: 0; margin: 0; }
.pc-checklist-item {
display: flex; align-items: center; gap: 8px; padding: 5px 0;
border-bottom: 1px solid #f8fafc;
}
.pc-checklist-item input[type="checkbox"] { accent-color: var(--pc-indigo); width: 15px; height: 15px; cursor: pointer; }
.pc-checklist-item .text {
flex: 1; font-size: 13px; color: #374151; border: none; outline: none; background: transparent;
}
.pc-checklist-item .text.done { text-decoration: line-through; color: #94a3b8; }
.pc-checklist-item .remove {
width: 20px; height: 20px; border: none; background: transparent;
color: #cbd5e1; cursor: pointer; font-size: 14px; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
}
.pc-checklist-item .remove:hover { color: #ef4444; background: #fef2f2; }
.pc-checklist-add {
display: flex; align-items: center; gap: 6px; padding: 6px 0;
font-size: 12px; color: var(--pc-indigo); cursor: pointer; font-weight: 500;
}
.pc-checklist-add:hover { color: #4f46e5; }
.pc-checklist-progress {
height: 4px; background: #e2e8f0; border-radius: 2px; overflow: hidden; margin-bottom: 8px;
}
.pc-checklist-progress-bar {
height: 100%; background: var(--pc-emerald); border-radius: 2px; transition: width 0.3s;
}
/* ===== List/Table View ===== */
.pc-list-view {
flex: 1; overflow: auto; background: #fff;
}
.pc-list-table {
width: 100%; border-collapse: collapse; font-size: 12px;
}
.pc-list-table th {
position: sticky; top: 0; background: #f8fafc; padding: 8px 12px;
text-align: left; font-weight: 700; color: #64748b; font-size: 10px;
text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid #e2e8f0;
cursor: pointer; user-select: none; white-space: nowrap;
}
.pc-list-table th:hover { color: #374151; }
.pc-list-table th .sort-icon { font-size: 10px; margin-left: 2px; }
.pc-list-table td {
padding: 8px 12px; border-bottom: 1px solid #f1f5f9; color: #374151;
vertical-align: middle;
}
.pc-list-table tr:hover td { background: #f8fafc; }
.pc-list-table tr.selected td { background: #eef2ff; }
.pc-list-status-badge {
display: inline-flex; align-items: center; gap: 4px; font-size: 11px;
padding: 2px 8px; border-radius: 9999px; font-weight: 500;
}
/* ===== Filter Bar ===== */
.pc-filter-bar {
display: flex; align-items: center; gap: 8px; padding: 6px 16px;
background: #fff; border-bottom: 1px solid #e2e8f0; flex-shrink: 0;
}
.pc-filter-bar select, .pc-filter-bar input {
padding: 4px 8px; border: 1px solid #e2e8f0; border-radius: 6px;
font-size: 11px; outline: none; background: #fff; color: #374151;
}
.pc-filter-bar select:focus, .pc-filter-bar input:focus { border-color: var(--pc-indigo); }
.pc-filter-label { font-size: 10px; font-weight: 600; color: #94a3b8; }
.pc-filter-clear {
font-size: 11px; color: var(--pc-indigo); cursor: pointer; font-weight: 500;
background: none; border: none; padding: 4px 8px;
}
.pc-filter-clear:hover { text-decoration: underline; }
/* ===== Storyboard View ===== */
.sb-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #e5e7eb; }
.sb-topbar {
display: flex; align-items: center; gap: 8px; padding: 8px 16px;
background: #fff; border-bottom: 1px solid #e2e8f0; flex-shrink: 0;
}
.sb-topbar label { font-size: 10px; font-weight: 600; color: #94a3b8; }
.sb-topbar input, .sb-topbar select {
padding: 4px 8px; border: 1px solid #e2e8f0; border-radius: 6px;
font-size: 12px; outline: none;
}
.sb-topbar input:focus { border-color: var(--pc-indigo); }
/* 스토리보드 전역 placeholder 스타일 */
.sb-topbar input::placeholder,
.sb-topbar select::placeholder,
.sb-editor input::placeholder,
.sb-editor textarea::placeholder,
.sb-editor select::placeholder { color: #c8d0da; font-style: italic; }
.sb-blk-text:empty::before,
.sb-blk-heading:empty::before { font-style: italic; }
.sb-topbar-sep { width: 1px; height: 24px; background: #e2e8f0; }
.sb-pages-nav {
display: flex; align-items: center; gap: 4px;
font-size: 12px; font-weight: 600; color: #374151;
}
.sb-pages-nav button {
width: 28px; height: 28px; border-radius: 6px; border: 1px solid #e2e8f0;
background: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center;
font-size: 14px; color: #64748b;
}
.sb-pages-nav button:hover { background: #f1f5f9; }
.sb-body { flex: 1; display: flex; overflow: hidden; }
.sb-page-list {
width: 140px; flex-shrink: 0; background: #f1f5f9; border-right: 1px solid #e2e8f0;
overflow-y: auto; padding: 8px;
}
.sb-page-thumb {
padding: 6px 8px; border-radius: 6px; cursor: pointer; margin-bottom: 4px;
font-size: 10px; color: #64748b; border: 2px solid transparent;
background: #fff; transition: all 0.15s;
}
.sb-page-thumb:hover { border-color: #cbd5e1; }
.sb-page-thumb.active { border-color: var(--pc-indigo); background: #eef2ff; color: #4338ca; }
.sb-page-thumb-num { font-weight: 700; font-size: 11px; color: #374151; }
.sb-editor { flex: 1; overflow: auto; padding: 24px; display: flex; justify-content: center; }
/* Storyboard Page (A4-like) */
.sb-page {
width: 1100px; min-height: 750px; background: #fff; border-radius: 4px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1); display: flex; flex-direction: column;
flex-shrink: 0;
}
.sb-page-header {
display: grid; grid-template-columns: 1fr auto auto auto auto auto;
border-bottom: 2px solid #1e293b; font-size: 10px;
}
.sb-page-header > div {
padding: 6px 10px; border-right: 1px solid #cbd5e1;
display: flex; flex-direction: column; justify-content: center;
}
.sb-page-header > div:last-child { border-right: none; }
.sb-page-header .label { font-size: 8px; color: #94a3b8; font-weight: 600; text-transform: uppercase; }
.sb-page-header .value { font-size: 11px; font-weight: 700; color: #1e293b; }
.sb-page-header .value input {
border: none; outline: none; font-size: 11px; font-weight: 700; color: #1e293b;
width: 100%; background: transparent;
}
.sb-page-header .value input:focus { border-bottom: 1px solid var(--pc-indigo); }
.sb-page-body { display: flex; flex: 1; min-height: 0; }
/* Left Menu Panel */
.sb-menu-panel {
flex-shrink: 0; border-right: none;
padding: 12px 0; font-size: 11px; overflow-y: auto; background: #f8fafc;
}
.sb-menu-resizer {
width: 5px; flex-shrink: 0; cursor: col-resize; background: #e2e8f0;
transition: background .15s;
}
.sb-menu-resizer:hover, .sb-menu-resizer.active { background: #818cf8; }
.sb-menu-section { font-size: 8px; font-weight: 700; color: #94a3b8; padding: 4px 12px; text-transform: uppercase; }
.sb-menu-item {
padding: 5px 12px 5px 16px; color: #64748b; cursor: default;
font-size: 11px; line-height: 1.4;
}
.sb-menu-item.active { color: #4338ca; font-weight: 700; background: #eef2ff; border-right: 3px solid #4338ca; }
.sb-menu-child { padding-left: 28px; font-size: 10px; }
.sb-menu-child.active { color: #4338ca; font-weight: 700; }
.sb-menu-logo { padding: 8px 12px; font-size: 13px; font-weight: 800; color: #1e293b; letter-spacing: -0.5px; }
/* Main Content + Description */
.sb-content-area { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.sb-wireframe {
flex: 1; padding: 16px; min-height: 300px; position: relative;
}
.sb-wireframe-placeholder {
border: 2px dashed #d1d5db; border-radius: 8px; height: 100%; min-height: 280px;
display: flex; align-items: center; justify-content: center; flex-direction: column;
color: #94a3b8; font-size: 12px; gap: 8px; cursor: pointer; transition: all 0.15s;
}
.sb-wireframe-placeholder:hover { border-color: var(--pc-indigo); background: #fafafe; }
.sb-wireframe-content {
width: 100%; min-height: 280px; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 16px; font-size: 13px; line-height: 1.7; color: #374151;
outline: none; overflow: auto;
}
.sb-wireframe-content:focus { border-color: var(--pc-indigo); }
.sb-wireframe-img { max-width: 100%; border-radius: 8px; }
.sb-desc-resizer {
height: 5px; flex-shrink: 0; cursor: row-resize; background: #1e293b;
transition: background .15s;
}
.sb-desc-resizer:hover, .sb-desc-resizer.active { background: #818cf8; }
.sb-desc-panel {
padding: 12px 16px; background: #fafbfc;
overflow-y: auto; flex-shrink: 0;
}
.sb-desc-title {
font-size: 10px; font-weight: 700; color: #1e293b; margin-bottom: 8px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.sb-desc-item {
display: flex; gap: 10px; padding: 6px 0; border-bottom: 1px solid #f1f5f9;
font-size: 12px; line-height: 1.6;
}
.sb-desc-item:last-child { border-bottom: none; }
.sb-desc-num {
width: 24px; height: 24px; border-radius: 50%; flex-shrink: 0;
background: #1e293b; color: #fff; font-size: 10px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.sb-desc-text { flex: 1; color: #374151; }
.sb-desc-text textarea {
width: 100%; border: none; outline: none; font-size: 12px; line-height: 1.6;
color: #374151; resize: none; background: transparent; min-height: 20px;
}
.sb-desc-text textarea:focus { background: #fff; border-radius: 4px; }
.sb-desc-add {
display: flex; align-items: center; gap: 6px; padding: 8px 0;
font-size: 11px; color: var(--pc-indigo); cursor: pointer; font-weight: 500;
}
.sb-desc-add:hover { color: #4f46e5; }
.sb-desc-remove {
width: 20px; height: 20px; border: none; background: transparent; color: #cbd5e1;
cursor: pointer; font-size: 14px; border-radius: 4px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
}
.sb-desc-remove:hover { color: #ef4444; background: #fef2f2; }
.sb-desc-num { cursor: grab; }
.sb-desc-num:active { cursor: grabbing; }
/* Marker (description number badge on canvas) */
.sb-blk-marker {
width: 28px; height: 28px; border-radius: 50%; background: #1e293b; color: #fff;
font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center;
user-select: none; cursor: move;
}
/* Block Editor (Wireframe) */
.sb-block-toolbar {
display: flex; align-items: center; gap: 4px; padding: 6px 12px;
border-bottom: 1px solid #e2e8f0; background: #fafbfc; flex-wrap: wrap; flex-shrink: 0;
}
.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; 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 {
position: absolute; border: 1.5px solid transparent; border-radius: 6px;
padding: 0; transition: border-color .1s, box-shadow .1s;
background: #fff; cursor: move; user-select: none;
overflow: hidden;
}
.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); box-shadow: 0 0 0 2px rgba(99,102,241,0.15); }
.sb-block.sb-block-editing { cursor: default; user-select: text; overflow: visible; }
.sb-block-actions {
position: absolute; right: 4px; top: -26px; display: flex; gap: 2px;
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, .sb-block.selected .sb-block-actions { opacity: 1; }
.sb-block-action-btn {
width: 20px; height: 20px; border: none; background: transparent; color: #94a3b8;
border-radius: 4px; cursor: pointer; font-size: 11px; display: flex;
align-items: center; justify-content: center;
}
.sb-block-action-btn:hover { background: #f1f5f9; color: #475569; }
.sb-block-action-btn.danger:hover { background: #fef2f2; color: #ef4444; }
/* Resize handles */
.sb-resize-handle {
position: absolute; background: #fff; border: 1.5px solid var(--pc-indigo);
border-radius: 2px; z-index: 5; opacity: 0; transition: opacity .1s;
}
.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; }
/* Multi-select (lasso) */
.sb-block.sb-multi-selected { border-color: #f59e0b; box-shadow: 0 0 0 2px rgba(245,158,11,0.2); }
.sb-block.sb-multi-selected .sb-block-actions { opacity: 0; }
.sb-block.sb-multi-selected .sb-resize-handle { opacity: 0; }
.sb-lasso-rect {
position: absolute; border: 1.5px dashed #6366f1; background: rgba(99,102,241,0.06);
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; }
.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: #c8d0da; }
.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; }
/* Template Dropdown */
.sb-tpl-dropdown {
position: relative; display: inline-block;
}
.sb-tpl-trigger {
padding: 3px 10px; border: 1px solid #e2e8f0; border-radius: 5px;
font-size: 10px; cursor: pointer; background: #fff; color: #475569;
display: flex; align-items: center; gap: 4px; white-space: nowrap;
transition: all .12s;
}
.sb-tpl-trigger:hover { border-color: var(--pc-indigo); color: var(--pc-indigo); }
.sb-tpl-panel {
position: fixed; z-index: 9990;
width: 380px; max-height: 520px; background: #fff;
border: 1px solid #e2e8f0; border-radius: 10px;
box-shadow: 0 12px 32px rgba(0,0,0,0.15); display: flex; flex-direction: column;
}
.sb-tpl-tabs {
display: flex; border-bottom: 1px solid #e2e8f0;
}
.sb-tpl-tab {
flex: 1; padding: 8px; font-size: 11px; font-weight: 600; text-align: center;
cursor: pointer; color: #94a3b8; border-bottom: 2px solid transparent;
transition: all .12s;
}
.sb-tpl-tab:hover { color: #475569; }
.sb-tpl-tab.active { color: var(--pc-indigo); border-bottom-color: var(--pc-indigo); }
.sb-tpl-search {
margin: 8px 10px 4px; padding: 6px 10px; border: 1px solid #e2e8f0;
border-radius: 6px; font-size: 11px; outline: none; width: calc(100% - 20px);
}
.sb-tpl-search:focus { border-color: var(--pc-indigo); }
.sb-tpl-list { flex: 1; overflow-y: auto; padding: 4px 6px 8px; }
.sb-tpl-item {
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
border-radius: 6px; cursor: pointer; transition: all .1s;
}
.sb-tpl-item:hover { background: #f1f5f9; }
.sb-tpl-item-icon {
width: 32px; height: 32px; border-radius: 6px; background: #f1f5f9;
display: flex; align-items: center; justify-content: center; font-size: 15px;
flex-shrink: 0;
}
.sb-tpl-item-info { flex: 1; min-width: 0; }
.sb-tpl-item-name { font-size: 12px; font-weight: 600; color: #1e293b; }
.sb-tpl-item-desc { font-size: 10px; color: #94a3b8; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sb-tpl-item-actions { display: flex; gap: 2px; flex-shrink: 0; }
.sb-tpl-item-btn {
width: 22px; height: 22px; border: none; background: transparent; color: #cbd5e1;
border-radius: 4px; cursor: pointer; font-size: 12px;
display: flex; align-items: center; justify-content: center;
}
.sb-tpl-item-btn:hover { background: #fee2e2; color: #ef4444; }
.sb-tpl-save-bar {
padding: 8px 10px; border-top: 1px solid #e2e8f0; display: flex; gap: 6px; align-items: center;
}
.sb-tpl-save-input {
flex: 1; padding: 5px 8px; border: 1px solid #e2e8f0; border-radius: 5px;
font-size: 11px; outline: none;
}
.sb-tpl-save-input:focus { border-color: var(--pc-indigo); }
.sb-tpl-save-btn {
padding: 5px 12px; border: none; border-radius: 5px; font-size: 11px;
font-weight: 600; cursor: pointer; background: var(--pc-indigo); color: #fff;
}
.sb-tpl-save-btn:hover { opacity: 0.9; }
.sb-tpl-empty { text-align: center; padding: 20px; color: #cbd5e1; font-size: 11px; }
/* Menu Tree Editor Modal */
.sb-menu-modal-overlay {
position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center;
}
.sb-menu-modal {
background: #fff; border-radius: 12px; width: 480px; max-height: 80vh;
display: flex; flex-direction: column; box-shadow: 0 25px 50px rgba(0,0,0,0.15);
}
.sb-menu-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid #e2e8f0;
}
.sb-menu-modal-header h3 { font-size: 15px; font-weight: 700; color: #1e293b; margin: 0; }
.sb-menu-modal-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
.sb-menu-modal-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 12px 20px; border-top: 1px solid #e2e8f0;
}
.sb-mt-item {
display: flex; align-items: center; gap: 6px; padding: 5px 0;
border-bottom: 1px solid #f8fafc;
}
.sb-mt-item:hover { background: #f8fafc; border-radius: 6px; }
.sb-mt-drag {
cursor: grab; color: #cbd5e1; flex-shrink: 0; font-size: 14px; padding: 0 2px;
user-select: none;
}
.sb-mt-drag:active { cursor: grabbing; }
.sb-mt-icon { flex-shrink: 0; width: 18px; text-align: center; color: #94a3b8; font-size: 11px; cursor: pointer; }
.sb-mt-icon:hover { color: #475569; }
.sb-mt-name {
flex: 1; border: none; outline: none; font-size: 13px; padding: 4px 6px;
border-radius: 4px; color: #334155; background: transparent;
}
.sb-mt-name:focus { background: #f1f5f9; }
.sb-mt-actions { display: flex; gap: 2px; flex-shrink: 0; }
.sb-mt-btn {
width: 24px; height: 24px; border: none; background: transparent;
color: #94a3b8; cursor: pointer; border-radius: 4px; font-size: 13px;
display: flex; align-items: center; justify-content: center;
}
.sb-mt-btn:hover { background: #f1f5f9; color: #475569; }
.sb-mt-btn.danger:hover { background: #fef2f2; color: #ef4444; }
.sb-mt-children { padding-left: 24px; border-left: 2px solid #e2e8f0; margin-left: 10px; }
.sb-mt-add-root {
display: flex; align-items: center; gap: 6px; padding: 10px 0 4px;
font-size: 12px; color: var(--pc-indigo); cursor: pointer; font-weight: 500;
}
.sb-mt-add-root:hover { color: #4f46e5; }
/* Phase Swimlane (Timeline View) */
.pc-swimlane {
position: absolute; top: 0;
height: 100%;
border-left: 2px dashed rgba(99,102,241,0.2);
padding: 8px 12px;
pointer-events: none;
}
.pc-swimlane-label {
font-size: 10px; font-weight: 700; color: var(--pc-indigo);
text-transform: uppercase; letter-spacing: 1px; opacity: 0.5;
}
</style>
<div class="pc-wrap" id="planningCanvas" x-data="planningCanvas()" x-init="init()" @keydown.window="handleKeyDown($event)" @keyup.window="handleKeyUp($event)">
{{-- ===== Top Toolbar ===== --}}
<div class="pc-toolbar">
{{-- Left: Tools --}}
<div class="pc-toolbar-group">
<button class="pc-tb-btn" :class="{ active: tool === 'select' }" @click="tool = 'select'" title="선택 (V)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"/></svg>
</button>
<button class="pc-tb-btn" :class="{ active: tool === 'pan' }" @click="tool = 'pan'" title="이동 (H)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11"/></svg>
</button>
<button class="pc-tb-btn" :class="{ active: tool === 'connect' }" @click="tool = 'connect'" title="연결 (C)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
</button>
</div>
{{-- Center: Title + View Mode --}}
<div class="pc-tb-title">
<input type="text" x-model="projectTitle" placeholder="프로젝트 이름을 입력하세요" spellcheck="false">
</div>
<div class="pc-toolbar-group">
<div class="pc-view-tabs">
<button class="pc-view-tab" :class="{ active: viewMode === 'free' }" @click="switchView('free')">자유배치</button>
<button class="pc-view-tab" :class="{ active: viewMode === 'timeline' }" @click="switchView('timeline')">타임라인</button>
<button class="pc-view-tab" :class="{ active: viewMode === 'flow' }" @click="switchView('flow')">플로우</button>
<button class="pc-view-tab" :class="{ active: viewMode === 'kanban' }" @click="switchView('kanban')">칸반</button>
<button class="pc-view-tab" :class="{ active: viewMode === 'list' }" @click="switchView('list')">리스트</button>
<button class="pc-view-tab" :class="{ active: viewMode === 'storyboard' }" @click="switchView('storyboard')" style="color:#f59e0b;">스토리보드</button>
</div>
</div>
{{-- Right: Actions --}}
<div class="pc-toolbar-group">
<button class="pc-tb-btn" @click="toggleSidebar()" title="사이드바 토글">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/></svg>
</button>
<button class="pc-tb-btn" @click="viewMode === 'storyboard' ? sbUndo() : undoAction()" title="실행 취소 (Ctrl+Z)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
</button>
<button class="pc-tb-btn" @click="viewMode === 'storyboard' ? sbRedo() : redoAction()" title="다시 실행 (Ctrl+Y)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 10H11a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6"/></svg>
</button>
<div class="pc-tb-sep"></div>
<button class="pc-tb-btn" @click="exportProject()" title="내보내기">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</button>
<button class="pc-tb-btn" @click="saveProject()" title="저장 (Ctrl+S)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"/></svg>
</button>
<span class="pc-tb-badge" x-text="nodes.length + ' nodes'"></span>
</div>
</div>
{{-- ===== Body ===== --}}
<div class="pc-body">
{{-- Left Sidebar --}}
<div class="pc-sidebar" :class="{ collapsed: !sidebarOpen }">
<div class="pc-sidebar-tabs">
<button class="pc-sidebar-tab" :class="{ active: sidebarTab === 'palette' }" @click="sidebarTab = 'palette'">노드</button>
<button class="pc-sidebar-tab" :class="{ active: sidebarTab === 'properties' }" @click="sidebarTab = 'properties'">속성</button>
<button class="pc-sidebar-tab" :class="{ active: sidebarTab === 'projects' }" @click="sidebarTab = 'projects'">프로젝트</button>
</div>
{{-- Palette Panel --}}
<div class="pc-sidebar-panel" :class="{ active: sidebarTab === 'palette' }">
{{-- 기획 단계 --}}
<div class="pc-palette-section">
<h4>기획 단계</h4>
<div class="pc-palette-grid">
<template x-for="item in paletteItems.planning" :key="item.type">
<div class="pc-palette-item"
draggable="true"
@dragstart="onPaletteDragStart($event, item)"
@dblclick="addNodeAtCenter(item)">
<div class="pc-palette-icon" :style="'background:' + item.bg">
<span x-text="item.emoji"></span>
</div>
<span x-text="item.label"></span>
</div>
</template>
</div>
</div>
{{-- 분석 도구 --}}
<div class="pc-palette-section">
<h4>분석 & 설계</h4>
<div class="pc-palette-grid">
<template x-for="item in paletteItems.analysis" :key="item.type">
<div class="pc-palette-item"
draggable="true"
@dragstart="onPaletteDragStart($event, item)"
@dblclick="addNodeAtCenter(item)">
<div class="pc-palette-icon" :style="'background:' + item.bg">
<span x-text="item.emoji"></span>
</div>
<span x-text="item.label"></span>
</div>
</template>
</div>
</div>
{{-- 구조 요소 --}}
<div class="pc-palette-section">
<h4>구조 & 흐름</h4>
<div class="pc-palette-grid">
<template x-for="item in paletteItems.structure" :key="item.type">
<div class="pc-palette-item"
draggable="true"
@dragstart="onPaletteDragStart($event, item)"
@dblclick="addNodeAtCenter(item)">
<div class="pc-palette-icon" :style="'background:' + item.bg">
<span x-text="item.emoji"></span>
</div>
<span x-text="item.label"></span>
</div>
</template>
</div>
</div>
{{-- 산출물 --}}
<div class="pc-palette-section">
<h4>산출물</h4>
<div class="pc-palette-grid">
<template x-for="item in paletteItems.output" :key="item.type">
<div class="pc-palette-item"
draggable="true"
@dragstart="onPaletteDragStart($event, item)"
@dblclick="addNodeAtCenter(item)">
<div class="pc-palette-icon" :style="'background:' + item.bg">
<span x-text="item.emoji"></span>
</div>
<span x-text="item.label"></span>
</div>
</template>
</div>
</div>
</div>
{{-- Properties Panel --}}
<div class="pc-sidebar-panel" :class="{ active: sidebarTab === 'properties' }">
<template x-if="!selectedNode">
<div class="pc-props-empty">
<svg class="w-8 h-8 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5"/>
</svg>
<span>노드를 선택하세요</span>
</div>
</template>
<template x-if="selectedNode">
<div>
<div class="pc-prop-group">
<div class="pc-prop-label">제목</div>
<input class="pc-prop-input" x-model="selectedNode.title" @input="onPropChange()">
</div>
<div class="pc-prop-group">
<div class="pc-prop-label">설명</div>
<textarea class="pc-prop-textarea" x-model="selectedNode.description" @input="onPropChange()"></textarea>
</div>
<div class="pc-prop-group">
<div class="pc-prop-label">상태</div>
<select class="pc-prop-input" x-model="selectedNode.status" @change="onPropChange()">
<option value="todo">대기</option>
<option value="progress">진행중</option>
<option value="review">검토중</option>
<option value="done">완료</option>
</select>
</div>
<div class="pc-prop-group">
<div class="pc-prop-label">색상</div>
<div class="pc-color-swatches">
<template x-for="c in nodeColors" :key="c.value">
<div class="pc-color-swatch"
:class="{ active: selectedNode.color === c.value }"
:style="'background:' + c.value"
@click="selectedNode.color = c.value; onPropChange()"></div>
</template>
</div>
</div>
<div class="pc-prop-group">
<div class="pc-prop-label">담당자</div>
<input class="pc-prop-input" x-model="selectedNode.assignee" placeholder="담당자 이름" @input="onPropChange()">
</div>
<div class="pc-prop-group">
<div class="pc-prop-label">마감일</div>
<input class="pc-prop-input" type="date" x-model="selectedNode.dueDate" @change="onPropChange()">
</div>
<div class="pc-prop-group">
<div class="pc-prop-label">태그 (콤마 구분)</div>
<input class="pc-prop-input" :value="(selectedNode.tags||[]).join(', ')"
@change="selectedNode.tags = $event.target.value.split(',').map(s=>s.trim()).filter(Boolean); onPropChange()">
</div>
<div class="pc-prop-group">
<div class="pc-prop-label">우선순위</div>
<select class="pc-prop-input" x-model="selectedNode.priority" @change="onPropChange()">
<option value="low">낮음</option>
<option value="medium">보통</option>
<option value="high">높음</option>
<option value="critical">긴급</option>
</select>
</div>
<div style="margin-top: 16px; display:flex; flex-direction:column; gap:6px;">
<button class="w-full py-2 bg-indigo-50 text-indigo-600 text-xs font-medium rounded-lg hover:bg-indigo-100 transition"
@click="openNodeModal(selectedNode)">상세 편집</button>
<button class="w-full py-2 bg-red-50 text-red-600 text-xs font-medium rounded-lg hover:bg-red-100 transition"
@click="deleteSelectedNode()">노드 삭제</button>
</div>
</div>
</template>
</div>
{{-- Projects Panel --}}
<div class="pc-sidebar-panel" :class="{ active: sidebarTab === 'projects' }">
<div style="margin-bottom: 12px;">
<button class="w-full py-2 bg-indigo-50 text-indigo-600 text-xs font-medium rounded-lg hover:bg-indigo-100 transition"
@click="newProject()">+ 프로젝트</button>
</div>
<template x-for="(proj, idx) in savedProjects" :key="proj.id">
<div class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-50 cursor-pointer mb-1"
:class="{ 'bg-indigo-50': currentProjectId === proj.id }"
@click="loadProject(proj.id)">
<div class="flex-1 min-w-0">
<div class="text-xs font-medium text-gray-800 truncate" x-text="proj.title || '제목 없음'"></div>
<div class="text-xs text-gray-400" x-text="proj.nodeCount + '개 노드 · ' + formatDate(proj.updatedAt)"></div>
</div>
<button class="text-gray-300 hover:text-red-500 transition text-xs" @click.stop="deleteProject(proj.id)">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</template>
<div x-show="savedProjects.length === 0" class="text-center text-gray-400 text-xs py-8">
저장된 프로젝트가 없습니다
</div>
</div>
</div>
{{-- Main Content Area --}}
<div style="flex:1; display:flex; flex-direction:column; min-width:0;">
{{-- Filter Bar --}}
<div class="pc-filter-bar" x-show="viewMode === 'kanban' || viewMode === 'list'">
<span class="pc-filter-label">필터:</span>
<input type="text" placeholder="검색 (Ctrl+F)" x-model="filterText" style="width:160px;" @keydown.escape="filterText=''">
<select x-model="filterStatus">
<option value="">전체 상태</option>
<option value="todo">대기</option>
<option value="progress">진행중</option>
<option value="review">검토중</option>
<option value="done">완료</option>
</select>
<select x-model="filterPriority">
<option value="">전체 우선순위</option>
<option value="critical">긴급</option>
<option value="high">높음</option>
<option value="medium">보통</option>
<option value="low">낮음</option>
</select>
<select x-model="filterType">
<option value="">전체 유형</option>
<template x-for="item in allPaletteItems" :key="item.type">
<option :value="item.type" x-text="item.label"></option>
</template>
</select>
<button class="pc-filter-clear" x-show="filterText || filterStatus || filterPriority || filterType"
@click="filterText=''; filterStatus=''; filterPriority=''; filterType='';">초기화</button>
<span style="margin-left:auto; font-size:10px; color:#94a3b8;" x-text="filteredNodes.length + ' / ' + nodes.length + '개'"></span>
</div>
{{-- Canvas (free / timeline / flow) --}}
<div class="pc-canvas-wrap" id="canvasWrap"
x-show="viewMode === 'free' || viewMode === 'timeline' || viewMode === 'flow'"
@mousedown="onCanvasMouseDown($event)"
@mousemove="onCanvasMouseMove($event)"
@mouseup="onCanvasMouseUp($event)"
@wheel="onCanvasWheel($event)"
@dragover.prevent
@drop="onCanvasDrop($event)"
@contextmenu.prevent="showContextMenu($event)">
<div class="pc-canvas" id="canvas" :style="'transform: scale(' + zoom + ') translate(' + panX + 'px,' + panY + 'px)'">
{{-- SVG Connections (프로그래밍 렌더링 Alpine template은 SVG 네임스페이스 미지원) --}}
<svg class="pc-connections" id="connectionsSvg" x-ref="connSvg" x-effect="renderConnections()"></svg>
{{-- Timeline Swimlanes --}}
<template x-if="viewMode === 'timeline'">
<template x-for="(phase, idx) in phases" :key="phase.id">
<div class="pc-swimlane" :style="'left:' + (idx * 400 + 100) + 'px; width: 400px;'">
<div class="pc-swimlane-label" x-text="phase.name"></div>
</div>
</template>
</template>
{{-- Nodes --}}
<template x-for="node in nodes" :key="node.id">
<div class="pc-node"
:class="{ selected: selectedNode?.id === node.id }"
:style="'left:' + node.x + 'px; top:' + node.y + 'px; border-left: 4px solid ' + (node.color || '#6366f1')"
:id="'node-' + node.id"
@mousedown.stop="onNodeMouseDown($event, node)"
@dblclick.stop="openNodeModal(node)">
<div class="pc-node-header">
<div class="pc-node-icon" :style="'background:' + (node.bg || '#eef2ff')">
<span x-text="node.emoji || '📌'"></span>
</div>
<div class="pc-node-title"
contenteditable="true"
@blur="node.title = $event.target.textContent; autoSave()"
@keydown.enter.prevent="$event.target.blur()"
x-text="node.title"></div>
<div style="width: 8px; height: 8px; border-radius: 50; flex-shrink: 0;"
:style="'background:' + statusColor(node.status)"></div>
</div>
<div class="pc-node-body" x-show="node.description">
<div x-text="node.description" style="overflow:hidden; max-height: 40px; text-overflow: ellipsis;"></div>
</div>
<div class="pc-node-tags" x-show="node.tags && node.tags.length > 0">
<template x-for="tag in (node.tags||[])" :key="tag">
<span class="pc-node-tag" x-text="tag"></span>
</template>
</div>
<div class="pc-node-footer">
<span x-text="node.typeLabel || node.type"></span>
<span x-text="priorityLabel(node.priority)"></span>
</div>
{{-- Connection Ports --}}
<div class="pc-port pc-port-top" @mousedown.stop="startConnection($event, node, 'top')"></div>
<div class="pc-port pc-port-bottom" @mousedown.stop="startConnection($event, node, 'bottom')"></div>
<div class="pc-port pc-port-left" @mousedown.stop="startConnection($event, node, 'left')"></div>
<div class="pc-port pc-port-right" @mousedown.stop="startConnection($event, node, 'right')"></div>
</div>
</template>
</div>
{{-- Zoom Controls --}}
<div class="pc-zoom-controls">
<button class="pc-zoom-btn" @click="zoomIn()">+</button>
<div class="pc-zoom-label" x-text="Math.round(zoom * 100) + '%'"></div>
<button class="pc-zoom-btn" @click="zoomOut()">-</button>
<button class="pc-zoom-btn" @click="zoomFit()" title="맞춤">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/></svg>
</button>
</div>
{{-- Minimap --}}
<div class="pc-minimap" id="minimap"></div>
</div>
{{-- Kanban View --}}
<div class="pc-kanban" x-show="viewMode === 'kanban'">
<template x-for="col in kanbanColumns" :key="col.key">
<div class="pc-kanban-col">
<div class="pc-kanban-col-header">
<div class="pc-kanban-col-dot" :style="'background:' + col.color"></div>
<div class="pc-kanban-col-title" x-text="col.label"></div>
<div class="pc-kanban-col-count" x-text="kanbanNodesFor(col.key).length"></div>
</div>
<div class="pc-kanban-col-body"
@dragover.prevent="$event.currentTarget.classList.add('drag-over')"
@dragleave="$event.currentTarget.classList.remove('drag-over')"
@drop.prevent="onKanbanDrop($event, col.key); $event.currentTarget.classList.remove('drag-over')">
<template x-for="node in kanbanNodesFor(col.key)" :key="node.id">
<div class="pc-kanban-card"
:style="'--card-color:' + (node.color || '#6366f1')"
draggable="true"
@dragstart="onKanbanDragStart($event, node)"
@dragend="$event.target.classList.remove('dragging')"
@dblclick="openNodeModal(node)">
<div class="pc-kanban-card-title">
<span class="emoji" x-text="node.emoji || '📌'"></span>
<span x-text="node.title"></span>
</div>
<div class="pc-kanban-card-desc" x-show="node.description" x-text="node.description"></div>
<div class="pc-kanban-card-meta">
<template x-for="tag in (node.tags||[]).slice(0,3)" :key="tag">
<span class="pc-kanban-card-tag" x-text="tag"></span>
</template>
<span class="pc-kanban-card-tag" x-show="node.assignee" x-text="'👤 ' + (node.assignee||'')"></span>
<span class="pc-kanban-card-tag" x-show="node.dueDate" x-text="'📅 ' + formatShortDate(node.dueDate)"
:style="isOverdue(node.dueDate) ? 'background:#fef2f2;color:#ef4444;' : ''"></span>
<template x-if="node.checklist && node.checklist.length > 0">
<span class="pc-kanban-card-tag" x-text="'☑ ' + node.checklist.filter(c=>c.done).length + '/' + node.checklist.length"></span>
</template>
<span class="pc-kanban-card-priority" :class="node.priority" x-text="priorityLabel(node.priority)"></span>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
{{-- List/Table View --}}
<div class="pc-list-view" x-show="viewMode === 'list'">
<table class="pc-list-table">
<thead>
<tr>
<th style="width:30px;"></th>
<th @click="sortList('title')">제목 <span class="sort-icon" x-text="sortIcon('title')"></span></th>
<th @click="sortList('type')" style="width:80px;">유형 <span class="sort-icon" x-text="sortIcon('type')"></span></th>
<th @click="sortList('status')" style="width:80px;">상태 <span class="sort-icon" x-text="sortIcon('status')"></span></th>
<th @click="sortList('priority')" style="width:80px;">우선순위 <span class="sort-icon" x-text="sortIcon('priority')"></span></th>
<th @click="sortList('assignee')" style="width:90px;">담당자 <span class="sort-icon" x-text="sortIcon('assignee')"></span></th>
<th @click="sortList('dueDate')" style="width:90px;">마감일 <span class="sort-icon" x-text="sortIcon('dueDate')"></span></th>
<th style="width:60px;">체크</th>
</tr>
</thead>
<tbody>
<template x-for="node in sortedFilteredNodes" :key="node.id">
<tr :class="{ selected: selectedNode?.id === node.id }"
@click="selectedNode = node; sidebarTab = 'properties';"
@dblclick="openNodeModal(node)"
style="cursor:pointer;">
<td><span x-text="node.emoji || '📌'"></span></td>
<td>
<div style="font-weight:600;" x-text="node.title"></div>
<div style="font-size:10px; color:#94a3b8; max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" x-text="node.description"></div>
</td>
<td><span class="pc-node-tag" x-text="node.typeLabel || node.type"></span></td>
<td>
<span class="pc-list-status-badge" :style="'background:' + statusColor(node.status) + '20; color:' + statusColor(node.status)">
<span style="width:6px;height:6px;border-radius:50;display:inline-block;" :style="'background:' + statusColor(node.status)"></span>
<span x-text="statusLabel(node.status)"></span>
</span>
</td>
<td><span class="pc-kanban-card-priority" :class="node.priority" x-text="priorityLabel(node.priority)"></span></td>
<td x-text="node.assignee || '-'" style="font-size:11px;"></td>
<td style="font-size:11px;" :style="isOverdue(node.dueDate) ? 'color:#ef4444;font-weight:600;' : ''"
x-text="node.dueDate ? formatShortDate(node.dueDate) : '-'"></td>
<td style="font-size:11px; color:#94a3b8;"
x-text="node.checklist && node.checklist.length ? node.checklist.filter(c=>c.done).length + '/' + node.checklist.length : '-'"></td>
</tr>
</template>
</tbody>
</table>
<div x-show="filteredNodes.length === 0" class="text-center text-gray-400 text-sm py-16">노드가 없습니다</div>
</div>
{{-- Storyboard View --}}
<div class="sb-wrap" x-show="viewMode === 'storyboard'">
{{-- 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>
<div class="sb-block-toolbar-sep"></div>
{{-- Description Marker --}}
<div style="display:flex; align-items:center; gap:3px;">
<input type="text" x-model="sbMarkerNum" placeholder="01" maxlength="3"
style="width:32px; padding:3px 4px; border:1px solid #e2e8f0; border-radius:5px; font-size:10px; text-align:center; font-weight:700;">
<button class="sb-block-toolbar-btn" @click="sbAddMarkerBlock()" title="번호 뱃지를 캔버스에 추가">
<span style="display:inline-flex;width:16px;height:16px;border-radius:50%;background:#1e293b;color:#fff;font-size:8px;font-weight:700;align-items:center;justify-content:center;" x-text="sbMarkerNum || '01'"></span>
번호
</button>
</div>
<div class="sb-block-toolbar-sep"></div>
{{-- Template Dropdown --}}
<div class="sb-tpl-dropdown" @click.outside="sbTplOpen = false">
<button class="sb-tpl-trigger" x-ref="sbTplBtn" @click="sbToggleTplPanel()">📋 템플릿 <span style="font-size:8px;"></span></button>
<div class="sb-tpl-panel" x-ref="sbTplPanel" x-show="sbTplOpen" x-cloak x-transition>
<div class="sb-tpl-tabs">
<div class="sb-tpl-tab" :class="{ active: sbTplTab === 'preset' }" @click="sbTplTab = 'preset'">기본 템플릿</div>
<div class="sb-tpl-tab" :class="{ active: sbTplTab === 'custom' }" @click="sbTplTab = 'custom'"> 템플릿</div>
</div>
<input class="sb-tpl-search" type="text" placeholder="템플릿 검색..." x-model="sbTplSearch">
<div class="sb-tpl-list">
{{-- Preset Templates --}}
<template x-if="sbTplTab === 'preset'">
<div>
<template x-for="tpl in sbFilteredPresets" :key="tpl.name">
<div class="sb-tpl-item" @click="sbInsertTemplate(tpl); sbTplOpen = false;">
<div class="sb-tpl-item-icon" x-text="tpl.icon"></div>
<div class="sb-tpl-item-info">
<div class="sb-tpl-item-name" x-text="tpl.name"></div>
<div class="sb-tpl-item-desc" x-text="tpl.desc"></div>
</div>
</div>
</template>
<div class="sb-tpl-empty" x-show="sbFilteredPresets.length === 0">검색 결과가 없습니다</div>
</div>
</template>
{{-- Custom Templates --}}
<template x-if="sbTplTab === 'custom'">
<div>
<template x-for="(tpl, ti) in sbFilteredCustoms" :key="tpl.name">
<div class="sb-tpl-item">
<div class="sb-tpl-item-icon">📄</div>
<div class="sb-tpl-item-info" @click="sbInsertTemplate(tpl); sbTplOpen = false;">
<div class="sb-tpl-item-name" x-text="tpl.name"></div>
<div class="sb-tpl-item-desc" x-text="tpl.blocks.length + '개 블록'"></div>
</div>
<div class="sb-tpl-item-actions">
<button class="sb-tpl-item-btn" @click.stop="sbDeleteCustomTemplate(ti)" title="삭제">×</button>
</div>
</div>
</template>
<div class="sb-tpl-empty" x-show="sbFilteredCustoms.length === 0 && !sbTplSearch">
저장된 템플릿이 없습니다<br>
<span style="font-size:10px;">현재 페이지 블록을 아래에서 저장하세요</span>
</div>
<div class="sb-tpl-empty" x-show="sbFilteredCustoms.length === 0 && sbTplSearch">검색 결과가 없습니다</div>
</div>
</template>
</div>
{{-- Save current blocks as template --}}
<div class="sb-tpl-save-bar">
<input class="sb-tpl-save-input" type="text" placeholder="현재 블록을 템플릿으로 저장..." x-model="sbTplSaveName" @keydown.enter="sbSaveAsTemplate()">
<button class="sb-tpl-save-btn" @click="sbSaveAsTemplate()" :disabled="!sbTplSaveName.trim()">저장</button>
</div>
</div>
</div>
</div>
{{-- Storyboard Top Bar --}}
<div class="sb-topbar">
<label>프로젝트</label>
<input type="text" x-model="sb.docInfo.projectName" placeholder="프로젝트명" style="width:200px;" @input="autoSave()">
<div class="sb-topbar-sep"></div>
<label>단위업무</label>
<input type="text" x-model="sb.docInfo.unitTask" placeholder="단위업무명" style="width:120px;" @input="autoSave()">
<div class="sb-topbar-sep"></div>
<label>버전</label>
<input type="text" x-model="sb.docInfo.version" placeholder="D1.0" style="width:60px;" @input="autoSave()">
<div class="sb-topbar-sep"></div>
<div class="sb-pages-nav">
<button @click="sbPrevPage()" title="이전 페이지">&lt;</button>
<span x-text="(sb.currentPageIndex + 1) + ' / ' + sb.pages.length"></span>
<button @click="sbNextPage()" title="다음 페이지">&gt;</button>
</div>
<div class="sb-topbar-sep"></div>
<button style="padding:4px 10px; border:1px solid #e2e8f0; border-radius:6px; font-size:11px; cursor:pointer; background:#fff; color:#374151;"
@click="sbAddPage()">+ 페이지 추가</button>
<button style="padding:4px 10px; border:1px solid #e2e8f0; border-radius:6px; font-size:11px; cursor:pointer; background:#fff; color:#2563eb;"
@click="sbDuplicatePage()">페이지 복사</button>
<button style="padding:4px 10px; border:1px solid #e2e8f0; border-radius:6px; font-size:11px; cursor:pointer; background:#fff; color:#ef4444;"
x-show="sb.pages.length > 1"
@click="sbDeletePage()">페이지 삭제</button>
<div style="margin-left:auto;"></div>
<button style="padding:4px 10px; border:1px solid #e2e8f0; border-radius:6px; font-size:11px; cursor:pointer; background:#fff; color:#374151;"
@click="sbEditMenu()">메뉴 편집</button>
<button style="padding:4px 10px; border:1px solid #e2e8f0; border-radius:6px; font-size:11px; cursor:pointer; background:#fff; color:#374151;"
@click="sbPrintPreview()">🖨 인쇄</button>
<button style="padding:4px 10px; border:1px solid var(--pc-indigo); border-radius:6px; font-size:11px; cursor:pointer; background:var(--pc-indigo); color:#fff;"
@click="sbExportHtml()">HTML 내보내기</button>
</div>
<div class="sb-body">
{{-- Page List (thumbnail) --}}
<div class="sb-page-list">
<template x-for="(pg, idx) in sb.pages" :key="pg.id">
<div class="sb-page-thumb" :class="{ active: sb.currentPageIndex === idx }" @click="sb.currentPageIndex = idx">
<div class="sb-page-thumb-num" x-text="(idx + 1) + '.'"></div>
<div x-text="pg.screenName || '(화면명)'" style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"></div>
</div>
</template>
</div>
{{-- Page Editor --}}
<div class="sb-editor">
<template x-if="sbCurrentPage">
<div class="sb-page">
{{-- Page Header --}}
<div class="sb-page-header">
<div>
<span class="label">단위업무명</span>
<span class="value" x-text="sb.docInfo.unitTask || '-'"></span>
</div>
<div>
<span class="label">버전</span>
<span class="value" x-text="sb.docInfo.version || '-'"></span>
</div>
<div>
<span class="label">Page</span>
<span class="value" x-text="sb.currentPageIndex + 1"></span>
</div>
<div>
<span class="label">경로</span>
<span class="value"><input type="text" x-model="sbCurrentPage.path" placeholder="품질관리 > 제품검사관리" @input="autoSave()"></span>
</div>
<div>
<span class="label">화면명</span>
<span class="value"><input type="text" x-model="sbCurrentPage.screenName" placeholder="제품검사 목록" @input="autoSave()"></span>
</div>
<div>
<span class="label">화면 ID</span>
<span class="value"><input type="text" x-model="sbCurrentPage.screenId" placeholder="QM-001" @input="autoSave()"></span>
</div>
</div>
<div class="sb-page-body"
@mousemove.window="if(_sbMenuResize){ sbMenuWidth = Math.max(80, Math.min(400, _sbMenuResize.startW + $event.clientX - _sbMenuResize.startX)); } if(_sbDescResize){ sbDescHeight = Math.max(60, Math.min(500, _sbDescResize.startH - ($event.clientY - _sbDescResize.startY))); }"
@mouseup.window="if(_sbMenuResize){ _sbMenuResize = null; document.querySelector('.sb-menu-resizer.active')?.classList.remove('active'); } if(_sbDescResize){ _sbDescResize = null; document.querySelector('.sb-desc-resizer.active')?.classList.remove('active'); }">
{{-- Left Menu --}}
<div class="sb-menu-panel" :style="'width:' + sbMenuWidth + 'px'">
<div class="sb-menu-logo" x-text="sb.docInfo.projectName || 'LOGO'"></div>
<div class="sb-menu-section">ERP 메뉴</div>
<template x-for="menu in sb.menuTree" :key="menu.name">
<div>
<div class="sb-menu-item" :class="{ active: sbCurrentPage.path && sbCurrentPage.path.startsWith(menu.name) }" x-text="menu.name"></div>
<template x-for="child in (menu.children || [])" :key="child.name">
<div class="sb-menu-item sb-menu-child"
:class="{ active: sbCurrentPage.path && sbCurrentPage.path.includes(child.name) }"
x-text="'- ' + child.name"></div>
</template>
</div>
</template>
</div>
{{-- Menu / Content Resizer --}}
<div class="sb-menu-resizer"
@mousedown.prevent="_sbMenuResize = { startX: $event.clientX, startW: sbMenuWidth }; $event.target.classList.add('active')"></div>
{{-- Content + Description --}}
<div class="sb-content-area">
{{-- Block Editor Area (moved toolbar to sb-topbar above) --}}
<input type="file" accept="image/*" x-ref="sbBlockImageInput" style="display:none;" @change="sbBlockUploadImage($event)">
{{-- Block Editor Area (Free Canvas) --}}
<div class="sb-blocks-area" x-ref="sbCanvas"
@mousedown="sbCanvasMouseDown($event)"
@mousemove="sbCanvasMouseMove($event)"
@mouseup="sbCanvasMouseUp($event)"
@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;">
{{-- Lasso rectangle --}}
<div class="sb-lasso-rect" x-show="_sbLasso" x-cloak
:style="_sbLasso ? 'left:'+_sbLasso.rx+'px;top:'+_sbLasso.ry+'px;width:'+_sbLasso.rw+'px;height:'+_sbLasso.rh+'px' : ''"></div>
<template x-if="!sbPageBlocks.length">
<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>
<span>블록을 추가하여 화면을 구성하세요</span>
<span style="font-size:10px; color:#cbd5e1;">상단 툴바에서 블록 유형을 선택하거나 여기를 클릭하세요</span>
</div>
</template>
<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;'
+ (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"
@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>
</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 --}}
<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>
{{-- 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 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>
{{-- Marker (description number) --}}
<template x-if="blk.type === 'marker'">
<div class="sb-blk-marker" x-text="blk.content || '01'"></div>
</template>
</div>
</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>
{{-- Description Panel --}}
<div class="sb-desc-panel" :style="'height:' + sbDescHeight + 'px'">
<div class="sb-desc-title">Description</div>
<template x-for="(desc, idx) in (sbCurrentPage.descriptions || [])" :key="idx">
<div class="sb-desc-item">
<div class="sb-desc-num"
x-text="String(idx + 1).padStart(2, '0')"
draggable="true"
@dragstart="$event.dataTransfer.setData('text/plain', 'marker:' + String(idx + 1).padStart(2, '0')); $event.dataTransfer.effectAllowed = 'copy';"></div>
<div class="sb-desc-text">
<textarea x-model="desc.text" rows="2" placeholder="기능 설명을 입력하세요..." @input="autoSave()"></textarea>
</div>
<button class="sb-desc-remove" @click="sbCurrentPage.descriptions.splice(idx, 1); autoSave();">&times;</button>
</div>
</template>
<div class="sb-desc-add" @click="sbAddDescription()">+ Description 항목 추가</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>{{-- /Main Content Area --}}
</div>
{{-- Menu Tree Editor Modal --}}
<template x-if="sbMenuEditorOpen">
<div class="sb-menu-modal-overlay" @click.self="sbMenuEditorOpen = false" @keydown.escape.window="sbMenuEditorOpen = false">
<div class="sb-menu-modal" @click.stop>
<div class="sb-menu-modal-header">
<h3>ERP 메뉴 트리 편집</h3>
<button class="sb-mt-btn" @click="sbMenuEditorOpen = false" style="width:28px;height:28px;font-size:16px;">×</button>
</div>
<div class="sb-menu-modal-body">
<template x-for="(menu, mi) in sbMenuDraft" :key="mi">
<div>
<div class="sb-mt-item" draggable="true"
@dragstart="sbMenuDragStart('root', mi, $event)"
@dragover.prevent="sbMenuDragOver('root', mi, $event)"
@drop="sbMenuDrop('root', mi, $event)"
@dragend="sbMenuDragEnd()">
<span class="sb-mt-drag" title="드래그하여 순서 변경"></span>
<span class="sb-mt-icon" @click="sbMenuToggle(mi)" x-text="(menu.children && menu.children.length) ? (menu._open !== false ? '▾' : '▸') : '·'"></span>
<input type="text" class="sb-mt-name" x-model="menu.name" placeholder="메뉴명 입력" style="font-weight:600;">
<div class="sb-mt-actions">
<button class="sb-mt-btn" @click="sbMenuAddChild(mi)" title="하위 메뉴 추가">+</button>
<button class="sb-mt-btn danger" @click="sbMenuRemove(mi)" title="삭제">×</button>
</div>
</div>
<div class="sb-mt-children" x-show="menu._open !== false">
<template x-for="(child, ci) in (menu.children || [])" :key="ci">
<div class="sb-mt-item" draggable="true"
@dragstart="sbMenuDragStart('child', mi + '-' + ci, $event)"
@dragover.prevent="sbMenuDragOver('child', mi + '-' + ci, $event)"
@drop="sbMenuDrop('child', mi + '-' + ci, $event)"
@dragend="sbMenuDragEnd()">
<span class="sb-mt-drag" title="드래그하여 순서 변경"></span>
<span class="sb-mt-icon" style="color:#cbd5e1;"></span>
<input type="text" class="sb-mt-name" x-model="child.name" placeholder="하위 메뉴명">
<div class="sb-mt-actions">
<button class="sb-mt-btn danger" @click="menu.children.splice(ci, 1)" title="삭제">×</button>
</div>
</div>
</template>
<div class="sb-mt-add-root" style="padding:4px 0 2px; font-size:11px;" @click="sbMenuAddChild(mi)">+ 하위 메뉴 추가</div>
</div>
</div>
</template>
<div class="sb-mt-add-root" @click="sbMenuDraft.push({ name: '', children: [], _open: true })">+ 상위 메뉴 추가</div>
</div>
<div class="sb-menu-modal-footer">
<button style="padding:6px 16px; border:1px solid #e2e8f0; border-radius:6px; font-size:12px; cursor:pointer; background:#fff; color:#64748b;"
@click="sbMenuEditorOpen = false">취소</button>
<button style="padding:6px 16px; border:none; border-radius:6px; font-size:12px; cursor:pointer; background:var(--pc-indigo); color:#fff; font-weight:600;"
@click="sbMenuApply()">적용</button>
</div>
</div>
</div>
</template>
{{-- Node Detail Modal --}}
<template x-if="modalNode">
<div class="pc-modal-overlay" @click.self="closeNodeModal()" @keydown.escape.window="closeNodeModal()">
<div class="pc-modal">
<div class="pc-modal-header">
<span class="emoji" x-text="modalNode.emoji || '📌'"></span>
<input type="text" x-model="modalNode.title" placeholder="제목을 입력하세요" @input="onModalChange()">
<button class="pc-modal-close" @click="closeNodeModal()">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="pc-modal-body">
<div class="pc-modal-row">
<div class="pc-modal-label">상태</div>
<div class="pc-modal-field">
<select x-model="modalNode.status" @change="onModalChange()">
<option value="todo">대기</option>
<option value="progress">진행중</option>
<option value="review">검토중</option>
<option value="done">완료</option>
</select>
</div>
</div>
<div class="pc-modal-row">
<div class="pc-modal-label">우선순위</div>
<div class="pc-modal-field">
<select x-model="modalNode.priority" @change="onModalChange()">
<option value="low">낮음</option>
<option value="medium">보통</option>
<option value="high">높음</option>
<option value="critical">긴급</option>
</select>
</div>
</div>
<div class="pc-modal-row">
<div class="pc-modal-label">담당자</div>
<div class="pc-modal-field">
<input type="text" x-model="modalNode.assignee" placeholder="담당자 이름" @input="onModalChange()">
</div>
</div>
<div class="pc-modal-row">
<div class="pc-modal-label">마감일</div>
<div class="pc-modal-field">
<input type="date" x-model="modalNode.dueDate" @change="onModalChange()">
</div>
</div>
<div class="pc-modal-row">
<div class="pc-modal-label">태그</div>
<div class="pc-modal-field">
<input type="text" :value="(modalNode.tags||[]).join(', ')" placeholder="콤마로 구분"
@change="modalNode.tags = $event.target.value.split(',').map(s=>s.trim()).filter(Boolean); onModalChange()">
</div>
</div>
<div class="pc-modal-row">
<div class="pc-modal-label">설명</div>
<div class="pc-modal-field">
<textarea x-model="modalNode.description" placeholder="상세 설명을 입력하세요..." rows="4" @input="onModalChange()"></textarea>
</div>
</div>
<div class="pc-modal-row">
<div class="pc-modal-label">체크리스트</div>
<div class="pc-modal-field">
<template x-if="modalNode.checklist && modalNode.checklist.length > 0">
<div class="pc-checklist-progress">
<div class="pc-checklist-progress-bar" :style="'width:' + checklistProgress(modalNode) + '%'"></div>
</div>
</template>
<ul class="pc-checklist">
<template x-for="(item, idx) in (modalNode.checklist || [])" :key="idx">
<li class="pc-checklist-item">
<input type="checkbox" x-model="item.done" @change="onModalChange()">
<input type="text" class="text" :class="{ done: item.done }" x-model="item.text" @input="onModalChange()" placeholder="할 일 입력...">
<button class="remove" @click="modalNode.checklist.splice(idx,1); onModalChange();">&times;</button>
</li>
</template>
</ul>
<div class="pc-checklist-add" @click="addChecklistItem()">+ 항목 추가</div>
</div>
</div>
<div class="pc-modal-row">
<div class="pc-modal-label">색상</div>
<div class="pc-modal-field">
<div class="pc-color-swatches">
<template x-for="c in nodeColors" :key="c.value">
<div class="pc-color-swatch"
:class="{ active: modalNode.color === c.value }"
:style="'background:' + c.value"
@click="modalNode.color = c.value; onModalChange()"></div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
{{-- Context Menu --}}
<div class="pc-context-menu" id="contextMenu" @click.away="hideContextMenu()">
<div class="pc-cm-item" @click="duplicateNode(); hideContextMenu();">복제</div>
<div class="pc-cm-item" @click="bringToFront(); hideContextMenu();"> 앞으로</div>
<div class="pc-cm-sep"></div>
<div class="pc-cm-item" @click="addNodeAtMouse(paletteItems.planning[0]); hideContextMenu();">빠른 노드 추가</div>
<div class="pc-cm-sep"></div>
<div class="pc-cm-item danger" x-show="selectedConnection" @click="deleteSelectedConnection(); hideContextMenu();">연결선 삭제</div>
<div class="pc-cm-item danger" x-show="selectedNode" @click="deleteSelectedNode(); hideContextMenu();">노드 삭제</div>
</div>
</div>
@endsection
@push('scripts')
<script>
function planningCanvas() {
let idCounter = 0;
const STORAGE_KEY = 'pc_projects';
const CURRENT_KEY = 'pc_current';
return {
// State
projectTitle: '새 기획 프로젝트',
currentProjectId: null,
nodes: [],
connections: [],
savedProjects: [],
// Tools
tool: 'select',
viewMode: 'free',
sidebarOpen: true,
sidebarTab: 'palette',
zoom: 1,
panX: 0,
panY: 0,
// Selection
selectedNode: null,
selectedConnection: null,
modalNode: null,
// Filters
filterText: '',
filterStatus: '',
filterPriority: '',
filterType: '',
// List sort
listSortKey: 'title',
listSortDir: 'asc',
// Kanban
kanbanColumns: [
{ key: 'todo', label: '대기', color: '#94a3b8' },
{ key: 'progress', label: '진행중', color: '#3b82f6' },
{ key: 'review', label: '검토중', color: '#f59e0b' },
{ key: 'done', label: '완료', color: '#10b981' },
],
_kanbanDragNodeId: null,
_connTick: 0,
// Storyboard
sbMenuEditorOpen: false,
sbMenuDraft: [],
_sbMenuDrag: null,
sbSelectedBlock: null,
sbEditingBlock: null,
sbBlockImageTarget: null,
_sbBlockDragIdx: null,
_sbDrag: null, // { blk, startX, startY, origX, origY }
_sbResize: null, // { blk, dir, startX, startY, origW, origH }
_sbClipboard: null, // copied block data
sbMenuWidth: 160,
sbDescHeight: 200,
sbMarkerNum: '01',
_sbMenuResize: null,
_sbDescResize: null,
sbMultiSelected: [], // 다중 선택 블록 id 배열
_sbLasso: null, // { startX, startY, rx, ry, rw, rh }
_sbLassoDone: false, // 올가미 완료 직후 click.self 방지 플래그
_sbMultiDrag: null, // { startX, startY, origins: [{id, x, y}] }
_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: '',
sbTplSaveName: '',
sbCustomTemplates: [],
sb: {
docInfo: { projectName: '', unitTask: '', version: 'D1.0' },
menuTree: [
{ name: '대시보드', children: [] },
{ name: '판매관리', children: [] },
{ name: '생산관리', children: [] },
{ name: '출고관리', children: [] },
{ name: '품질관리', children: [
{ name: '제품검사관리' },
{ name: '실적신고관리' },
{ name: '품질인정심사' },
]},
{ name: '자재관리', children: [] },
{ name: '기준정보', children: [] },
],
pages: [],
currentPageIndex: 0,
},
// Drag State
dragging: false,
dragNode: null,
dragOffsetX: 0,
dragOffsetY: 0,
panning: false,
panStartX: 0,
panStartY: 0,
spaceHeld: false,
// Connection Drawing
drawingConnection: false,
connSource: null,
connSourcePort: null,
tempConnectionPath: '',
contextMenuPos: { x: 0, y: 0 },
// Undo/Redo
history: [],
historyIndex: -1,
// Phases for timeline
phases: [
{ id: 'discover', name: '발견 (Discover)' },
{ id: 'define', name: '정의 (Define)' },
{ id: 'design', name: '설계 (Design)' },
{ id: 'develop', name: '개발 (Develop)' },
{ id: 'deliver', name: '검증 (Deliver)' },
],
// Colors
nodeColors: [
{ value: '#6366f1', label: 'Indigo' },
{ value: '#3b82f6', label: 'Blue' },
{ value: '#10b981', label: 'Emerald' },
{ value: '#f59e0b', label: 'Amber' },
{ value: '#f43f5e', label: 'Rose' },
{ value: '#8b5cf6', label: 'Violet' },
{ value: '#06b6d4', label: 'Cyan' },
{ value: '#64748b', label: 'Slate' },
],
// Node Palette
paletteItems: {
planning: [
{ type: 'vision', label: '비전/목표', emoji: '🎯', bg: '#eef2ff', color: '#6366f1', typeLabel: '비전' },
{ type: 'persona', label: '페르소나', emoji: '👤', bg: '#fef3c7', color: '#f59e0b', typeLabel: '페르소나' },
{ type: 'problem', label: '문제정의', emoji: '❗', bg: '#fce7f3', color: '#ec4899', typeLabel: '문제' },
{ type: 'hypothesis', label: '가설', emoji: '💡', bg: '#ecfdf5', color: '#10b981', typeLabel: '가설' },
{ type: 'requirement', label: '요구사항', emoji: '📋', bg: '#ede9fe', color: '#8b5cf6', typeLabel: '요구사항' },
{ type: 'constraint', label: '제약조건', emoji: '🔒', bg: '#fef2f2', color: '#ef4444', typeLabel: '제약' },
],
analysis: [
{ type: 'usecase', label: '유스케이스', emoji: '🔄', bg: '#dbeafe', color: '#3b82f6', typeLabel: 'UC' },
{ type: 'userflow', label: '사용자흐름', emoji: '🚶', bg: '#e0f2fe', color: '#0ea5e9', typeLabel: 'Flow' },
{ type: 'datamodel', label: '데이터모델', emoji: '🗃️', bg: '#f0fdf4', color: '#22c55e', typeLabel: 'Data' },
{ type: 'api', label: 'API 설계', emoji: '🔌', bg: '#fefce8', color: '#eab308', typeLabel: 'API' },
],
structure: [
{ type: 'decision', label: '의사결정', emoji: '⚖️', bg: '#fff7ed', color: '#f97316', typeLabel: '결정' },
{ type: 'milestone', label: '마일스톤', emoji: '🏁', bg: '#ede9fe', color: '#7c3aed', typeLabel: '마일스톤' },
{ type: 'phase', label: '단계/Phase', emoji: '📐', bg: '#e0e7ff', color: '#4f46e5', typeLabel: 'Phase' },
{ type: 'note', label: '메모', emoji: '📝', bg: '#f8fafc', color: '#64748b', typeLabel: '메모' },
],
output: [
{ type: 'wireframe', label: '와이어프레임', emoji: '🖼️', bg: '#ecfeff', color: '#06b6d4', typeLabel: 'WF' },
{ type: 'prototype', label: '프로토타입', emoji: '🧪', bg: '#faf5ff', color: '#a855f7', typeLabel: 'Proto' },
{ type: 'document', label: '문서/산출물', emoji: '📄', bg: '#f0f9ff', color: '#0284c7', typeLabel: '문서' },
{ type: 'presentation', label: '발표자료', emoji: '📊', bg: '#fffbeb', color: '#d97706', typeLabel: 'PPT' },
],
},
get sbCurrentPage() {
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 sbPresetTemplates() {
return [
{ name: '검색 + 목록 화면', icon: '🔍', desc: '검색조건 + 데이터 테이블', blocks: [
{ type: 'heading2', content: '검색 조건' },
{ type: 'input', label: '검색어', placeholder: '품명, 품번 등을 입력하세요' },
{ type: 'badges', items: [
{ text: '검색', color: '#4338ca', textColor: '#fff' },
{ text: '초기화', color: '#f1f5f9', textColor: '#475569' },
]},
{ type: 'divider', content: '' },
{ type: 'heading2', content: '목록' },
{ type: 'table', cols: ['No', '항목명', '상태', '담당자', '등록일', '비고'], rows: [
['1', '샘플 항목 A', '진행중', '홍길동', '2026-03-01', ''],
['2', '샘플 항목 B', '대기', '김철수', '2026-03-02', ''],
['3', '', '', '', '', ''],
]},
{ type: 'badges', items: [
{ text: '총 3건', color: '#e0e7ff', textColor: '#4338ca' },
]},
]},
{ name: '상세 정보 폼', icon: '📝', desc: '라벨+입력 필드 그룹', blocks: [
{ type: 'heading', content: '상세 정보' },
{ type: 'input', label: '품명', placeholder: '품명을 입력하세요' },
{ type: 'input', label: '품번', placeholder: '품번을 입력하세요' },
{ type: 'select', label: '분류', placeholder: '분류를 선택하세요' },
{ type: 'input', label: '규격', placeholder: '규격 정보' },
{ type: 'text', content: '' },
{ type: 'divider', content: '' },
{ type: 'badges', items: [
{ text: '저장', color: '#4338ca', textColor: '#fff' },
{ text: '취소', color: '#f1f5f9', textColor: '#475569' },
]},
]},
{ name: 'CRUD 화면', icon: '⚙️', desc: '제목 + 검색 + 테이블 + 버튼 조합', blocks: [
{ type: 'heading', content: '관리 화면 제목' },
{ type: 'text', content: '화면 설명 텍스트를 입력하세요.' },
{ type: 'divider', content: '' },
{ type: 'heading2', content: '검색 조건' },
{ type: 'input', label: '검색어', placeholder: '검색어 입력' },
{ type: 'select', label: '상태', placeholder: '전체' },
{ type: 'badges', items: [
{ text: '검색', color: '#4338ca', textColor: '#fff' },
{ text: '초기화', color: '#f1f5f9', textColor: '#475569' },
{ text: '+ 신규등록', color: '#10b981', textColor: '#fff' },
{ text: '엑셀 다운로드', color: '#f1f5f9', textColor: '#475569' },
]},
{ type: 'divider', content: '' },
{ type: 'table', cols: ['선택', 'No', '항목명', '분류', '상태', '담당자', '수정일'], rows: [
['☐', '1', '', '', '', '', ''],
['☐', '2', '', '', '', '', ''],
]},
{ type: 'badges', items: [
{ text: '선택 삭제', color: '#fef2f2', textColor: '#ef4444' },
{ text: '일괄 수정', color: '#e0e7ff', textColor: '#4338ca' },
]},
]},
{ name: '대시보드 카드', icon: '📊', desc: '통계 카드 + 요약 테이블', blocks: [
{ type: 'heading', content: '대시보드' },
{ type: 'card', title: '총 주문건', content: '1,234건 (+12.5% ↑)' },
{ type: 'card', title: '매출 합계', content: '₩85,600,000' },
{ type: 'card', title: '미처리 건수', content: '23건' },
{ type: 'divider', content: '' },
{ type: 'heading2', content: '최근 현황' },
{ type: 'table', cols: ['구분', '이번 달', '전월', '증감률'], rows: [
['수주', '450건', '380건', '+18.4%'],
['출하', '420건', '395건', '+6.3%'],
['반품', '12건', '18건', '-33.3%'],
]},
]},
{ name: '결재/승인 폼', icon: '✅', desc: '결재라인 + 체크리스트 + 버튼', blocks: [
{ type: 'heading', content: '결재 요청서' },
{ type: 'table', cols: ['구분', '기안자', '검토', '승인'], rows: [
['직급', '사원', '과장', '부장'],
['성명', '', '', ''],
['상태', '기안', '대기', '대기'],
]},
{ type: 'divider', content: '' },
{ type: 'heading2', content: '요청 내용' },
{ type: 'text', content: '결재 요청 상세 내용을 입력하세요.' },
{ type: 'divider', content: '' },
{ type: 'todo', items: [
{ text: '첨부파일 확인', checked: false },
{ text: '금액 확인', checked: false },
{ text: '기안 내용 검토', checked: false },
]},
{ type: 'badges', items: [
{ text: '결재 요청', color: '#4338ca', textColor: '#fff' },
{ text: '임시 저장', color: '#f1f5f9', textColor: '#475569' },
]},
]},
{ name: '탭 레이아웃', icon: '📑', desc: '탭 메뉴 + 콘텐츠 영역', blocks: [
{ type: 'heading', content: '화면 제목' },
{ type: 'badges', items: [
{ text: '기본정보', color: '#4338ca', textColor: '#fff' },
{ text: 'BOM', color: '#f1f5f9', textColor: '#475569' },
{ text: '이력', color: '#f1f5f9', textColor: '#475569' },
{ text: '첨부파일', color: '#f1f5f9', textColor: '#475569' },
]},
{ type: 'divider', content: '' },
{ type: 'callout', icon: '', content: '선택한 탭의 콘텐츠가 여기에 표시됩니다.' },
{ type: 'text', content: '' },
]},
{ name: '팝업/모달', icon: '💬', desc: '모달 다이얼로그 UI', blocks: [
{ type: 'card', title: '모달 제목', content: '' },
{ type: 'text', content: '모달 본문 내용을 입력하세요.' },
{ type: 'input', label: '입력 항목', placeholder: '값을 입력하세요' },
{ type: 'divider', content: '' },
{ type: 'badges', items: [
{ text: '확인', color: '#4338ca', textColor: '#fff' },
{ text: '취소', color: '#f1f5f9', textColor: '#475569' },
]},
]},
{ name: '로그인 화면', icon: '🔐', desc: '로그인 폼 UI', blocks: [
{ type: 'heading', content: '로그인' },
{ type: 'text', content: '시스템에 접속하려면 로그인하세요.' },
{ type: 'input', label: '아이디', placeholder: 'user@company.com' },
{ type: 'input', label: '비밀번호', placeholder: '••••••••' },
{ type: 'todo', items: [
{ text: '자동 로그인', checked: false },
]},
{ type: 'button', content: '로그인', color: '#4338ca' },
{ type: 'text', content: '비밀번호를 잊으셨나요?' },
]},
{ name: '빈 페이지 (기본 구조)', icon: '📄', desc: '제목 + 설명 + 구분선', blocks: [
{ type: 'heading', content: '' },
{ type: 'text', content: '' },
{ type: 'divider', content: '' },
]},
];
},
get sbFilteredPresets() {
const q = this.sbTplSearch.toLowerCase().trim();
if (!q) return this.sbPresetTemplates;
return this.sbPresetTemplates.filter(t => t.name.toLowerCase().includes(q) || t.desc.toLowerCase().includes(q));
},
get sbFilteredCustoms() {
const q = this.sbTplSearch.toLowerCase().trim();
if (!q) return this.sbCustomTemplates;
return this.sbCustomTemplates.filter(t => t.name.toLowerCase().includes(q));
},
get allPaletteItems() {
return [
...this.paletteItems.planning,
...this.paletteItems.analysis,
...this.paletteItems.structure,
...this.paletteItems.output,
];
},
get filteredNodes() {
return this.nodes.filter(n => {
if (this.filterStatus && n.status !== this.filterStatus) return false;
if (this.filterPriority && n.priority !== this.filterPriority) return false;
if (this.filterType && n.type !== this.filterType) return false;
if (this.filterText) {
const q = this.filterText.toLowerCase();
if (!(n.title||'').toLowerCase().includes(q) &&
!(n.description||'').toLowerCase().includes(q) &&
!(n.assignee||'').toLowerCase().includes(q) &&
!(n.tags||[]).some(t => t.toLowerCase().includes(q))) return false;
}
return true;
});
},
get sortedFilteredNodes() {
const items = [...this.filteredNodes];
const key = this.listSortKey;
const dir = this.listSortDir === 'asc' ? 1 : -1;
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
const statusOrder = { todo: 0, progress: 1, review: 2, done: 3 };
items.sort((a, b) => {
let va = a[key] || '', vb = b[key] || '';
if (key === 'priority') { va = priorityOrder[va] ?? 9; vb = priorityOrder[vb] ?? 9; }
else if (key === 'status') { va = statusOrder[va] ?? 9; vb = statusOrder[vb] ?? 9; }
else { va = String(va).toLowerCase(); vb = String(vb).toLowerCase(); }
return va < vb ? -dir : va > vb ? dir : 0;
});
return items;
},
init() {
this.loadSavedProjects();
const currentId = localStorage.getItem(CURRENT_KEY);
if (currentId && this.savedProjects.find(p => p.id === currentId)) {
this.loadProject(currentId);
} else {
this.newProject();
}
this.sbInitPages();
this.sbCustomTemplates = JSON.parse(localStorage.getItem('sb_custom_templates') || '[]');
},
// ===== Project Management =====
newProject() {
this.currentProjectId = 'proj_' + Date.now();
this.projectTitle = '새 기획 프로젝트';
this.nodes = [];
this.connections = [];
this.selectedNode = null;
this.selectedConnection = null;
this.history = [];
this.historyIndex = -1;
this.panX = 0;
this.panY = 0;
this.zoom = 1;
// 스토리보드 초기화
this.sb.docInfo = { projectName: '', unitTask: '', version: 'D1.0' };
this.sb.pages = [];
this.sb.currentPageIndex = 0;
this.sbInitPages();
localStorage.setItem(CURRENT_KEY, this.currentProjectId);
this.pushHistory();
},
saveProject() {
const proj = {
id: this.currentProjectId,
title: this.projectTitle,
nodes: JSON.parse(JSON.stringify(this.nodes)),
connections: JSON.parse(JSON.stringify(this.connections)),
sb: JSON.parse(JSON.stringify(this.sb)),
viewMode: this.viewMode,
nodeCount: this.nodes.length,
updatedAt: new Date().toISOString(),
};
let projects = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
const idx = projects.findIndex(p => p.id === proj.id);
if (idx >= 0) projects[idx] = proj;
else projects.unshift(proj);
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects));
localStorage.setItem(CURRENT_KEY, this.currentProjectId);
this.loadSavedProjects();
},
autoSave() {
clearTimeout(this._autoSaveTimer);
this._autoSaveTimer = setTimeout(() => this.saveProject(), 2000);
},
loadProject(id) {
const projects = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
const proj = projects.find(p => p.id === id);
if (!proj) return;
this.currentProjectId = proj.id;
this.projectTitle = proj.title;
this.nodes = proj.nodes || [];
this.connections = proj.connections || [];
this.viewMode = proj.viewMode || 'free';
this.selectedNode = null;
this.selectedConnection = null;
this.history = [];
this.historyIndex = -1;
// 스토리보드 데이터 복원
if (proj.sb) {
this.sb = JSON.parse(JSON.stringify(proj.sb));
} else {
this.sb.docInfo = { projectName: '', unitTask: '', version: 'D1.0' };
this.sb.pages = [];
this.sb.currentPageIndex = 0;
}
this.sbInitPages();
localStorage.setItem(CURRENT_KEY, id);
this.pushHistory();
idCounter = Math.max(0, ...this.nodes.map(n => parseInt(n.id?.split('_')[1]) || 0)) + 1;
},
deleteProject(id) {
if (!confirm('이 프로젝트를 삭제하시겠습니까?')) return;
let projects = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
projects = projects.filter(p => p.id !== id);
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects));
this.loadSavedProjects();
if (this.currentProjectId === id) this.newProject();
},
loadSavedProjects() {
this.savedProjects = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
},
exportProject() {
const data = {
title: this.projectTitle,
nodes: this.nodes,
connections: this.connections,
exportedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (this.projectTitle || 'planning') + '.json';
a.click();
URL.revokeObjectURL(url);
},
// ===== Node Operations =====
generateId() { return 'n_' + (++idCounter); },
addNode(paletteItem, x, y) {
const node = {
id: this.generateId(),
type: paletteItem.type,
typeLabel: paletteItem.typeLabel || paletteItem.label,
title: paletteItem.label,
description: '',
emoji: paletteItem.emoji,
bg: paletteItem.bg,
color: paletteItem.color || '#6366f1',
status: 'todo',
priority: 'medium',
tags: [],
assignee: '',
dueDate: '',
checklist: [],
x: x,
y: y,
};
this.nodes.push(node);
this.selectedNode = node;
this.sidebarTab = 'properties';
this.pushHistory();
this.autoSave();
return node;
},
addNodeAtCenter(paletteItem) {
const wrap = document.getElementById('canvasWrap');
const cx = (wrap.clientWidth / 2 - this.panX * this.zoom) / this.zoom;
const cy = (wrap.clientHeight / 2 - this.panY * this.zoom) / this.zoom;
this.addNode(paletteItem, cx - 80, cy - 40);
},
addNodeAtMouse(paletteItem) {
const x = (this.contextMenuPos.x - this.panX * this.zoom) / this.zoom;
const y = (this.contextMenuPos.y - this.panY * this.zoom) / this.zoom;
this.addNode(paletteItem, x, y);
},
deleteSelectedNode() {
if (!this.selectedNode) return;
const id = this.selectedNode.id;
this.connections = this.connections.filter(c => c.from !== id && c.to !== id);
this.nodes = this.nodes.filter(n => n.id !== id);
this.selectedNode = null;
this.pushHistory();
this.autoSave();
},
duplicateNode() {
if (!this.selectedNode) return;
const orig = this.selectedNode;
const dup = { ...JSON.parse(JSON.stringify(orig)), id: this.generateId(), x: orig.x + 30, y: orig.y + 30 };
this.nodes.push(dup);
this.selectedNode = dup;
this.pushHistory();
this.autoSave();
},
bringToFront() {
if (!this.selectedNode) return;
const idx = this.nodes.findIndex(n => n.id === this.selectedNode.id);
if (idx >= 0) {
const [node] = this.nodes.splice(idx, 1);
this.nodes.push(node);
}
},
// ===== Connection Rendering (SVG namespace 직접 제어) =====
renderConnections() {
// _connTick 읽기 → x-effect 의존성 등록 (노드 이동 시 리렌더 트리거)
void this._connTick;
void this.connections.length;
void this.selectedConnection;
void this.drawingConnection;
void this.tempConnectionPath;
const svg = this.$refs.connSvg;
if (!svg) return;
// 현재 내용 클리어
svg.innerHTML = '';
const NS = 'http://www.w3.org/2000/svg';
// 기존 연결선 (화살표 없이 단순 곡선)
const self = this;
this.connections.forEach(conn => {
const d = this.getConnectionPath(conn);
if (!d) return;
const path = document.createElementNS(NS, 'path');
path.setAttribute('d', d);
const isSel = this.selectedConnection?.id === conn.id;
path.setAttribute('stroke', isSel ? '#6366f1' : '#94a3b8');
path.setAttribute('stroke-width', isSel ? '3' : '2');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-linecap', 'round');
path.style.pointerEvents = 'stroke';
path.style.cursor = 'pointer';
path.addEventListener('click', () => { self.selectConnection(conn); });
svg.appendChild(path);
});
// 드래그 중인 임시 연결선
if (this.drawingConnection && this.tempConnectionPath) {
const temp = document.createElementNS(NS, 'path');
temp.setAttribute('d', this.tempConnectionPath);
temp.setAttribute('stroke', '#6366f1');
temp.setAttribute('stroke-width', '2');
temp.setAttribute('stroke-dasharray', '6 3');
temp.setAttribute('fill', 'none');
temp.setAttribute('stroke-linecap', 'round');
temp.style.pointerEvents = 'none';
svg.appendChild(temp);
}
},
// ===== Connection Operations =====
startConnection(e, node, port) {
if (this.tool !== 'connect' && this.tool !== 'select') return;
this.drawingConnection = true;
this.connSource = node;
this.connSourcePort = port;
e.preventDefault();
},
getPortPos(node, port) {
const el = document.getElementById('node-' + node.id);
if (!el) return { x: node.x, y: node.y };
const w = el.offsetWidth || 160;
const h = el.offsetHeight || 80;
switch (port) {
case 'top': return { x: node.x + w / 2, y: node.y };
case 'bottom': return { x: node.x + w / 2, y: node.y + h };
case 'left': return { x: node.x, y: node.y + h / 2 };
case 'right': return { x: node.x + w, y: node.y + h / 2 };
default: return { x: node.x + w / 2, y: node.y + h / 2 };
}
},
getConnectionPath(conn) {
const fromNode = this.nodes.find(n => n.id === conn.from);
const toNode = this.nodes.find(n => n.id === conn.to);
if (!fromNode || !toNode) return '';
const from = this.getPortPos(fromNode, conn.fromPort || 'right');
const to = this.getPortPos(toNode, conn.toPort || 'left');
const dx = Math.abs(to.x - from.x) * 0.5;
return `M ${from.x} ${from.y} C ${from.x + dx} ${from.y}, ${to.x - dx} ${to.y}, ${to.x} ${to.y}`;
},
selectConnection(conn) {
this.selectedConnection = conn;
this.selectedNode = null;
},
// ===== Canvas Events =====
onCanvasMouseDown(e) {
if (e.target.closest('.pc-node') || e.target.closest('.pc-port')) {
// 스페이스바 누른 채 노드 위에서도 패닝 가능
if (this.spaceHeld) {
this.panning = true;
this.panStartX = e.clientX - this.panX * this.zoom;
this.panStartY = e.clientY - this.panY * this.zoom;
document.getElementById('canvasWrap')?.style.setProperty('cursor', 'grabbing');
e.preventDefault();
}
return;
}
if (this.tool === 'pan' || this.spaceHeld || e.button === 1) {
this.panning = true;
this.panStartX = e.clientX - this.panX * this.zoom;
this.panStartY = e.clientY - this.panY * this.zoom;
document.getElementById('canvasWrap')?.style.setProperty('cursor', 'grabbing');
e.preventDefault();
} else {
this.selectedNode = null;
this.selectedConnection = null;
}
},
onCanvasMouseMove(e) {
if (this.panning) {
this.panX = (e.clientX - this.panStartX) / this.zoom;
this.panY = (e.clientY - this.panStartY) / this.zoom;
}
if (this.dragging && this.dragNode) {
const wrap = document.getElementById('canvasWrap');
const rect = wrap.getBoundingClientRect();
this.dragNode.x = (e.clientX - rect.left - this.panX * this.zoom) / this.zoom - this.dragOffsetX;
this.dragNode.y = (e.clientY - rect.top - this.panY * this.zoom) / this.zoom - this.dragOffsetY;
this._connTick++;
}
if (this.drawingConnection && this.connSource) {
const wrap = document.getElementById('canvasWrap');
const rect = wrap.getBoundingClientRect();
const mx = (e.clientX - rect.left - this.panX * this.zoom) / this.zoom;
const my = (e.clientY - rect.top - this.panY * this.zoom) / this.zoom;
const from = this.getPortPos(this.connSource, this.connSourcePort);
const dx = Math.abs(mx - from.x) * 0.5;
this.tempConnectionPath = `M ${from.x} ${from.y} C ${from.x + dx} ${from.y}, ${mx - dx} ${my}, ${mx} ${my}`;
}
},
onCanvasMouseUp(e) {
if (this.dragging) {
this.dragging = false;
this.dragNode = null;
this.pushHistory();
this.autoSave();
}
if (this.panning) {
this.panning = false;
const wrap = document.getElementById('canvasWrap');
if (this.spaceHeld) wrap?.style.setProperty('cursor', 'grab');
else wrap?.style.removeProperty('cursor');
}
if (this.drawingConnection) {
// Check if dropped on a node port
const target = e.target.closest('.pc-node');
if (target && this.connSource) {
const targetId = target.id.replace('node-', '');
if (targetId !== this.connSource.id) {
// Detect closest port
const port = e.target.classList.contains('pc-port-top') ? 'top'
: e.target.classList.contains('pc-port-bottom') ? 'bottom'
: e.target.classList.contains('pc-port-left') ? 'left'
: 'left';
const exists = this.connections.some(c => c.from === this.connSource.id && c.to === targetId);
if (!exists) {
this.connections.push({
id: 'c_' + Date.now(),
from: this.connSource.id,
to: targetId,
fromPort: this.connSourcePort,
toPort: port,
});
this.pushHistory();
this.autoSave();
}
}
}
this.drawingConnection = false;
this.connSource = null;
this.tempConnectionPath = '';
}
},
onNodeMouseDown(e, node) {
this.selectedNode = node;
this.selectedConnection = null;
this.sidebarTab = 'properties';
if (this.tool === 'select' || this.tool === 'pan') {
this.dragging = true;
this.dragNode = node;
const wrap = document.getElementById('canvasWrap');
const rect = wrap.getBoundingClientRect();
this.dragOffsetX = (e.clientX - rect.left - this.panX * this.zoom) / this.zoom - node.x;
this.dragOffsetY = (e.clientY - rect.top - this.panY * this.zoom) / this.zoom - node.y;
}
},
onCanvasWheel(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
this.zoom = Math.min(3, Math.max(0.2, this.zoom + delta));
},
// ===== Palette Drag & Drop =====
_dragPaletteItem: null,
onPaletteDragStart(e, item) {
this._dragPaletteItem = item;
e.dataTransfer.effectAllowed = 'copy';
},
onCanvasDrop(e) {
if (!this._dragPaletteItem) return;
const wrap = document.getElementById('canvasWrap');
const rect = wrap.getBoundingClientRect();
const x = (e.clientX - rect.left - this.panX * this.zoom) / this.zoom;
const y = (e.clientY - rect.top - this.panY * this.zoom) / this.zoom;
this.addNode(this._dragPaletteItem, x - 80, y - 30);
this._dragPaletteItem = null;
},
// ===== View Modes =====
switchView(mode) {
this.viewMode = mode;
if (mode === 'timeline') this.layoutTimeline();
else if (mode === 'flow') this.layoutFlow();
},
layoutTimeline() {
if (this.nodes.length === 0) return;
this.nodes.forEach((node, i) => {
const col = i % this.phases.length;
const row = Math.floor(i / this.phases.length);
node.x = col * 400 + 140;
node.y = row * 140 + 60;
});
this.pushHistory();
},
layoutFlow() {
if (this.nodes.length === 0) return;
const cols = Math.ceil(Math.sqrt(this.nodes.length));
this.nodes.forEach((node, i) => {
node.x = (i % cols) * 240 + 100;
node.y = Math.floor(i / cols) * 160 + 60;
});
this.pushHistory();
},
// ===== Zoom =====
zoomIn() { this.zoom = Math.min(3, this.zoom + 0.1); },
zoomOut() { this.zoom = Math.max(0.2, this.zoom - 0.1); },
zoomFit() {
if (this.nodes.length === 0) { this.zoom = 1; this.panX = 0; this.panY = 0; return; }
const wrap = document.getElementById('canvasWrap');
const minX = Math.min(...this.nodes.map(n => n.x)) - 50;
const minY = Math.min(...this.nodes.map(n => n.y)) - 50;
const maxX = Math.max(...this.nodes.map(n => n.x + 200)) + 50;
const maxY = Math.max(...this.nodes.map(n => n.y + 120)) + 50;
const w = maxX - minX;
const h = maxY - minY;
this.zoom = Math.min(1.5, Math.min(wrap.clientWidth / w, wrap.clientHeight / h));
this.panX = -minX + 20 / this.zoom;
this.panY = -minY + 20 / this.zoom;
},
// ===== Undo/Redo =====
pushHistory() {
const state = JSON.stringify({ nodes: this.nodes, connections: this.connections });
this.history = this.history.slice(0, this.historyIndex + 1);
this.history.push(state);
this.historyIndex = this.history.length - 1;
if (this.history.length > 50) { this.history.shift(); this.historyIndex--; }
},
undoAction() {
if (this.historyIndex <= 0) return;
this.historyIndex--;
this._restoreState();
},
redoAction() {
if (this.historyIndex >= this.history.length - 1) return;
this.historyIndex++;
this._restoreState();
},
_restoreState() {
const state = JSON.parse(this.history[this.historyIndex]);
this.nodes = state.nodes;
this.connections = state.connections;
this.selectedNode = null;
this.selectedConnection = null;
},
// ===== Keyboard Shortcuts =====
handleKeyDown(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
// 스페이스바: 패닝 모드 (누르고 있는 동안)
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
if (!this.spaceHeld) {
this.spaceHeld = true;
this._toolBeforeSpace = this.tool;
this.tool = 'pan';
document.getElementById('canvasWrap')?.style.setProperty('cursor', 'grab');
}
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (this.selectedConnection) {
this.deleteSelectedConnection();
} else {
this.deleteSelectedNode();
}
}
if (e.key === 'v' || e.key === 'V') this.tool = 'select';
if (e.key === 'h' || e.key === 'H') this.tool = 'pan';
if (e.key === 'c' || e.key === 'C') this.tool = 'connect';
if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); this.viewMode === 'storyboard' ? this.sbUndo() : this.undoAction(); return; }
if ((e.ctrlKey || e.metaKey) && e.key === 'y') { e.preventDefault(); this.viewMode === 'storyboard' ? this.sbRedo() : this.redoAction(); return; }
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); this.saveProject(); }
if ((e.ctrlKey || e.metaKey) && e.key === 'd') { e.preventDefault(); this.duplicateNode(); }
// 스토리보드 블록 Ctrl+C / Ctrl+V / Delete (단일 + 다중)
if (this.viewMode === 'storyboard' && (this.sbSelectedBlock || this.sbMultiSelected.length > 0)) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C')) {
e.preventDefault();
this.sbCopyBlock();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'x' || e.key === 'X')) {
e.preventDefault();
this.sbCutBlock();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V')) {
e.preventDefault();
this.sbPasteBlock();
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
this.sbDeleteSelectedBlock();
return;
}
}
if (this.viewMode === 'storyboard' && !this.sbSelectedBlock && this.sbMultiSelected.length === 0) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V') && this._sbClipboard) {
e.preventDefault();
this.sbPasteBlock();
return;
}
}
// Ctrl+A 전체 선택
if (this.viewMode === 'storyboard' && (e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
const page = this.sbCurrentPage;
if (page && page.blocks && page.blocks.length > 0) {
e.preventDefault();
this.sbSelectedBlock = null;
this.sbMultiSelected = page.blocks.map(b => b.id);
return;
}
}
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
if (this.viewMode === 'kanban' || this.viewMode === 'list') {
e.preventDefault();
this.$nextTick(() => {
const input = document.querySelector('.pc-filter-bar input[type="text"]');
if (input) input.focus();
});
}
}
},
handleKeyUp(e) {
if (e.key === ' ' || e.code === 'Space') {
if (this.spaceHeld) {
this.spaceHeld = false;
this.panning = false;
this.tool = this._toolBeforeSpace || 'select';
document.getElementById('canvasWrap')?.style.removeProperty('cursor');
}
}
},
// ===== Connection Delete =====
deleteSelectedConnection() {
if (!this.selectedConnection) return;
this.connections = this.connections.filter(c => c.id !== this.selectedConnection.id);
this.selectedConnection = null;
this.pushHistory();
this.autoSave();
},
// ===== Storyboard =====
sbInitPages() {
if (!this.sb.pages || this.sb.pages.length === 0) {
this.sb.pages = [this.sbNewPageData()];
this.sb.currentPageIndex = 0;
}
},
sbNewPageData() {
return {
id: 'sp_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
path: '',
screenName: '',
screenId: '',
wireframeContent: '',
wireframeImage: '',
blocks: [],
descriptions: [],
};
},
sbAddPage() {
const newPage = this.sbNewPageData();
this.sb.pages.push(newPage);
this.sb.currentPageIndex = this.sb.pages.length - 1;
this.autoSave();
},
sbDuplicatePage() {
const page = this.sbCurrentPage;
if (!page) return;
const copy = JSON.parse(JSON.stringify(page));
copy.id = 'pg_' + Date.now();
copy.screenName = (copy.screenName || '화면') + ' (복사)';
// 블록 id도 새로 생성
if (copy.blocks) {
copy.blocks.forEach(blk => {
blk.id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
});
}
this.sb.pages.splice(this.sb.currentPageIndex + 1, 0, copy);
this.sb.currentPageIndex = this.sb.currentPageIndex + 1;
this.autoSave();
},
sbDeletePage() {
if (this.sb.pages.length <= 1) return;
if (!confirm('이 페이지를 삭제하시겠습니까?')) return;
this.sb.pages.splice(this.sb.currentPageIndex, 1);
if (this.sb.currentPageIndex >= this.sb.pages.length) {
this.sb.currentPageIndex = this.sb.pages.length - 1;
}
this.autoSave();
},
sbPrevPage() {
if (this.sb.currentPageIndex > 0) this.sb.currentPageIndex--;
},
sbNextPage() {
if (this.sb.currentPageIndex < this.sb.pages.length - 1) this.sb.currentPageIndex++;
},
sbAddDescription() {
const page = this.sbCurrentPage;
if (!page) return;
if (!page.descriptions) page.descriptions = [];
page.descriptions.push({ text: '' });
this.autoSave();
},
// ===== Block Editor =====
sbNewBlock(type) {
const id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
// 자동 배치: 기존 블록 아래에 겹치지 않게
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, marker: 32 };
const defH = { heading: 40, heading2: 36, text: 50, divider: 20, table: 140, card: 90, button: 50, input: 70, select: 70, callout: 60, code: 80, badges: 50, todo: 80, image: 200, marker: 32 };
const base = { id, type, content: '', x: 16, y: autoY, w: defW[type] || 240, h: defH[type] || 40 };
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: '' };
case 'marker': return { ...base, content: base.content || '01' };
default: return base;
}
},
sbAddBlock(type) {
const page = this.sbCurrentPage;
if (!page) return;
if (!page.blocks) page.blocks = [];
this.sbPushHistory();
const blk = this.sbNewBlock(type);
page.blocks.push(blk);
this.sbSelectedBlock = blk.id;
this.autoSave();
},
sbAddMarkerBlock() {
const num = this.sbMarkerNum || '01';
const page = this.sbCurrentPage;
if (!page) return;
if (!page.blocks) page.blocks = [];
this.sbPushHistory();
const blk = this.sbNewBlock('marker');
blk.content = num;
page.blocks.push(blk);
this.sbSelectedBlock = blk.id;
// 다음 번호 자동 증가
const n = parseInt(num, 10);
if (!isNaN(n)) this.sbMarkerNum = String(n + 1).padStart(2, '0');
this.autoSave();
},
sbDropMarker(e) {
const data = e.dataTransfer.getData('text/plain');
if (!data.startsWith('marker:')) return;
const num = data.slice(7);
const page = this.sbCurrentPage;
if (!page) return;
if (!page.blocks) page.blocks = [];
this.sbPushHistory();
const rect = this.$refs.sbCanvas.getBoundingClientRect();
const x = e.clientX - rect.left + this.$refs.sbCanvas.scrollLeft;
const y = e.clientY - rect.top + this.$refs.sbCanvas.scrollTop;
const blk = this.sbNewBlock('marker');
blk.content = num;
blk.x = Math.max(0, x - 14);
blk.y = Math.max(0, y - 14);
page.blocks.push(blk);
this.sbSelectedBlock = blk.id;
this.autoSave();
},
sbAddBlockAfter(idx, type) {
const page = this.sbCurrentPage;
if (!page || !page.blocks) return;
this.sbPushHistory();
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;
this.sbPushHistory();
page.blocks.splice(idx, 1);
this.sbSelectedBlock = null;
this.autoSave();
},
sbDuplicateBlock(idx) {
const page = this.sbCurrentPage;
if (!page || !page.blocks) return;
this.sbPushHistory();
const copy = JSON.parse(JSON.stringify(page.blocks[idx]));
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);
this.sbSelectedBlock = copy.id;
this.autoSave();
},
// ===== Free Canvas: Drag & Resize =====
sbBlockMouseDown(blk, bi, e) {
// 더블클릭 편집 모드에서는 드래그 안 함
if (this.sbEditingBlock === blk.id) return;
this.sbPushHistory();
// 다중 선택된 블록 중 하나를 클릭하면 그룹 드래그
if (this.sbMultiSelected.includes(blk.id)) {
const page = this.sbCurrentPage;
const origins = this.sbMultiSelected.map(id => {
const b = page.blocks.find(x => x.id === id);
return b ? { id: b.id, x: b.x || 0, y: b.y || 0 } : null;
}).filter(Boolean);
this._sbMultiDrag = { startX: e.clientX, startY: e.clientY, origins };
e.preventDefault();
return;
}
// 단일 선택
this.sbMultiSelected = [];
this.sbSelectedBlock = blk.id;
this.sbCtxMenu = null; // 컨텍스트 메뉴 닫기
this.sbFmtDropdown = null;
this._sbDrag = {
blk,
startX: e.clientX,
startY: e.clientY,
origX: blk.x || 0,
origY: blk.y || 0,
};
this.sbUpdateFormatBar();
e.preventDefault();
},
sbResizeStart(blk, dir, e) {
this.sbSelectedBlock = blk.id;
this.sbPushHistory();
this._sbResize = {
blk,
dir,
startX: e.clientX,
startY: e.clientY,
origW: blk.w || 240,
origH: blk.h || 40,
};
e.preventDefault();
},
sbCanvasMouseDown(e) {
// 블록이나 리사이즈 핸들 위가 아니면 올가미 시작
const onBlock = e.target.closest('.sb-block');
if (!onBlock) {
this.sbEditingBlock = null;
const rect = this.$refs.sbCanvas.getBoundingClientRect();
const scrollL = this.$refs.sbCanvas.scrollLeft;
const scrollT = this.$refs.sbCanvas.scrollTop;
const sx = e.clientX - rect.left + scrollL;
const sy = e.clientY - rect.top + scrollT;
this._sbLasso = { startX: sx, startY: sy, rx: sx, ry: sy, rw: 0, rh: 0 };
this.sbMultiSelected = [];
this.sbSelectedBlock = null;
}
},
sbCanvasMouseMove(e) {
// 드래그/리사이즈 중에는 서식 툴바 숨기기
if (this._sbDrag || this._sbResize || this._sbMultiDrag || this._sbLasso) {
this.sbFormatBar = null;
}
// 올가미 드래그
if (this._sbLasso) {
const rect = this.$refs.sbCanvas.getBoundingClientRect();
const scrollL = this.$refs.sbCanvas.scrollLeft;
const scrollT = this.$refs.sbCanvas.scrollTop;
const cx = e.clientX - rect.left + scrollL;
const cy = e.clientY - rect.top + scrollT;
const l = this._sbLasso;
l.rx = Math.min(l.startX, cx);
l.ry = Math.min(l.startY, cy);
l.rw = Math.abs(cx - l.startX);
l.rh = Math.abs(cy - l.startY);
return;
}
// 다중 블록 드래그
if (this._sbMultiDrag) {
const md = this._sbMultiDrag;
const dx = e.clientX - md.startX;
const dy = e.clientY - md.startY;
const page = this.sbCurrentPage;
if (page && page.blocks) {
md.origins.forEach(o => {
const blk = page.blocks.find(b => b.id === o.id);
if (blk) {
blk.x = Math.max(0, o.x + dx);
blk.y = Math.max(0, o.y + dy);
}
});
}
return;
}
// 단일 블록 드래그
if (this._sbDrag) {
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);
}
}
},
sbCanvasMouseUp(e) {
// 올가미 완료 → 범위 내 블록 선택
if (this._sbLasso) {
const l = this._sbLasso;
if (l.rw > 5 && l.rh > 5) {
const page = this.sbCurrentPage;
if (page && page.blocks) {
this.sbMultiSelected = page.blocks.filter(blk => {
const bx = blk.x || 0, by = blk.y || 0;
const bw = blk.w || 240, bh = blk.h || 40;
// 블록이 올가미 사각형과 겹치는지 체크
return bx + bw > l.rx && bx < l.rx + l.rw &&
by + bh > l.ry && by < l.ry + l.rh;
}).map(b => b.id);
}
// 1개만 선택되면 단일 선택으로 전환
if (this.sbMultiSelected.length === 1) {
this.sbSelectedBlock = this.sbMultiSelected[0];
this.sbMultiSelected = [];
}
}
this._sbLasso = null;
this._sbLassoDone = true;
return;
}
// 다중 드래그 완료
if (this._sbMultiDrag) {
this._sbMultiDrag = null;
this.autoSave();
return;
}
if (this._sbDrag) {
this._sbDrag = null;
this.autoSave();
this.sbUpdateFormatBar();
}
if (this._sbResize) {
this._sbResize = null;
this.autoSave();
this.sbUpdateFormatBar();
}
},
sbCopyBlock() {
const page = this.sbCurrentPage;
if (!page || !page.blocks) return;
// 다중 선택 복사
if (this.sbMultiSelected.length > 0) {
this._sbClipboard = this.sbMultiSelected.map(id => {
const b = page.blocks.find(x => x.id === id);
return b ? JSON.parse(JSON.stringify(b)) : null;
}).filter(Boolean);
return;
}
// 단일 복사
const blk = page.blocks.find(b => b.id === this.sbSelectedBlock);
if (!blk) return;
this._sbClipboard = JSON.parse(JSON.stringify(blk));
},
sbCutBlock() {
this.sbCopyBlock();
this.sbDeleteSelectedBlock();
},
sbPasteBlock() {
if (!this._sbClipboard) return;
const page = this.sbCurrentPage;
if (!page) return;
if (!page.blocks) page.blocks = [];
this.sbPushHistory();
// 다중 붙여넣기
if (Array.isArray(this._sbClipboard)) {
const newIds = [];
const copies = this._sbClipboard.map(b => {
const copy = JSON.parse(JSON.stringify(b));
copy.id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
copy.x = (copy.x || 0) + 24;
copy.y = (copy.y || 0) + 24;
newIds.push(copy.id);
return copy;
});
page.blocks.push(...copies);
this.sbSelectedBlock = null;
this.sbMultiSelected = newIds;
this._sbClipboard = copies; // 연속 오프셋 누적
this.autoSave();
return;
}
// 단일 붙여넣기
const copy = JSON.parse(JSON.stringify(this._sbClipboard));
copy.id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
copy.x = (copy.x || 0) + 24;
copy.y = (copy.y || 0) + 24;
page.blocks.push(copy);
this.sbSelectedBlock = copy.id;
this._sbClipboard = copy; // 연속 붙여넣기 시 오프셋 누적
this.autoSave();
},
sbDeleteSelectedBlock() {
const page = this.sbCurrentPage;
if (!page || !page.blocks) return;
this.sbPushHistory();
// 다중 삭제
if (this.sbMultiSelected.length > 0) {
page.blocks = page.blocks.filter(b => !this.sbMultiSelected.includes(b.id));
this.sbMultiSelected = [];
this.sbSelectedBlock = null;
this.autoSave();
return;
}
// 단일 삭제
const idx = page.blocks.findIndex(b => b.id === this.sbSelectedBlock);
if (idx < 0) return;
page.blocks.splice(idx, 1);
this.sbSelectedBlock = null;
this.autoSave();
},
// ===== Storyboard Undo/Redo =====
sbPushHistory() {
if (this._sbHistoryPaused) return;
const page = this.sbCurrentPage;
if (!page || !page.blocks) return;
const snap = JSON.parse(JSON.stringify(page.blocks));
// 현재 위치 이후의 히스토리 삭제 (새 분기)
if (this._sbHistoryIdx < this._sbHistory.length - 1) {
this._sbHistory = this._sbHistory.slice(0, this._sbHistoryIdx + 1);
}
this._sbHistory.push(snap);
// 최대 50개
if (this._sbHistory.length > 50) {
this._sbHistory = this._sbHistory.slice(this._sbHistory.length - 50);
}
this._sbHistoryIdx = this._sbHistory.length - 1;
},
sbUndo() {
if (this._sbHistory.length === 0) return;
const page = this.sbCurrentPage;
if (!page) return;
// 처음 undo 시 현재(변경 후) 상태를 히스토리 끝에 저장
const curSnap = JSON.parse(JSON.stringify(page.blocks || []));
if (this._sbHistoryIdx === this._sbHistory.length - 1) {
const lastSnap = JSON.stringify(this._sbHistory[this._sbHistoryIdx]);
if (lastSnap !== JSON.stringify(curSnap)) {
this._sbHistory.push(curSnap);
this._sbHistoryIdx = this._sbHistory.length - 1;
}
}
if (this._sbHistoryIdx <= 0) return;
this._sbHistoryIdx--;
this._sbHistoryPaused = true;
page.blocks = JSON.parse(JSON.stringify(this._sbHistory[this._sbHistoryIdx]));
this.sbSelectedBlock = null;
this.sbMultiSelected = [];
this._sbHistoryPaused = false;
this.autoSave();
},
sbRedo() {
if (this._sbHistoryIdx >= this._sbHistory.length - 1) return;
const page = this.sbCurrentPage;
if (!page) return;
this._sbHistoryIdx++;
this._sbHistoryPaused = true;
page.blocks = JSON.parse(JSON.stringify(this._sbHistory[this._sbHistoryIdx]));
this.sbMultiSelected = [];
this.sbSelectedBlock = null;
this._sbHistoryPaused = false;
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(''));
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;
},
// ===== Template System =====
sbInsertTemplate(tpl) {
const page = this.sbCurrentPage;
if (!page) return;
if (!page.blocks) page.blocks = [];
this.sbPushHistory();
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, marker: 32 };
const defH = { heading: 40, heading2: 36, text: 50, divider: 20, table: 140, card: 90, button: 50, input: 70, select: 70, callout: 60, code: 80, badges: 50, todo: 80, image: 200, marker: 32 };
blocks.forEach(blk => {
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);
this.autoSave();
},
sbSaveAsTemplate() {
const name = this.sbTplSaveName.trim();
if (!name) return;
const page = this.sbCurrentPage;
if (!page || !page.blocks || page.blocks.length === 0) {
alert('현재 페이지에 블록이 없습니다.');
return;
}
const blocks = JSON.parse(JSON.stringify(page.blocks));
// id 제거 (삽입 시 새로 생성)
blocks.forEach(blk => { delete blk.id; });
this.sbCustomTemplates.push({ name, blocks });
localStorage.setItem('sb_custom_templates', JSON.stringify(this.sbCustomTemplates));
this.sbTplSaveName = '';
this.sbTplTab = 'custom';
},
sbDeleteCustomTemplate(idx) {
if (!confirm('이 템플릿을 삭제하시겠습니까?')) return;
this.sbCustomTemplates.splice(idx, 1);
localStorage.setItem('sb_custom_templates', JSON.stringify(this.sbCustomTemplates));
},
sbToggleTplPanel() {
this.sbTplOpen = !this.sbTplOpen;
if (this.sbTplOpen) {
this.$nextTick(() => {
const btn = this.$refs.sbTplBtn;
const panel = this.$refs.sbTplPanel;
if (!btn || !panel) return;
const r = btn.getBoundingClientRect();
const pw = 380;
const ww = window.innerWidth;
const wh = window.innerHeight;
// 가로: 버튼 오른쪽 끝 기준, 화면 밖이면 왼쪽으로 이동
let left = r.right - pw;
if (left < 8) left = 8;
if (left + pw > ww - 8) left = ww - pw - 8;
// 세로: 버튼 아래, 공간 부족하면 위로
let top = r.bottom + 4;
if (top + 520 > wh) top = Math.max(8, r.top - 520 - 4);
panel.style.left = left + 'px';
panel.style.top = top + 'px';
});
}
},
sbEditMenu() {
// deep copy menuTree → draft (+ _open 플래그 추가)
this.sbMenuDraft = JSON.parse(JSON.stringify(this.sb.menuTree)).map(m => {
m.children = m.children || [];
m._open = true;
return m;
});
this.sbMenuEditorOpen = true;
},
sbMenuApply() {
// _open 플래그 제거 후 적용
this.sb.menuTree = this.sbMenuDraft.map(m => {
const { _open, ...rest } = m;
rest.children = (rest.children || []).map(c => ({ name: c.name }));
return rest;
}).filter(m => m.name.trim() !== '');
this.sbMenuEditorOpen = false;
this.autoSave();
},
sbMenuToggle(mi) {
this.sbMenuDraft[mi]._open = this.sbMenuDraft[mi]._open === false ? true : false;
},
sbMenuAddChild(mi) {
if (!this.sbMenuDraft[mi].children) this.sbMenuDraft[mi].children = [];
this.sbMenuDraft[mi].children.push({ name: '' });
this.sbMenuDraft[mi]._open = true;
},
sbMenuRemove(mi) {
if (this.sbMenuDraft[mi].children?.length > 0 && !confirm('하위 메뉴도 함께 삭제됩니다. 계속하시겠습니까?')) return;
this.sbMenuDraft.splice(mi, 1);
},
sbMenuDragStart(level, key, e) {
this._sbMenuDrag = { level, key };
e.dataTransfer.effectAllowed = 'move';
},
sbMenuDragOver(level, key, e) {
if (!this._sbMenuDrag) return;
e.dataTransfer.dropEffect = 'move';
},
sbMenuDrop(level, key, e) {
if (!this._sbMenuDrag) return;
const src = this._sbMenuDrag;
this._sbMenuDrag = null;
if (src.level !== level) return;
if (level === 'root') {
const fromIdx = parseInt(src.key);
const toIdx = parseInt(key);
if (fromIdx === toIdx) return;
const item = this.sbMenuDraft.splice(fromIdx, 1)[0];
this.sbMenuDraft.splice(toIdx, 0, item);
} else {
// child: key format = "parentIdx-childIdx"
const [spi, sci] = src.key.split('-').map(Number);
const [dpi, dci] = key.split('-').map(Number);
if (spi === dpi && sci === dci) return;
if (spi === dpi) {
// 같은 부모 내 이동
const children = this.sbMenuDraft[spi].children;
const item = children.splice(sci, 1)[0];
children.splice(dci, 0, item);
} else {
// 다른 부모로 이동
const item = this.sbMenuDraft[spi].children.splice(sci, 1)[0];
this.sbMenuDraft[dpi].children.splice(dci, 0, item);
}
}
},
sbMenuDragEnd() {
this._sbMenuDrag = null;
},
sbExportHtml() {
let html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>' +
(this.sb.docInfo.projectName || 'Storyboard') + '</title>' +
'<style>body{font-family:Pretendard,-apple-system,sans-serif;margin:0;padding:20px;background:#e5e7eb;}' +
'.page{width:1100px;margin:0 auto 24px;background:#fff;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,0.1);overflow:hidden;page-break-after:always;}' +
'.hdr{display:grid;grid-template-columns:1fr auto auto auto auto auto;border-bottom:2px solid #1e293b;font-size:10px;}' +
'.hdr>div{padding:6px 10px;border-right:1px solid #cbd5e1;}.hdr>div:last-child{border-right:none;}' +
'.lbl{font-size:8px;color:#94a3b8;font-weight:600;}.val{font-size:11px;font-weight:700;color:#1e293b;}' +
'.body{display:flex;min-height:500px;}.menu{width:160px;border-right:1px solid #e2e8f0;padding:12px 0;background:#f8fafc;font-size:11px;}' +
'.menu-logo{padding:8px 12px;font-size:13px;font-weight:800;}.menu-sec{font-size:8px;font-weight:700;color:#94a3b8;padding:4px 12px;}' +
'.menu-item{padding:5px 12px 5px 16px;color:#64748b;}.menu-item.active{color:#4338ca;font-weight:700;background:#eef2ff;border-right:3px solid #4338ca;}' +
'.menu-child{padding-left:28px;font-size:10px;}.menu-child.active{color:#4338ca;font-weight:700;}' +
'.content{flex:1;display:flex;flex-direction:column;}.wf{flex:1;padding:16px;}' +
'.wf img{max-width:100%;border-radius:4px;}.wf-text{border:1px solid #e2e8f0;border-radius:4px;padding:12px;min-height:200px;font-size:13px;line-height:1.7;}' +
'.desc{border-top:2px solid #1e293b;padding:12px 16px;background:#fafbfc;}' +
'.desc-title{font-size:10px;font-weight:700;margin-bottom:8px;}.desc-item{display:flex;gap:10px;padding:6px 0;border-bottom:1px solid #f1f5f9;font-size:12px;line-height:1.6;}' +
'.desc-num{width:24px;height:24px;border-radius:50%;background:#1e293b;color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;}' +
'@media print{body{background:#fff;padding:0;}.page{box-shadow:none;margin:0;border-radius:0;}}</style></head><body>';
this.sb.pages.forEach((pg, idx) => {
html += '<div class="page"><div class="hdr">';
html += '<div><span class="lbl">단위업무명</span><br><span class="val">' + (this.sb.docInfo.unitTask || '-') + '</span></div>';
html += '<div><span class="lbl">버전</span><br><span class="val">' + (this.sb.docInfo.version || '-') + '</span></div>';
html += '<div><span class="lbl">Page</span><br><span class="val">' + (idx + 1) + '</span></div>';
html += '<div><span class="lbl">경로</span><br><span class="val">' + (pg.path || '-') + '</span></div>';
html += '<div><span class="lbl">화면명</span><br><span class="val">' + (pg.screenName || '-') + '</span></div>';
html += '<div><span class="lbl">화면 ID</span><br><span class="val">' + (pg.screenId || '-') + '</span></div>';
html += '</div><div class="body"><div class="menu">';
html += '<div class="menu-logo">' + (this.sb.docInfo.projectName || 'LOGO') + '</div>';
html += '<div class="menu-sec">ERP 메뉴</div>';
this.sb.menuTree.forEach(m => {
const isActive = pg.path && pg.path.startsWith(m.name);
html += '<div class="menu-item' + (isActive ? ' active' : '') + '">' + m.name + '</div>';
(m.children || []).forEach(c => {
const cActive = pg.path && pg.path.includes(c.name);
html += '<div class="menu-item menu-child' + (cActive ? ' active' : '') + '">- ' + c.name + '</div>';
});
});
html += '</div><div class="content"><div class="wf">';
if (pg.blocks && pg.blocks.length > 0) {
// 캔버스 높이 계산
const maxBottom = Math.max(...pg.blocks.map(b => (b.y || 0) + (b.h || 40)), 400);
html += '<div style="position:relative;min-height:' + maxBottom + 'px;">';
pg.blocks.forEach(blk => {
html += '<div style="position:absolute;left:' + (blk.x||0) + 'px;top:' + (blk.y||0) + 'px;width:' + (blk.w||240) + 'px;min-height:' + (blk.h||40) + 'px;">';
html += this.sbExportBlock(blk);
html += '</div>';
});
html += '</div>';
} 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>';
pg.descriptions.forEach((d, di) => {
html += '<div class="desc-item"><div class="desc-num">' + String(di + 1).padStart(2, '0') + '</div>';
html += '<div>' + (d.text || '').replace(/\n/g, '<br>') + '</div></div>';
});
html += '</div>';
}
html += '</div></div></div>';
});
html += '</body></html>';
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (this.sb.docInfo.projectName || 'storyboard') + '.html';
a.click();
URL.revokeObjectURL(url);
},
sbPrintPreview() {
// HTML 내보내기와 동일하게 생성 후 새 창에서 인쇄
const origExport = this.sbExportHtml.bind(this);
let html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>' +
(this.sb.docInfo.projectName || 'Storyboard') + ' - 인쇄</title>' +
'<style>body{font-family:Pretendard,-apple-system,sans-serif;margin:0;padding:0;background:#fff;}' +
'.page{width:100%;background:#fff;overflow:hidden;page-break-after:always;border-bottom:1px solid #e2e8f0;}' +
'.page:last-child{border-bottom:none;}' +
'.hdr{display:grid;grid-template-columns:1fr auto auto auto auto auto;border-bottom:2px solid #1e293b;font-size:10px;}' +
'.hdr>div{padding:6px 10px;border-right:1px solid #cbd5e1;}.hdr>div:last-child{border-right:none;}' +
'.lbl{font-size:8px;color:#94a3b8;font-weight:600;}.val{font-size:11px;font-weight:700;color:#1e293b;}' +
'.body{display:flex;min-height:500px;}.menu{width:160px;border-right:1px solid #e2e8f0;padding:12px 0;background:#f8fafc;font-size:11px;}' +
'.menu-logo{padding:8px 12px;font-size:13px;font-weight:800;}.menu-sec{font-size:8px;font-weight:700;color:#94a3b8;padding:4px 12px;}' +
'.menu-item{padding:5px 12px 5px 16px;color:#64748b;}.menu-item.active{color:#4338ca;font-weight:700;background:#eef2ff;border-right:3px solid #4338ca;}' +
'.menu-child{padding-left:28px;font-size:10px;}.menu-child.active{color:#4338ca;font-weight:700;}' +
'.content{flex:1;display:flex;flex-direction:column;min-width:0;}.wf{flex:1;padding:16px;overflow:hidden;}' +
'.desc{border-top:2px solid #1e293b;padding:12px 16px;background:#fafbfc;}' +
'.desc-title{font-size:10px;font-weight:700;margin-bottom:8px;}.desc-item{display:flex;gap:10px;padding:6px 0;border-bottom:1px solid #f1f5f9;font-size:12px;line-height:1.6;}' +
'.desc-num{width:24px;height:24px;border-radius:50%;background:#1e293b;color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;}' +
'@media print{@page{size:A4 landscape;margin:8mm;} body{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}</style></head><body>';
this.sb.pages.forEach((pg, idx) => {
html += '<div class="page"><div class="hdr">';
html += '<div><span class="lbl">단위업무명</span><br><span class="val">' + (this.sb.docInfo.unitTask || '-') + '</span></div>';
html += '<div><span class="lbl">버전</span><br><span class="val">' + (this.sb.docInfo.version || '-') + '</span></div>';
html += '<div><span class="lbl">Page</span><br><span class="val">' + (idx + 1) + '</span></div>';
html += '<div><span class="lbl">경로</span><br><span class="val">' + (pg.path || '-') + '</span></div>';
html += '<div><span class="lbl">화면명</span><br><span class="val">' + (pg.screenName || '-') + '</span></div>';
html += '<div><span class="lbl">화면 ID</span><br><span class="val">' + (pg.screenId || '-') + '</span></div>';
html += '</div><div class="body"><div class="menu">';
html += '<div class="menu-logo">' + (this.sb.docInfo.projectName || 'LOGO') + '</div>';
html += '<div class="menu-sec">ERP 메뉴</div>';
this.sb.menuTree.forEach(m => {
const isActive = pg.path && pg.path.startsWith(m.name);
html += '<div class="menu-item' + (isActive ? ' active' : '') + '">' + m.name + '</div>';
(m.children || []).forEach(c => {
const cActive = pg.path && pg.path.includes(c.name);
html += '<div class="menu-item menu-child' + (cActive ? ' active' : '') + '">- ' + c.name + '</div>';
});
});
html += '</div><div class="content"><div class="wf">';
if (pg.blocks && pg.blocks.length > 0) {
const maxBottom = Math.max(...pg.blocks.map(b => (b.y || 0) + (b.h || 40)), 400);
html += '<div style="position:relative;min-height:' + maxBottom + 'px;">';
pg.blocks.forEach(blk => {
html += '<div style="position:absolute;left:' + (blk.x||0) + 'px;top:' + (blk.y||0) + 'px;width:' + (blk.w||240) + 'px;min-height:' + (blk.h||40) + 'px;">';
html += this.sbExportBlock(blk);
html += '</div>';
});
html += '</div>';
}
html += '</div>';
if (pg.descriptions && pg.descriptions.length > 0) {
html += '<div class="desc"><div class="desc-title">Description</div>';
pg.descriptions.forEach((d, di) => {
html += '<div class="desc-item"><div class="desc-num">' + String(di + 1).padStart(2, '0') + '</div>';
html += '<div>' + (d.text || '').replace(/\n/g, '<br>') + '</div></div>';
});
html += '</div>';
}
html += '</div></div></div>';
});
html += '</body></html>';
const printWin = window.open('', '_blank');
printWin.document.write(html);
printWin.document.close();
printWin.onload = () => { printWin.print(); };
},
sbExportBlock(blk) {
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) {
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>';
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>' : '';
case 'marker': return '<div style="display:inline-flex;width:28px;height:28px;border-radius:50%;background:#1e293b;color:#fff;font-size:11px;font-weight:700;align-items:center;justify-content:center;">' + esc(blk.content) + '</div>';
default: return '<p>' + esc(blk.content) + '</p>';
}
},
// ===== Context Menu =====
showContextMenu(e) {
this.contextMenuPos = { x: e.clientX, y: e.clientY };
const menu = document.getElementById('contextMenu');
const wrapRect = document.getElementById('canvasWrap').getBoundingClientRect();
this.contextMenuPos.x = (e.clientX - wrapRect.left);
this.contextMenuPos.y = (e.clientY - wrapRect.top);
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
menu.classList.add('show');
},
hideContextMenu() {
document.getElementById('contextMenu').classList.remove('show');
},
// ===== Kanban =====
kanbanNodesFor(statusKey) {
return this.filteredNodes.filter(n => (n.status || 'todo') === statusKey);
},
onKanbanDragStart(e, node) {
this._kanbanDragNodeId = node.id;
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
},
onKanbanDrop(e, statusKey) {
if (!this._kanbanDragNodeId) return;
const node = this.nodes.find(n => n.id === this._kanbanDragNodeId);
if (node) {
node.status = statusKey;
if (this.selectedNode?.id === node.id) this.selectedNode = node;
if (this.modalNode?.id === node.id) this.modalNode = node;
this.pushHistory();
this.autoSave();
}
this._kanbanDragNodeId = null;
},
// ===== Node Detail Modal =====
openNodeModal(node) {
this.modalNode = node;
this.selectedNode = node;
if (!this.modalNode.checklist) this.modalNode.checklist = [];
if (!this.modalNode.assignee) this.modalNode.assignee = '';
if (!this.modalNode.dueDate) this.modalNode.dueDate = '';
},
closeNodeModal() {
this.modalNode = null;
this.pushHistory();
this.autoSave();
},
onModalChange() {
this.autoSave();
},
addChecklistItem() {
if (!this.modalNode.checklist) this.modalNode.checklist = [];
this.modalNode.checklist.push({ text: '', done: false });
},
checklistProgress(node) {
if (!node.checklist || node.checklist.length === 0) return 0;
return Math.round(node.checklist.filter(c => c.done).length / node.checklist.length * 100);
},
// ===== List View =====
sortList(key) {
if (this.listSortKey === key) {
this.listSortDir = this.listSortDir === 'asc' ? 'desc' : 'asc';
} else {
this.listSortKey = key;
this.listSortDir = 'asc';
}
},
sortIcon(key) {
if (this.listSortKey !== key) return '';
return this.listSortDir === 'asc' ? '▲' : '▼';
},
// ===== Multi-Select =====
multiSelected: [],
toggleMultiSelect(node, e) {
if (e.shiftKey) {
const idx = this.multiSelected.findIndex(n => n.id === node.id);
if (idx >= 0) this.multiSelected.splice(idx, 1);
else this.multiSelected.push(node);
} else {
this.multiSelected = [node];
}
},
// ===== Helpers =====
toggleSidebar() { this.sidebarOpen = !this.sidebarOpen; },
onPropChange() { this.autoSave(); },
statusColor(status) {
const map = { todo: '#94a3b8', progress: '#3b82f6', review: '#f59e0b', done: '#10b981' };
return map[status] || '#94a3b8';
},
statusLabel(status) {
const map = { todo: '대기', progress: '진행중', review: '검토중', done: '완료' };
return map[status] || status;
},
priorityLabel(p) {
const map = { low: '낮음', medium: '보통', high: '높음', critical: '긴급' };
return map[p] || '';
},
formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return (d.getMonth() + 1) + '/' + d.getDate() + ' ' + d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0');
},
formatShortDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return (d.getMonth() + 1) + '/' + d.getDate();
},
isOverdue(dateStr) {
if (!dateStr) return false;
const d = new Date(dateStr);
const today = new Date();
today.setHours(0,0,0,0);
return d < today;
},
};
}
</script>
@endpush