2026-03-07 22:06:06 +09:00
@ extends ( 'layouts.app' )
@ section ( 'title' , '기획디자인' )
@ section ( 'content' )
< style >
/* ===== Planning Canvas Core ===== */
: root {
-- pc - sidebar : 280 px ;
-- pc - toolbar : 48 px ;
-- 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 ( 100 vh - 64 px );
background : #f8fafc;
overflow : hidden ;
}
/* Top Toolbar */
. pc - toolbar {
height : var ( -- pc - toolbar );
background : #1e293b;
display : flex ;
align - items : center ;
padding : 0 12 px ;
gap : 4 px ;
flex - shrink : 0 ;
z - index : 30 ;
}
. pc - toolbar - group {
display : flex ;
align - items : center ;
gap : 2 px ;
padding : 0 8 px ;
border - right : 1 px solid rgba ( 255 , 255 , 255 , 0.1 );
}
. pc - toolbar - group : last - child { border - right : none ; }
. pc - tb - btn {
width : 32 px ; height : 32 px ;
display : flex ; align - items : center ; justify - content : center ;
border - radius : 6 px ; border : none ; background : transparent ;
color : #94a3b8; cursor: pointer; transition: all 0.15s;
font - size : 11 px ;
}
. 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 : 16 px ; height : 16 px ; }
. pc - tb - title {
flex : 1 ; text - align : center ;
font - size : 13 px ; font - weight : 600 ; color : #e2e8f0;
letter - spacing : - 0.3 px ;
}
. pc - tb - title input {
background : transparent ; border : none ; color : #e2e8f0;
font - size : 13 px ; font - weight : 600 ; text - align : center ;
outline : none ; width : 300 px ;
}
. pc - tb - title input : focus { border - bottom : 1 px solid var ( -- pc - blue ); }
. pc - tb - badge {
font - size : 10 px ; padding : 2 px 6 px ; border - radius : 9999 px ;
background : rgba ( 99 , 102 , 241 , 0.2 ); color : #a5b4fc; font-weight: 500;
}
. pc - tb - sep { width : 1 px ; height : 24 px ; background : rgba ( 255 , 255 , 255 , 0.1 ); margin : 0 4 px ; }
/* Body: Sidebar + Canvas */
. pc - body {
display : flex ;
flex : 1 ;
2026-03-08 09:08:21 +09:00
position : relative ;
2026-03-07 22:06:06 +09:00
min - height : 0 ;
overflow : hidden ;
}
/* Left Sidebar — Node Palette + Properties */
. pc - sidebar {
width : var ( -- pc - sidebar );
background : #fff;
border - right : 1 px solid #e2e8f0;
display : flex ;
flex - direction : column ;
flex - shrink : 0 ;
overflow : hidden ;
transition : width 0.2 s ;
}
. pc - sidebar . collapsed { width : 0 ; border - right : none ; }
. pc - sidebar - tabs {
display : flex ; border - bottom : 1 px solid #e2e8f0; flex-shrink: 0;
}
. pc - sidebar - tab {
flex : 1 ; padding : 8 px 0 ; text - align : center ;
font - size : 11 px ; font - weight : 600 ; color : #94a3b8;
cursor : pointer ; border - bottom : 2 px solid transparent ;
transition : all 0.15 s ; 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 ); }
2026-03-08 09:08:21 +09:00
. pc - sidebar - collapse - btn {
width : 28 px ; height : 28 px ; flex - shrink : 0 ;
display : flex ; align - items : center ; justify - content : center ;
background : none ; border : none ; color : #94a3b8; cursor: pointer;
border - radius : 4 px ; margin - left : auto ; transition : all 0.15 s ;
}
. pc - sidebar - collapse - btn : hover { background : #f1f5f9; color: #475569; }
. pc - sidebar - expand - btn {
position : absolute ; left : 0 ; top : 50 % ;
transform : translateY ( - 50 % );
width : 16 px ; height : 48 px ; z - index : 50 ;
background : #f1f5f9; border: 1px solid #e2e8f0;
border - left : none ; border - radius : 0 6 px 6 px 0 ;
display : flex ; align - items : center ; justify - content : center ;
cursor : pointer ; color : #94a3b8; transition: all 0.15s;
}
. pc - sidebar - expand - btn : hover { background : #e2e8f0; color: #475569; width: 20px; }
2026-03-07 22:06:06 +09:00
. pc - sidebar - panel {
flex : 1 ; overflow - y : auto ; padding : 12 px ;
display : none ;
}
. pc - sidebar - panel . active { display : block ; }
/* Node Palette Items */
. pc - palette - section { margin - bottom : 16 px ; }
. pc - palette - section h4 {
font - size : 10 px ; font - weight : 700 ; text - transform : uppercase ;
color : #94a3b8; margin-bottom: 8px; letter-spacing: 0.5px;
}
. pc - palette - grid {
display : grid ; grid - template - columns : 1 fr 1 fr ; gap : 6 px ;
}
. pc - palette - item {
display : flex ; flex - direction : column ; align - items : center ; gap : 4 px ;
padding : 10 px 6 px ; border - radius : 8 px ; border : 1 px solid #e2e8f0;
background : #fff; cursor: grab; transition: all 0.15s;
font - size : 10 px ; 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 : 36 px ; height : 36 px ; border - radius : 8 px ;
display : flex ; align - items : center ; justify - content : center ;
font - size : 16 px ;
}
/* Canvas Area */
. pc - canvas - wrap {
flex : 1 ; position : relative ; overflow : hidden ;
background :
radial - gradient ( circle at 1 px 1 px , #e2e8f0 1px, transparent 1px);
background - size : 24 px 24 px ;
}
. pc - canvas {
position : absolute ;
top : 0 ; left : 0 ;
width : 6000 px ; height : 4000 px ;
transform - origin : 0 0 ;
}
/* Canvas Nodes */
. pc - node {
position : absolute ;
min - width : 160 px ;
background : #fff;
border - radius : 12 px ;
box - shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , 0.08 ), 0 4 px 12 px rgba ( 0 , 0 , 0 , 0.04 );
border : 2 px solid transparent ;
cursor : move ;
user - select : none ;
transition : box - shadow 0.15 s , border - color 0.15 s ;
z - index : 10 ;
}
. pc - node : hover { box - shadow : 0 4 px 16 px rgba ( 0 , 0 , 0 , 0.12 ); }
. pc - node . selected { border - color : var ( -- pc - indigo ); box - shadow : 0 0 0 3 px rgba ( 99 , 102 , 241 , 0.15 ), 0 4 px 16 px rgba ( 0 , 0 , 0 , 0.1 ); z - index : 20 ; }
. pc - node - header {
display : flex ; align - items : center ; gap : 8 px ;
padding : 10 px 12 px 6 px ;
border - radius : 12 px 12 px 0 0 ;
}
. pc - node - icon {
width : 28 px ; height : 28 px ; border - radius : 8 px ;
display : flex ; align - items : center ; justify - content : center ;
font - size : 14 px ; flex - shrink : 0 ;
}
. pc - node - title {
font - size : 12 px ; 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 : 4 px 12 px 10 px ;
font - size : 11 px ; color : #64748b; line-height: 1.5;
}
. pc - node - body [ contenteditable ] {
outline : none ; min - height : 16 px ;
}
. pc - node - tags {
display : flex ; flex - wrap : wrap ; gap : 3 px ;
padding : 0 12 px 8 px ;
}
. pc - node - tag {
font - size : 9 px ; padding : 1 px 6 px ; border - radius : 9999 px ;
background : #f1f5f9; color: #64748b; font-weight: 500;
}
. pc - node - footer {
display : flex ; align - items : center ; justify - content : space - between ;
padding : 6 px 12 px ; border - top : 1 px solid #f1f5f9;
font - size : 10 px ; color : #94a3b8;
}
/* Connection Ports */
. pc - port {
position : absolute ; width : 10 px ; height : 10 px ;
background : #fff; border: 2px solid #cbd5e1; border-radius: 50%;
cursor : crosshair ; z - index : 15 ; transition : all 0.15 s ;
}
. pc - port : hover { border - color : var ( -- pc - indigo ); background : var ( -- pc - indigo ); transform : scale ( 1.3 ); }
. pc - port - top { top : - 5 px ; left : 50 % ; margin - left : - 5 px ; }
. pc - port - bottom { bottom : - 5 px ; left : 50 % ; margin - left : - 5 px ; }
. pc - port - left { left : - 5 px ; top : 50 % ; margin - top : - 5 px ; }
. pc - port - right { right : - 5 px ; top : 50 % ; margin - top : - 5 px ; }
/* 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 : 12 px ; right : 12 px ;
width : 180 px ; height : 120 px ;
background : rgba ( 255 , 255 , 255 , 0.95 ); border : 1 px solid #e2e8f0;
border - radius : 8 px ; box - shadow : 0 2 px 8 px rgba ( 0 , 0 , 0 , 0.08 );
z - index : 25 ; overflow : hidden ;
}
. pc - minimap - viewport {
position : absolute ; border : 2 px solid var ( -- pc - blue );
background : rgba ( 59 , 130 , 246 , 0.05 ); border - radius : 2 px ;
}
/* Zoom Controls */
. pc - zoom - controls {
position : absolute ; bottom : 12 px ; left : 12 px ;
display : flex ; gap : 4 px ; z - index : 25 ;
}
. pc - zoom - btn {
width : 32 px ; height : 32 px ; border - radius : 8 px ;
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.15 s ;
}
. pc - zoom - btn : hover { background : #f1f5f9; color: #1e293b; }
. pc - zoom - label {
height : 32 px ; padding : 0 10 px ; border - radius : 8 px ;
background : #fff; border: 1px solid #e2e8f0;
display : flex ; align - items : center ; justify - content : center ;
font - size : 11 px ; 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 : 12 px ; gap : 8 px ;
}
. pc - prop - group { margin - bottom : 12 px ; }
. pc - prop - label {
font - size : 10 px ; font - weight : 700 ; color : #94a3b8;
text - transform : uppercase ; letter - spacing : 0.5 px ;
margin - bottom : 4 px ;
}
. pc - prop - input {
width : 100 % ; padding : 6 px 8 px ; border : 1 px solid #e2e8f0;
border - radius : 6 px ; font - size : 12 px ; outline : none ;
transition : border - color 0.15 s ;
}
. pc - prop - input : focus { border - color : var ( -- pc - indigo ); }
. pc - prop - textarea {
width : 100 % ; padding : 6 px 8 px ; border : 1 px solid #e2e8f0;
border - radius : 6 px ; font - size : 12 px ; outline : none ;
resize : vertical ; min - height : 60 px ;
}
. pc - color - swatches {
display : flex ; gap : 4 px ; flex - wrap : wrap ;
}
. pc - color - swatch {
width : 24 px ; height : 24 px ; border - radius : 6 px ;
cursor : pointer ; border : 2 px solid transparent ;
transition : all 0.15 s ;
}
. 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 : 2 px ;
}
. pc - view - tab {
padding : 4 px 10 px ; border - radius : 6 px ;
font - size : 11 px ; font - weight : 600 ; color : #94a3b8;
cursor : pointer ; border : none ; background : transparent ;
transition : all 0.15 s ;
}
. 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 : 8 px ; box - shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , 0.12 );
padding : 4 px ; min - width : 160 px ; display : none ;
}
. pc - context - menu . show { display : block ; }
. pc - cm - item {
display : flex ; align - items : center ; gap : 8 px ;
padding : 6 px 10 px ; border - radius : 6 px ;
font - size : 12 px ; color : #374151; cursor: pointer;
transition : background 0.1 s ;
}
. pc - cm - item : hover { background : #f3f4f6; }
. pc - cm - item . danger { color : #ef4444; }
. pc - cm - item . danger : hover { background : #fef2f2; }
. pc - cm - sep { height : 1 px ; background : #e5e7eb; margin: 4px 0; }
2026-03-07 22:16:52 +09:00
/* ===== Kanban Board ===== */
. pc - kanban {
display : flex ; gap : 12 px ; flex : 1 ; padding : 16 px ; overflow - x : auto ; background : #f1f5f9;
}
. pc - kanban - col {
min - width : 260 px ; width : 260 px ; 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 : 8 px ; padding : 10 px 12 px ; flex - shrink : 0 ;
}
. pc - kanban - col - dot {
width : 10 px ; height : 10 px ; border - radius : 50 % ; flex - shrink : 0 ;
}
. pc - kanban - col - title {
font - size : 12 px ; font - weight : 700 ; color : #374151; flex: 1;
}
. pc - kanban - col - count {
font - size : 10 px ; font - weight : 600 ; color : #94a3b8; background: #fff;
padding : 1 px 7 px ; border - radius : 9999 px ;
}
. pc - kanban - col - body {
flex : 1 ; overflow - y : auto ; padding : 4 px 8 px 8 px ; display : flex ; flex - direction : column ; gap : 6 px ;
min - height : 60 px ;
}
. pc - kanban - col - body . drag - over { background : rgba ( 99 , 102 , 241 , 0.08 ); border - radius : 0 0 10 px 10 px ; }
. pc - kanban - card {
background : #fff; border-radius: 8px; padding: 10px 12px; cursor: grab;
border : 1 px solid #e2e8f0; transition: box-shadow 0.15s, border-color 0.15s;
border - left : 3 px solid var ( -- card - color , #6366f1);
}
. pc - kanban - card : hover { box - shadow : 0 2 px 8 px rgba ( 0 , 0 , 0 , 0.08 ); }
. pc - kanban - card . dragging { opacity : 0.4 ; }
. pc - kanban - card - title {
font - size : 12 px ; font - weight : 600 ; color : #1e293b; margin-bottom: 4px;
display : flex ; align - items : center ; gap : 6 px ;
}
. pc - kanban - card - title . emoji { font - size : 14 px ; }
. pc - kanban - card - desc {
font - size : 11 px ; color : #64748b; line-height: 1.4;
overflow : hidden ; max - height : 36 px ; text - overflow : ellipsis ;
margin - bottom : 6 px ;
}
. pc - kanban - card - meta {
display : flex ; align - items : center ; gap : 6 px ; flex - wrap : wrap ;
}
. pc - kanban - card - tag {
font - size : 9 px ; padding : 1 px 6 px ; border - radius : 9999 px ;
background : #f1f5f9; color: #64748b; font-weight: 500;
}
. pc - kanban - card - priority {
font - size : 9 px ; padding : 1 px 6 px ; border - radius : 9999 px ; 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 : 85 vh ; overflow - y : auto ; box - shadow : 0 24 px 48 px rgba ( 0 , 0 , 0 , 0.2 );
}
. pc - modal - header {
display : flex ; align - items : center ; gap : 12 px ; padding : 20 px 24 px 12 px ;
border - bottom : 1 px solid #f1f5f9;
}
. pc - modal - header . emoji { font - size : 24 px ; }
. pc - modal - header input {
flex : 1 ; font - size : 18 px ; font - weight : 700 ; border : none ; outline : none ; color : #1e293b;
}
. pc - modal - header input :: placeholder { color : #cbd5e1; }
. pc - modal - close {
width : 32 px ; height : 32 px ; border - radius : 8 px ; 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 : 16 px 24 px 24 px ; }
. pc - modal - row { display : flex ; gap : 12 px ; margin - bottom : 14 px ; }
. pc - modal - label {
width : 72 px ; flex - shrink : 0 ; font - size : 11 px ; font - weight : 600 ; color : #94a3b8;
padding - top : 7 px ; text - align : right ;
}
. pc - modal - field { flex : 1 ; }
. pc - modal - field input , . pc - modal - field select , . pc - modal - field textarea {
width : 100 % ; padding : 6 px 10 px ; border : 1 px solid #e2e8f0; border-radius: 8px;
font - size : 13 px ; outline : none ; transition : border - color 0.15 s ;
}
. 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 : 80 px ; line - height : 1.6 ; }
/* Checklist */
. pc - checklist { list - style : none ; padding : 0 ; margin : 0 ; }
. pc - checklist - item {
display : flex ; align - items : center ; gap : 8 px ; padding : 5 px 0 ;
border - bottom : 1 px solid #f8fafc;
}
. pc - checklist - item input [ type = " checkbox " ] { accent - color : var ( -- pc - indigo ); width : 15 px ; height : 15 px ; cursor : pointer ; }
. pc - checklist - item . text {
flex : 1 ; font - size : 13 px ; color : #374151; border: none; outline: none; background: transparent;
}
. pc - checklist - item . text . done { text - decoration : line - through ; color : #94a3b8; }
. pc - checklist - item . remove {
width : 20 px ; height : 20 px ; 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 : 6 px ; padding : 6 px 0 ;
font - size : 12 px ; color : var ( -- pc - indigo ); cursor : pointer ; font - weight : 500 ;
}
. pc - checklist - add : hover { color : #4f46e5; }
. pc - checklist - progress {
height : 4 px ; background : #e2e8f0; border-radius: 2px; overflow: hidden; margin-bottom: 8px;
}
. pc - checklist - progress - bar {
height : 100 % ; background : var ( -- pc - emerald ); border - radius : 2 px ; transition : width 0.3 s ;
}
/* ===== List/Table View ===== */
. pc - list - view {
flex : 1 ; overflow : auto ; background : #fff;
}
. pc - list - table {
width : 100 % ; border - collapse : collapse ; font - size : 12 px ;
}
. 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.5 px ; border - bottom : 2 px 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 : 10 px ; margin - left : 2 px ; }
. pc - list - table td {
padding : 8 px 12 px ; border - bottom : 1 px 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 : 4 px ; font - size : 11 px ;
padding : 2 px 8 px ; border - radius : 9999 px ; font - weight : 500 ;
}
/* ===== Filter Bar ===== */
. pc - filter - bar {
display : flex ; align - items : center ; gap : 8 px ; padding : 6 px 16 px ;
background : #fff; border-bottom: 1px solid #e2e8f0; flex-shrink: 0;
}
. pc - filter - bar select , . pc - filter - bar input {
padding : 4 px 8 px ; border : 1 px solid #e2e8f0; border-radius: 6px;
font - size : 11 px ; 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 : 10 px ; font - weight : 600 ; color : #94a3b8; }
. pc - filter - clear {
font - size : 11 px ; color : var ( -- pc - indigo ); cursor : pointer ; font - weight : 500 ;
background : none ; border : none ; padding : 4 px 8 px ;
}
. pc - filter - clear : hover { text - decoration : underline ; }
2026-03-07 22:55:39 +09:00
/* ===== Storyboard View ===== */
. sb - wrap { flex : 1 ; display : flex ; flex - direction : column ; overflow : hidden ; background : #e5e7eb; }
. sb - topbar {
display : flex ; align - items : center ; gap : 8 px ; padding : 8 px 16 px ;
background : #fff; border-bottom: 1px solid #e2e8f0; flex-shrink: 0;
}
. sb - topbar label { font - size : 10 px ; font - weight : 600 ; color : #94a3b8; }
. sb - topbar input , . sb - topbar select {
padding : 4 px 8 px ; border : 1 px solid #e2e8f0; border-radius: 6px;
font - size : 12 px ; outline : none ;
}
. sb - topbar input : focus { border - color : var ( -- pc - indigo ); }
2026-03-07 23:54:19 +09:00
/* 스토리보드 전역 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 ; }
2026-03-07 22:55:39 +09:00
. sb - topbar - sep { width : 1 px ; height : 24 px ; background : #e2e8f0; }
. sb - pages - nav {
display : flex ; align - items : center ; gap : 4 px ;
font - size : 12 px ; font - weight : 600 ; color : #374151;
}
. sb - pages - nav button {
width : 28 px ; height : 28 px ; border - radius : 6 px ; border : 1 px solid #e2e8f0;
background : #fff; cursor: pointer; display: flex; align-items: center; justify-content: center;
font - size : 14 px ; color : #64748b;
}
. sb - pages - nav button : hover { background : #f1f5f9; }
. sb - body { flex : 1 ; display : flex ; overflow : hidden ; }
. sb - page - list {
width : 140 px ; flex - shrink : 0 ; background : #f1f5f9; border-right: 1px solid #e2e8f0;
overflow - y : auto ; padding : 8 px ;
}
. sb - page - thumb {
padding : 6 px 8 px ; border - radius : 6 px ; cursor : pointer ; margin - bottom : 4 px ;
font - size : 10 px ; 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 : 11 px ; color : #374151; }
2026-03-08 09:19:18 +09:00
. sb - editor { flex : 1 ; overflow : auto ; padding : 12 px ; display : flex ; justify - content : center ; }
2026-03-07 22:55:39 +09:00
/* Storyboard Page (A4-like) */
. sb - page {
2026-03-08 09:19:18 +09:00
width : 1100 px ; max - width : 100 % ; min - height : 750 px ; background : #fff; border-radius: 4px;
2026-03-07 22:55:39 +09:00
box - shadow : 0 2 px 12 px rgba ( 0 , 0 , 0 , 0.1 ); display : flex ; flex - direction : column ;
2026-03-08 09:19:18 +09:00
flex - shrink : 0 ; transition : width 0.2 s ;
}
/* 메뉴트리 패널 접기 토글 */
. sb - menu - toggle {
width : 18 px ; flex - shrink : 0 ; cursor : pointer ;
display : flex ; align - items : center ; justify - content : center ;
background : #f1f5f9; border: none; color: #94a3b8;
transition : all 0.15 s ; border - left : 1 px solid #e2e8f0;
2026-03-07 22:55:39 +09:00
}
2026-03-08 09:19:18 +09:00
. sb - menu - toggle : hover { background : #e2e8f0; color: #475569; }
2026-03-07 22:55:39 +09:00
. sb - page - header {
display : grid ; grid - template - columns : 1 fr auto auto auto auto auto ;
border - bottom : 2 px solid #1e293b; font-size: 10px;
}
. sb - page - header > div {
padding : 6 px 10 px ; border - right : 1 px 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 : 8 px ; color : #94a3b8; font-weight: 600; text-transform: uppercase; }
. sb - page - header . value { font - size : 11 px ; font - weight : 700 ; color : #1e293b; }
. sb - page - header . value input {
border : none ; outline : none ; font - size : 11 px ; font - weight : 700 ; color : #1e293b;
width : 100 % ; background : transparent ;
}
. sb - page - header . value input : focus { border - bottom : 1 px solid var ( -- pc - indigo ); }
. sb - page - body { display : flex ; flex : 1 ; min - height : 0 ; }
/* Left Menu Panel */
. sb - menu - panel {
2026-03-08 00:20:42 +09:00
flex - shrink : 0 ; border - right : none ;
2026-03-07 22:55:39 +09:00
padding : 12 px 0 ; font - size : 11 px ; overflow - y : auto ; background : #f8fafc;
}
2026-03-08 00:20:42 +09:00
. sb - menu - resizer {
width : 5 px ; flex - shrink : 0 ; cursor : col - resize ; background : #e2e8f0;
transition : background . 15 s ;
}
. sb - menu - resizer : hover , . sb - menu - resizer . active { background : #818cf8; }
2026-03-07 22:55:39 +09:00
. sb - menu - section { font - size : 8 px ; font - weight : 700 ; color : #94a3b8; padding: 4px 12px; text-transform: uppercase; }
. sb - menu - item {
padding : 5 px 12 px 5 px 16 px ; color : #64748b; cursor: default;
font - size : 11 px ; 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 : 28 px ; font - size : 10 px ; }
. sb - menu - child . active { color : #4338ca; font-weight: 700; }
. sb - menu - logo { padding : 8 px 12 px ; font - size : 13 px ; 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 : 16 px ; min - height : 300 px ; position : relative ;
}
. sb - wireframe - placeholder {
border : 2 px 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 : 280 px ; border : 1 px solid #e2e8f0; border-radius: 8px;
padding : 16 px ; font - size : 13 px ; 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 : 8 px ; }
2026-03-08 09:19:18 +09:00
. sb - desc - toggle - bar {
height : 24 px ; flex - shrink : 0 ; cursor : pointer ;
display : flex ; align - items : center ; justify - content : center ; gap : 6 px ;
background : #1e293b; color: #94a3b8; font-size: 9px; font-weight: 600;
text - transform : uppercase ; letter - spacing : 0.5 px ; transition : background 0.15 s ;
}
. sb - desc - toggle - bar : hover { background : #334155; color: #e2e8f0; }
. sb - desc - toggle - label { color : inherit ; }
2026-03-08 00:41:32 +09:00
. sb - desc - resizer {
2026-03-08 09:19:18 +09:00
height : 5 px ; flex - shrink : 0 ; cursor : row - resize ; background : #334155;
2026-03-08 00:41:32 +09:00
transition : background . 15 s ;
}
. sb - desc - resizer : hover , . sb - desc - resizer . active { background : #818cf8; }
2026-03-07 22:55:39 +09:00
. sb - desc - panel {
2026-03-08 00:41:32 +09:00
padding : 12 px 16 px ; background : #fafbfc;
overflow - y : auto ; flex - shrink : 0 ;
2026-03-07 22:55:39 +09:00
}
. sb - desc - item {
display : flex ; gap : 10 px ; padding : 6 px 0 ; border - bottom : 1 px solid #f1f5f9;
font - size : 12 px ; line - height : 1.6 ;
}
. sb - desc - item : last - child { border - bottom : none ; }
. sb - desc - num {
width : 24 px ; height : 24 px ; 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 : 12 px ; 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 : 6 px ; padding : 8 px 0 ;
font - size : 11 px ; color : var ( -- pc - indigo ); cursor : pointer ; font - weight : 500 ;
}
. sb - desc - add : hover { color : #4f46e5; }
. sb - desc - remove {
width : 20 px ; height : 20 px ; border : none ; background : transparent ; color : #cbd5e1;
cursor : pointer ; font - size : 14 px ; border - radius : 4 px ; display : flex ;
align - items : center ; justify - content : center ; flex - shrink : 0 ;
}
. sb - desc - remove : hover { color : #ef4444; background: #fef2f2; }
2026-03-08 00:41:32 +09:00
. sb - desc - num { cursor : grab ; }
. sb - desc - num : active { cursor : grabbing ; }
/* Marker (description number badge on canvas) */
. sb - blk - marker {
width : 28 px ; height : 28 px ; border - radius : 50 % ; background : #1e293b; color: #fff;
font - size : 11 px ; font - weight : 700 ; display : flex ; align - items : center ; justify - content : center ;
user - select : none ; cursor : move ;
}
2026-03-07 22:55:39 +09:00
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
/* Block Editor (Wireframe) */
. sb - block - toolbar {
display : flex ; align - items : center ; gap : 4 px ; padding : 6 px 12 px ;
2026-03-08 00:17:05 +09:00
border - bottom : 1 px solid #e2e8f0; background: #fafbfc; flex-wrap: wrap; flex-shrink: 0;
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
}
. sb - block - toolbar - btn {
padding : 3 px 8 px ; border : 1 px solid #e2e8f0; border-radius: 5px;
font - size : 10 px ; cursor : pointer ; background : #fff; color: #475569;
display : flex ; align - items : center ; gap : 3 px ; white - space : nowrap ;
transition : all . 12 s ;
}
. sb - block - toolbar - btn : hover { border - color : var ( -- pc - indigo ); color : var ( -- pc - indigo ); background : #eef2ff; }
. sb - block - toolbar - sep { width : 1 px ; height : 18 px ; background : #e2e8f0; margin: 0 2px; }
2026-03-07 23:40:14 +09:00
. sb - blocks - area {
flex : 1 ; position : relative ; overflow : auto ; min - height : 400 px ;
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;
}
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
. sb - block {
2026-03-07 23:40:14 +09:00
position : absolute ; border : 1.5 px solid transparent ; border - radius : 6 px ;
padding : 0 ; transition : border - color . 1 s , box - shadow . 1 s ;
background : #fff; cursor: move; user-select: none;
overflow : hidden ;
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
}
2026-03-07 23:40:14 +09:00
. 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 2 px rgba ( 99 , 102 , 241 , 0.15 ); }
. sb - block . sb - block - editing { cursor : default ; user - select : text ; overflow : visible ; }
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
. sb - block - actions {
2026-03-07 23:40:14 +09:00
position : absolute ; right : 4 px ; top : - 26 px ; display : flex ; gap : 2 px ;
opacity : 0 ; transition : opacity . 12 s ; z - index : 10 ;
background : #fff; border: 1px solid #e2e8f0; border-radius: 6px; padding: 2px 4px;
box - shadow : 0 2 px 6 px rgba ( 0 , 0 , 0 , 0.08 );
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
}
2026-03-07 23:40:14 +09:00
. sb - block : hover . sb - block - actions , . sb - block . selected . sb - block - actions { opacity : 1 ; }
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
. sb - block - action - btn {
2026-03-07 23:40:14 +09:00
width : 20 px ; height : 20 px ; border : none ; background : transparent ; color : #94a3b8;
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
border - radius : 4 px ; cursor : pointer ; font - size : 11 px ; display : flex ;
align - items : center ; justify - content : center ;
}
2026-03-07 23:40:14 +09:00
. sb - block - action - btn : hover { background : #f1f5f9; color: #475569; }
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
. sb - block - action - btn . danger : hover { background : #fef2f2; color: #ef4444; }
2026-03-07 23:40:14 +09:00
/* Resize handles */
. sb - resize - handle {
position : absolute ; background : #fff; border: 1.5px solid var(--pc-indigo);
border - radius : 2 px ; z - index : 5 ; opacity : 0 ; transition : opacity . 1 s ;
}
. sb - block . selected . sb - resize - handle { opacity : 1 ; }
. sb - resize - handle . sb - rh - r { width : 6 px ; height : 20 px ; right : - 4 px ; top : 50 % ; transform : translateY ( - 50 % ); cursor : e - resize ; }
. sb - resize - handle . sb - rh - b { width : 20 px ; height : 6 px ; bottom : - 4 px ; left : 50 % ; transform : translateX ( - 50 % ); cursor : s - resize ; }
. sb - resize - handle . sb - rh - br { width : 10 px ; height : 10 px ; right : - 5 px ; bottom : - 5 px ; cursor : se - resize ; border - radius : 3 px ; }
/* Size label */
. sb - block - size {
position : absolute ; bottom : - 20 px ; left : 50 % ; transform : translateX ( - 50 % );
font - size : 9 px ; color : #94a3b8; white-space: nowrap; pointer-events: none;
opacity : 0 ; transition : opacity . 1 s ;
}
. sb - block . selected . sb - block - size { opacity : 1 ; }
2026-03-08 00:26:26 +09:00
/* 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.5 px dashed #6366f1; background: rgba(99,102,241,0.06);
pointer - events : none ; z - index : 100 ;
}
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
2026-03-08 01:22:06 +09:00
/* Floating Format Toolbar (Notion-style) */
. sb - format - bar {
position : fixed ; z - index : 9999 ;
display : flex ; align - items : center ; gap : 2 px ;
padding : 4 px 6 px ; background : #1e293b; border-radius: 8px;
box - shadow : 0 4 px 16 px rgba ( 0 , 0 , 0 , 0.25 );
animation : sbFormatFadeIn 0.12 s ease ;
}
@ keyframes sbFormatFadeIn { from { opacity : 0 ; transform : translateY ( 4 px ); } to { opacity : 1 ; transform : translateY ( 0 ); } }
. sb - fmt - btn {
width : 28 px ; height : 28 px ; border : none ; background : transparent ; color : #94a3b8;
border - radius : 5 px ; cursor : pointer ; font - size : 12 px ;
display : flex ; align - items : center ; justify - content : center ; transition : all 0.1 s ;
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 : 1 px ; height : 20 px ; background : rgba ( 255 , 255 , 255 , 0.12 ); margin : 0 2 px ; }
. sb - fmt - color - dot {
width : 14 px ; height : 14 px ; border - radius : 50 % ; border : 2 px solid transparent ;
cursor : pointer ; transition : all 0.1 s ;
}
. 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 : 6 px ; background : #1e293b; border-radius: 8px; padding: 8px;
box - shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , 0.3 ); min - width : 140 px ; z - index : 10 ;
}
. sb - fmt - dropdown - title { font - size : 9 px ; 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 : 4 px ; }
. sb - fmt - size - option {
padding : 4 px 8 px ; border - radius : 4 px ; font - size : 11 px ; color : #cbd5e1;
cursor : pointer ; transition : all 0.1 s ; 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 8 px 30 px rgba ( 0 , 0 , 0 , 0.15 ); padding : 4 px 0 ;
min - width : 200 px ; animation : sbCtxFadeIn 0.1 s 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 : 8 px ; padding : 7 px 14 px ;
font - size : 12 px ; 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 : 18 px ; text - align : center ; font - size : 13 px ; flex - shrink : 0 ; }
. sb - ctx - label { flex : 1 ; }
. sb - ctx - shortcut { font - size : 10 px ; color : #94a3b8; }
. sb - ctx - sep { height : 1 px ; background : #e2e8f0; margin: 4px 0; }
. sb - ctx - sub { position : relative ; }
. sb - ctx - sub - panel {
position : absolute ; left : 100 % ; top : - 4 px ;
background : #fff; border: 1px solid #e2e8f0; border-radius: 10px;
box - shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , 0.12 ); padding : 8 px ; min - width : 160 px ;
}
. sb - ctx - sub - panel . sb - ctx - item { padding : 5 px 10 px ; }
. sb - ctx - color - grid { display : flex ; flex - wrap : wrap ; gap : 5 px ; padding : 4 px ; }
. sb - ctx - color - swatch {
width : 22 px ; height : 22 px ; border - radius : 5 px ; cursor : pointer ;
border : 2 px solid transparent ; transition : all 0.1 s ;
}
. sb - ctx - color - swatch : hover { transform : scale ( 1.15 ); }
. sb - ctx - color - swatch . active { border - color : #4338ca; }
2026-03-08 01:26:07 +09:00
/* Block style inheritance — 부모 .sb-block에 설정된 color/font-size/font-weight 등을 자식에게 전달 */
. sb - block [ style *= " color " ] . sb - blk - text ,
. sb - block [ style *= " color " ] . sb - blk - heading ,
. sb - block [ style *= " color " ] . sb - blk - callout - text ,
. sb - block [ style *= " color " ] . sb - blk - code ,
. sb - block [ style *= " color " ] . sb - blk - todo - text ,
. sb - block [ style *= " color " ] . sb - blk - mock - card - title ,
. sb - block [ style *= " color " ] . sb - blk - mock - card - body ,
. sb - block [ style *= " color " ] . sb - blk - mockup - label ,
. sb - block [ style *= " color " ] . sb - blk - table th ,
. sb - block [ style *= " color " ] . sb - blk - table td ,
. sb - block [ style *= " color " ] . sb - blk - mock - btn { color : inherit ; }
. sb - block [ style *= " font-size " ] . sb - blk - text ,
. sb - block [ style *= " font-size " ] . sb - blk - heading ,
. sb - block [ style *= " font-size " ] . sb - blk - callout - text ,
. sb - block [ style *= " font-size " ] . sb - blk - todo - text ,
. sb - block [ style *= " font-size " ] . sb - blk - mock - card - title ,
. sb - block [ style *= " font-size " ] . sb - blk - mock - card - body { font - size : inherit ; }
. sb - block [ style *= " font-weight " ] . sb - blk - text ,
. sb - block [ style *= " font-weight " ] . sb - blk - heading ,
. sb - block [ style *= " font-weight " ] . sb - blk - callout - text ,
. sb - block [ style *= " font-weight " ] . sb - blk - todo - text { font - weight : inherit ; }
. sb - block [ style *= " font-style " ] . sb - blk - text ,
. sb - block [ style *= " font-style " ] . sb - blk - heading ,
. sb - block [ style *= " font-style " ] . sb - blk - callout - text ,
. sb - block [ style *= " font-style " ] . sb - blk - todo - text { font - style : inherit ; }
. sb - block [ style *= " text-align " ] . sb - blk - text ,
. sb - block [ style *= " text-align " ] . sb - blk - heading ,
. sb - block [ style *= " text-align " ] . sb - blk - callout - text { text - align : inherit ; }
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
/* Block type styles */
. sb - blk - text { padding : 6 px 8 px ; font - size : 13 px ; line - height : 1.7 ; min - height : 24 px ; outline : none ; color : #334155; }
2026-03-07 23:54:19 +09:00
. sb - blk - text : empty :: before { content : attr ( data - placeholder ); color : #c8d0da; }
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
. sb - blk - heading { padding : 8 px 8 px 4 px ; font - size : 18 px ; font - weight : 700 ; line - height : 1.4 ; outline : none ; color : #1e293b; }
2026-03-07 23:54:19 +09:00
. sb - blk - heading : empty :: before { content : '제목을 입력하세요' ; color : #c8d0da; }
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
. sb - blk - h2 { font - size : 15 px ; font - weight : 600 ; }
. sb - blk - divider { border : none ; border - top : 1 px solid #e2e8f0; margin: 8px 0; }
. sb - blk - callout {
display : flex ; gap : 8 px ; padding : 10 px 12 px ; background : #eff6ff;
border - radius : 6 px ; border - left : 3 px solid #3b82f6;
}
. sb - blk - callout - icon { font - size : 16 px ; flex - shrink : 0 ; }
. sb - blk - callout - text { flex : 1 ; font - size : 12 px ; 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 : 4 px ; }
. sb - blk - table { width : 100 % ; border - collapse : collapse ; font - size : 12 px ; }
. sb - blk - table th { background : #f1f5f9; font-weight: 600; color: #475569; text-align: left; }
. sb - blk - table th , . sb - blk - table td {
border : 1 px solid #e2e8f0; padding: 6px 8px; min-width: 60px; outline: none;
}
. sb - blk - table td : focus { background : #f8fafc; }
. sb - blk - mockup { padding : 8 px ; }
. sb - blk - mockup - label { font - size : 9 px ; color : #94a3b8; font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
. sb - blk - mock - btn {
display : inline - block ; padding : 6 px 16 px ; border - radius : 6 px ; font - size : 12 px ;
font - weight : 600 ; cursor : default ;
}
. sb - blk - mock - input {
width : 100 % ; padding : 7 px 10 px ; border : 1 px solid #d1d5db; border-radius: 6px;
font - size : 12 px ; color : #6b7280; background: #f9fafb;
}
. sb - blk - mock - select {
padding : 7 px 10 px ; border : 1 px solid #d1d5db; border-radius: 6px;
font - size : 12 px ; color : #6b7280; background: #f9fafb;
}
. sb - blk - mock - card {
border : 1 px solid #e2e8f0; border-radius: 8px; padding: 12px; background: #fff;
}
. sb - blk - mock - card - title { font - size : 13 px ; font - weight : 600 ; color : #1e293b; margin-bottom: 4px; outline: none; }
. sb - blk - mock - card - body { font - size : 11 px ; 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 : 6 px ; }
. sb - blk - image - placeholder {
border : 2 px 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 : 6 px ; padding : 4 px 8 px ; }
. sb - blk - todo input [ type = checkbox ] { margin - top : 4 px ; accent - color : var ( -- pc - indigo ); }
. sb - blk - todo - text { flex : 1 ; font - size : 12 px ; outline : none ; line - height : 1.6 ; }
. sb - blk - todo - text : empty :: before { content : '할 일을 입력하세요' ; color : #cbd5e1; }
. sb - blk - badge - row { display : flex ; gap : 6 px ; flex - wrap : wrap ; padding : 4 px 8 px ; }
. sb - blk - badge {
padding : 3 px 10 px ; border - radius : 12 px ; font - size : 10 px ; font - weight : 600 ;
}
. sb - blk - code {
padding : 10 px 12 px ; background : #1e293b; border-radius: 6px; color: #e2e8f0;
font - family : 'Fira Code' , monospace ; font - size : 12 px ; line - height : 1.6 ;
outline : none ; white - space : pre - wrap ;
}
. sb - blk - code : empty :: before { content : '코드를 입력하세요' ; color : #64748b; }
. sb - blk - empty - area {
border : 2 px 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; }
2026-03-07 23:29:18 +09:00
/* Template Dropdown */
. sb - tpl - dropdown {
position : relative ; display : inline - block ;
}
. sb - tpl - trigger {
padding : 3 px 10 px ; border : 1 px solid #e2e8f0; border-radius: 5px;
font - size : 10 px ; cursor : pointer ; background : #fff; color: #475569;
display : flex ; align - items : center ; gap : 4 px ; white - space : nowrap ;
transition : all . 12 s ;
}
. sb - tpl - trigger : hover { border - color : var ( -- pc - indigo ); color : var ( -- pc - indigo ); }
. sb - tpl - panel {
2026-03-07 23:34:37 +09:00
position : fixed ; z - index : 9990 ;
width : 380 px ; max - height : 520 px ; background : #fff;
2026-03-07 23:29:18 +09:00
border : 1 px solid #e2e8f0; border-radius: 10px;
2026-03-07 23:34:37 +09:00
box - shadow : 0 12 px 32 px rgba ( 0 , 0 , 0 , 0.15 ); display : flex ; flex - direction : column ;
2026-03-07 23:29:18 +09:00
}
. sb - tpl - tabs {
display : flex ; border - bottom : 1 px solid #e2e8f0;
}
. sb - tpl - tab {
flex : 1 ; padding : 8 px ; font - size : 11 px ; font - weight : 600 ; text - align : center ;
cursor : pointer ; color : #94a3b8; border-bottom: 2px solid transparent;
transition : all . 12 s ;
}
. sb - tpl - tab : hover { color : #475569; }
. sb - tpl - tab . active { color : var ( -- pc - indigo ); border - bottom - color : var ( -- pc - indigo ); }
. sb - tpl - search {
margin : 8 px 10 px 4 px ; padding : 6 px 10 px ; border : 1 px solid #e2e8f0;
border - radius : 6 px ; font - size : 11 px ; outline : none ; width : calc ( 100 % - 20 px );
}
. sb - tpl - search : focus { border - color : var ( -- pc - indigo ); }
. sb - tpl - list { flex : 1 ; overflow - y : auto ; padding : 4 px 6 px 8 px ; }
. sb - tpl - item {
display : flex ; align - items : center ; gap : 8 px ; padding : 8 px 10 px ;
border - radius : 6 px ; cursor : pointer ; transition : all . 1 s ;
}
. sb - tpl - item : hover { background : #f1f5f9; }
. sb - tpl - item - icon {
width : 32 px ; height : 32 px ; border - radius : 6 px ; background : #f1f5f9;
display : flex ; align - items : center ; justify - content : center ; font - size : 15 px ;
flex - shrink : 0 ;
}
. sb - tpl - item - info { flex : 1 ; min - width : 0 ; }
. sb - tpl - item - name { font - size : 12 px ; font - weight : 600 ; color : #1e293b; }
. sb - tpl - item - desc { font - size : 10 px ; color : #94a3b8; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
. sb - tpl - item - actions { display : flex ; gap : 2 px ; flex - shrink : 0 ; }
. sb - tpl - item - btn {
width : 22 px ; height : 22 px ; border : none ; background : transparent ; color : #cbd5e1;
border - radius : 4 px ; cursor : pointer ; font - size : 12 px ;
display : flex ; align - items : center ; justify - content : center ;
}
. sb - tpl - item - btn : hover { background : #fee2e2; color: #ef4444; }
. sb - tpl - save - bar {
padding : 8 px 10 px ; border - top : 1 px solid #e2e8f0; display: flex; gap: 6px; align-items: center;
}
. sb - tpl - save - input {
flex : 1 ; padding : 5 px 8 px ; border : 1 px solid #e2e8f0; border-radius: 5px;
font - size : 11 px ; outline : none ;
}
. sb - tpl - save - input : focus { border - color : var ( -- pc - indigo ); }
. sb - tpl - save - btn {
padding : 5 px 12 px ; border : none ; border - radius : 5 px ; font - size : 11 px ;
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 : 20 px ; color : #cbd5e1; font-size: 11px; }
2026-03-07 23:07:17 +09:00
/* 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 25 px 50 px rgba ( 0 , 0 , 0 , 0.15 );
}
. sb - menu - modal - header {
display : flex ; align - items : center ; justify - content : space - between ;
padding : 16 px 20 px ; border - bottom : 1 px solid #e2e8f0;
}
. sb - menu - modal - header h3 { font - size : 15 px ; font - weight : 700 ; color : #1e293b; margin: 0; }
. sb - menu - modal - body { flex : 1 ; overflow - y : auto ; padding : 16 px 20 px ; }
. sb - menu - modal - footer {
display : flex ; justify - content : flex - end ; gap : 8 px ;
padding : 12 px 20 px ; border - top : 1 px solid #e2e8f0;
}
. sb - mt - item {
display : flex ; align - items : center ; gap : 6 px ; padding : 5 px 0 ;
border - bottom : 1 px 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 : 18 px ; 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 : 13 px ; padding : 4 px 6 px ;
border - radius : 4 px ; color : #334155; background: transparent;
}
. sb - mt - name : focus { background : #f1f5f9; }
. sb - mt - actions { display : flex ; gap : 2 px ; flex - shrink : 0 ; }
. sb - mt - btn {
width : 24 px ; height : 24 px ; 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 : 24 px ; border - left : 2 px solid #e2e8f0; margin-left: 10px; }
. sb - mt - add - root {
display : flex ; align - items : center ; gap : 6 px ; padding : 10 px 0 4 px ;
font - size : 12 px ; color : var ( -- pc - indigo ); cursor : pointer ; font - weight : 500 ;
}
. sb - mt - add - root : hover { color : #4f46e5; }
2026-03-07 22:06:06 +09:00
/* Phase Swimlane (Timeline View) */
. pc - swimlane {
position : absolute ; top : 0 ;
height : 100 % ;
border - left : 2 px dashed rgba ( 99 , 102 , 241 , 0.2 );
padding : 8 px 12 px ;
pointer - events : none ;
}
. pc - swimlane - label {
font - size : 10 px ; font - weight : 700 ; color : var ( -- pc - indigo );
text - transform : uppercase ; letter - spacing : 1 px ; opacity : 0.5 ;
}
</ style >
2026-03-07 22:28:40 +09:00
< div class = " pc-wrap " id = " planningCanvas " x - data = " planningCanvas() " x - init = " init() " @ keydown . window = " handleKeyDown( $event ) " @ keyup . window = " handleKeyUp( $event ) " >
2026-03-07 22:06:06 +09:00
{{ -- ===== 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 " >
2026-03-07 22:16:52 +09:00
< button class = " pc-view-tab " : class = " { active: viewMode === 'free' } " @ click = " switchView('free') " > 자유배치 </ button >
2026-03-07 22:06:06 +09:00
< 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 >
2026-03-07 22:16:52 +09:00
< 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 >
2026-03-07 22:55:39 +09:00
< button class = " pc-view-tab " : class = " { active: viewMode === 'storyboard' } " @ click = " switchView('storyboard') " style = " color:#f59e0b; " > 스토리보드 </ button >
2026-03-07 22:06:06 +09:00
</ 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 >
2026-03-07 23:49:34 +09:00
< button class = " pc-tb-btn " @ click = " viewMode === 'storyboard' ? sbUndo() : undoAction() " title = " 실행 취소 (Ctrl+Z) " >
2026-03-07 22:06:06 +09:00
< 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 >
2026-03-07 23:49:34 +09:00
< button class = " pc-tb-btn " @ click = " viewMode === 'storyboard' ? sbRedo() : redoAction() " title = " 다시 실행 (Ctrl+Y) " >
2026-03-07 22:06:06 +09:00
< 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 " >
2026-03-08 09:08:21 +09:00
{{ -- Sidebar Expand Button ( visible when collapsed ) -- }}
< button class = " pc-sidebar-expand-btn " x - show = " !sidebarOpen " x - cloak @ click = " sidebarOpen = true " title = " 패널 펼치기 " >
< svg width = " 10 " height = " 10 " viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 2.5 " stroke - linecap = " round " stroke - linejoin = " round " >< polyline points = " 9 6 15 12 9 18 " /></ svg >
</ button >
2026-03-07 22:06:06 +09:00
{{ -- 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 >
2026-03-08 09:08:21 +09:00
< button class = " pc-sidebar-collapse-btn " @ click = " sidebarOpen = false " title = " 패널 접기 " >
< svg width = " 14 " height = " 14 " viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 2 " stroke - linecap = " round " stroke - linejoin = " round " >< polyline points = " 11 17 6 12 11 7 " />< polyline points = " 18 17 13 12 18 7 " /></ svg >
</ button >
2026-03-07 22:06:06 +09:00
</ 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 >
2026-03-07 22:16:52 +09:00
< 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 >
2026-03-07 22:06:06 +09:00
< 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 >
2026-03-07 22:16:52 +09:00
< 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 >
2026-03-07 22:06:06 +09:00
< 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 >
2026-03-07 22:16:52 +09:00
{{ -- 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 ) -- }}
2026-03-07 22:06:06 +09:00
< div class = " pc-canvas-wrap " id = " canvasWrap "
2026-03-07 22:16:52 +09:00
x - show = " viewMode === 'free' || viewMode === 'timeline' || viewMode === 'flow' "
2026-03-07 22:06:06 +09:00
@ 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)' " >
2026-03-07 22:22:35 +09:00
{{ -- SVG Connections ( 프로그래밍 렌더링 — Alpine template은 SVG 네임스페이스 미지원 ) -- }}
< svg class = " pc-connections " id = " connectionsSvg " x - ref = " connSvg " x - effect = " renderConnections() " ></ svg >
2026-03-07 22:06:06 +09:00
{{ -- 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 "
2026-03-07 22:16:52 +09:00
@ mousedown . stop = " onNodeMouseDown( $event , node) "
@ dblclick . stop = " openNodeModal(node) " >
2026-03-07 22:06:06 +09:00
< 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 >
2026-03-07 22:16:52 +09:00
{{ -- 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 >
2026-03-07 22:55:39 +09:00
{{ -- Storyboard View -- }}
< div class = " sb-wrap " x - show = " viewMode === 'storyboard' " >
2026-03-08 00:17:05 +09:00
{{ -- 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 >
2026-03-08 00:41:32 +09:00
{{ -- 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 >
2026-03-08 00:17:05 +09:00
{{ -- 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 >
2026-03-07 22:55:39 +09:00
{{ -- 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 >
2026-03-07 23:51:53 +09:00
< button style = " padding:4px 10px; border:1px solid #e2e8f0; border-radius:6px; font-size:11px; cursor:pointer; background:#fff; color:#2563eb; "
@ click = " sbDuplicatePage() " > 페이지 복사 </ button >
2026-03-07 22:55:39 +09:00
< 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 >
2026-03-08 00:51:12 +09:00
< button style = " padding:4px 10px; border:1px solid #e2e8f0; border-radius:6px; font-size:11px; cursor:pointer; background:#fff; color:#374151; "
@ click = " sbPrintPreview() " > 🖨 인쇄 </ button >
2026-03-07 22:55:39 +09:00
< 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 " >
2026-03-08 09:19:18 +09:00
< div class = " sb-page " : style = " 'width:' + (sidebarOpen ? '1100' : '1400') + 'px' " >
2026-03-07 22:55:39 +09:00
{{ -- 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 >
2026-03-08 00:20:42 +09:00
< div class = " sb-page-body "
2026-03-08 00:41:32 +09:00
@ 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'); } " >
2026-03-07 22:55:39 +09:00
{{ -- Left Menu -- }}
2026-03-08 09:19:18 +09:00
< template x - if = " sbMenuOpen " >
< 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 >
</ template >
2026-03-07 22:55:39 +09:00
2026-03-08 09:19:18 +09:00
{{ -- Menu Toggle + Resizer -- }}
< button class = " sb-menu-toggle " @ click = " sbMenuOpen = !sbMenuOpen " : title = " sbMenuOpen ? '메뉴 접기' : '메뉴 펼치기' " >
< svg width = " 10 " height = " 10 " viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 2.5 " stroke - linecap = " round " stroke - linejoin = " round " >
< polyline : points = " sbMenuOpen ? '15 6 9 12 15 18' : '9 6 15 12 9 18' " />
</ svg >
</ button >
< template x - if = " sbMenuOpen " >
< div class = " sb-menu-resizer "
@ mousedown . prevent = " _sbMenuResize = { startX: $event .clientX, startW: sbMenuWidth }; $event .target.classList.add('active') " ></ div >
</ template >
2026-03-08 00:20:42 +09:00
2026-03-07 22:55:39 +09:00
{{ -- Content + Description -- }}
< div class = " sb-content-area " >
2026-03-08 00:17:05 +09:00
{{ -- Block Editor Area ( moved toolbar to sb - topbar above ) -- }}
< input type = " file " accept = " image/* " x - ref = " sbBlockImageInput " style = " display:none; " @ change = " sbBlockUploadImage( $event ) " >
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
2026-03-07 23:40:14 +09:00
{{ -- Block Editor Area ( Free Canvas ) -- }}
< div class = " sb-blocks-area " x - ref = " sbCanvas "
@ mousedown = " sbCanvasMouseDown( $event ) "
@ mousemove = " sbCanvasMouseMove( $event ) "
@ mouseup = " sbCanvasMouseUp( $event ) "
2026-03-08 01:22:06 +09:00
@ click . self = " if(!_sbLassoDone) { sbSelectedBlock = null; sbMultiSelected = []; sbFormatBar = null; sbCtxMenu = null; sbFmtDropdown = null; } _sbLassoDone = false; "
@ contextmenu . self . prevent = " sbCtxMenu = null; sbCtxSub = null; "
2026-03-08 00:41:32 +09:00
@ dragover . prevent = " $event .dataTransfer.dropEffect = 'copy' "
@ drop . prevent = " sbDropMarker( $event ) "
style = " min-height: 600px; flex: 1; " >
2026-03-08 00:26:26 +09:00
{{ -- 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 >
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
< template x - if = " !sbPageBlocks.length " >
2026-03-07 23:40:14 +09:00
< div class = " sb-blk-empty-area " style = " position:absolute;inset:40px; " @ click = " sbAddBlock('text') " >
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
< 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 >
2026-03-07 22:55:39 +09:00
</ div >
</ template >
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
< template x - for = " (blk, bi) in sbPageBlocks " : key = " blk.id " >
2026-03-07 23:40:14 +09:00
< div class = " sb-block "
2026-03-08 00:26:26 +09:00
: class = " { selected: sbSelectedBlock === blk.id, 'sb-multi-selected': sbMultiSelected.includes(blk.id), 'sb-block-editing': sbEditingBlock === blk.id } "
2026-03-08 01:22:06 +09:00
: 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;' : '' ) "
2026-03-07 23:40:14 +09:00
@ mousedown . stop = " sbBlockMouseDown(blk, bi, $event ) "
2026-03-08 01:22:06 +09:00
@ dblclick = " sbEditingBlock = blk.id "
@ contextmenu . prevent = " sbShowCtxMenu(blk, bi, $event ) " >
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
< 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 >
2026-03-07 22:55:39 +09:00
</ div >
2026-03-07 23:40:14 +09:00
{{ -- 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 >
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
{{ -- 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 >
2026-03-08 00:41:32 +09:00
{{ -- Marker ( description number ) -- }}
< template x - if = " blk.type === 'marker' " >
< div class = " sb-blk-marker " x - text = " blk.content || '01' " ></ div >
</ template >
2026-03-07 22:55:39 +09:00
</ div >
</ template >
</ div >
2026-03-08 01:22:06 +09:00
{{ -- 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 >
2026-03-08 09:19:18 +09:00
{{ -- Desc Toggle + Resizer -- }}
< div class = " sb-desc-toggle-bar " @ click = " sbDescOpen = !sbDescOpen " >
< span class = " sb-desc-toggle-label " > Description </ span >
< svg width = " 10 " height = " 10 " viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 2.5 " stroke - linecap = " round " stroke - linejoin = " round " >
< polyline : points = " sbDescOpen ? '18 15 12 9 6 15' : '6 9 12 15 18 9' " />
</ svg >
</ div >
< template x - if = " sbDescOpen " >
< div class = " sb-desc-resizer "
@ mousedown . stop . prevent = " _sbDescResize = { startY: $event .clientY, startH: sbDescHeight }; $event .target.classList.add('active') " ></ div >
</ template >
2026-03-07 22:55:39 +09:00
{{ -- Description Panel -- }}
2026-03-08 09:19:18 +09:00
< template x - if = " sbDescOpen " >
< div class = " sb-desc-panel " : style = " 'height:' + sbDescHeight + 'px' " >
< 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 >
2026-03-07 22:55:39 +09:00
</ div >
2026-03-08 09:19:18 +09:00
</ template >
< div class = " sb-desc-add " @ click = " sbAddDescription() " >+ Description 항목 추가 </ div >
</ div >
</ template >
2026-03-07 22:55:39 +09:00
</ div >
</ div >
</ div >
</ template >
</ div >
</ div >
</ div >
2026-03-07 22:16:52 +09:00
</ div > {{ -- / Main Content Area -- }}
2026-03-07 22:06:06 +09:00
</ div >
2026-03-07 23:07:17 +09:00
{{ -- 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 >
2026-03-07 22:16:52 +09:00
{{ -- 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 >
2026-03-07 22:06:06 +09:00
{{ -- 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 >
2026-03-07 22:28:40 +09:00
< 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 >
2026-03-07 22:06:06 +09:00
</ 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 ,
2026-03-07 22:16:52 +09:00
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 ,
2026-03-07 22:22:35 +09:00
_connTick : 0 ,
2026-03-07 22:06:06 +09:00
2026-03-07 22:55:39 +09:00
// Storyboard
2026-03-07 23:07:17 +09:00
sbMenuEditorOpen : false ,
sbMenuDraft : [],
_sbMenuDrag : null ,
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
sbSelectedBlock : null ,
2026-03-07 23:40:14 +09:00
sbEditingBlock : null ,
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
sbBlockImageTarget : null ,
_sbBlockDragIdx : null ,
2026-03-07 23:40:14 +09:00
_sbDrag : null , // { blk, startX, startY, origX, origY }
_sbResize : null , // { blk, dir, startX, startY, origW, origH }
2026-03-07 23:43:16 +09:00
_sbClipboard : null , // copied block data
2026-03-08 00:20:42 +09:00
sbMenuWidth : 160 ,
2026-03-08 09:19:18 +09:00
sbMenuOpen : true ,
sbDescOpen : true ,
2026-03-08 00:41:32 +09:00
sbDescHeight : 200 ,
sbMarkerNum : '01' ,
2026-03-08 00:20:42 +09:00
_sbMenuResize : null ,
2026-03-08 00:41:32 +09:00
_sbDescResize : null ,
2026-03-08 00:26:26 +09:00
sbMultiSelected : [], // 다중 선택 블록 id 배열
_sbLasso : null , // { startX, startY, rx, ry, rw, rh }
2026-03-08 00:29:01 +09:00
_sbLassoDone : false , // 올가미 완료 직후 click.self 방지 플래그
2026-03-08 00:26:26 +09:00
_sbMultiDrag : null , // { startX, startY, origins: [{id, x, y}] }
2026-03-07 23:49:34 +09:00
_sbHistory : [],
_sbHistoryIdx : - 1 ,
_sbHistoryPaused : false ,
2026-03-08 01:22:06 +09:00
// 서식 툴바 / 컨텍스트 메뉴
sbFormatBar : null , // { x, y } — 선택된 블록 위에 표시
sbFmtDropdown : null , // 'color' | 'bgColor' | 'fontSize' | null
sbCtxMenu : null , // { x, y, blockId, blockIdx } — 우클릭 메뉴
sbCtxSub : null , // 'color' | 'bgColor' | 'align' | null — 하위 메뉴
2026-03-07 23:29:18 +09:00
sbTplOpen : false ,
sbTplTab : 'preset' ,
sbTplSearch : '' ,
sbTplSaveName : '' ,
sbCustomTemplates : [],
2026-03-07 22:55:39 +09:00
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 ,
},
2026-03-07 22:06:06 +09:00
// Drag State
dragging : false ,
dragNode : null ,
dragOffsetX : 0 ,
dragOffsetY : 0 ,
panning : false ,
panStartX : 0 ,
panStartY : 0 ,
2026-03-07 22:28:40 +09:00
spaceHeld : false ,
2026-03-07 22:06:06 +09:00
// 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' },
],
},
2026-03-07 22:55:39 +09:00
get sbCurrentPage () {
return this . sb . pages [ this . sb . currentPageIndex ] || null ;
},
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
get sbPageBlocks () {
const page = this . sbCurrentPage ;
if ( ! page ) return [];
if ( ! page . blocks ) page . blocks = [];
return page . blocks ;
},
2026-03-07 23:29:18 +09:00
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 ));
},
2026-03-07 22:16:52 +09:00
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 ;
},
2026-03-07 22:06:06 +09:00
init () {
this . loadSavedProjects ();
const currentId = localStorage . getItem ( CURRENT_KEY );
if ( currentId && this . savedProjects . find ( p => p . id === currentId )) {
this . loadProject ( currentId );
} else {
this . newProject ();
}
2026-03-07 22:55:39 +09:00
this . sbInitPages ();
2026-03-07 23:29:18 +09:00
this . sbCustomTemplates = JSON . parse ( localStorage . getItem ( 'sb_custom_templates' ) || '[]' );
2026-03-07 22:06:06 +09:00
},
// ===== 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 ;
2026-03-07 22:55:39 +09:00
// 스토리보드 초기화
this . sb . docInfo = { projectName : '' , unitTask : '' , version : 'D1.0' };
this . sb . pages = [];
this . sb . currentPageIndex = 0 ;
this . sbInitPages ();
2026-03-07 22:06:06 +09:00
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 )),
2026-03-07 22:55:39 +09:00
sb : JSON . parse ( JSON . stringify ( this . sb )),
2026-03-07 22:06:06 +09:00
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 ;
2026-03-07 22:55:39 +09:00
// 스토리보드 데이터 복원
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 ();
2026-03-07 22:06:06 +09:00
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 : [],
2026-03-07 22:16:52 +09:00
assignee : '' ,
dueDate : '' ,
checklist : [],
2026-03-07 22:06:06 +09:00
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 );
}
},
2026-03-07 22:22:35 +09:00
// ===== 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' ;
2026-03-07 22:32:09 +09:00
// 기존 연결선 (화살표 없이 단순 곡선)
2026-03-07 22:22:35 +09:00
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' );
2026-03-07 22:32:09 +09:00
path . setAttribute ( 'stroke-linecap' , 'round' );
2026-03-07 22:22:35 +09:00
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' );
2026-03-07 22:32:09 +09:00
temp . setAttribute ( 'stroke-linecap' , 'round' );
2026-03-07 22:22:35 +09:00
temp . style . pointerEvents = 'none' ;
svg . appendChild ( temp );
}
},
2026-03-07 22:06:06 +09:00
// ===== 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 ) {
2026-03-07 22:28:40 +09:00
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 ) {
2026-03-07 22:06:06 +09:00
this . panning = true ;
this . panStartX = e . clientX - this . panX * this . zoom ;
this . panStartY = e . clientY - this . panY * this . zoom ;
2026-03-07 22:28:40 +09:00
document . getElementById ( 'canvasWrap' ) ? . style . setProperty ( 'cursor' , 'grabbing' );
2026-03-07 22:06:06 +09:00
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 ;
2026-03-07 22:22:35 +09:00
this . _connTick ++ ;
2026-03-07 22:06:06 +09:00
}
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 ();
}
2026-03-07 22:28:40 +09:00
if ( this . panning ) {
this . panning = false ;
const wrap = document . getElementById ( 'canvasWrap' );
if ( this . spaceHeld ) wrap ? . style . setProperty ( 'cursor' , 'grab' );
else wrap ? . style . removeProperty ( 'cursor' );
}
2026-03-07 22:06:06 +09:00
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 ();
2026-03-07 22:16:52 +09:00
else if ( mode === 'flow' ) this . layoutFlow ();
2026-03-07 22:06:06 +09:00
},
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 ;
2026-03-07 22:28:40 +09:00
// 스페이스바: 패닝 모드 (누르고 있는 동안)
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 ;
}
2026-03-07 22:06:06 +09:00
if ( e . key === 'Delete' || e . key === 'Backspace' ) {
if ( this . selectedConnection ) {
2026-03-07 22:28:40 +09:00
this . deleteSelectedConnection ();
2026-03-07 22:06:06 +09:00
} 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' ;
2026-03-07 23:49:34 +09:00
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 ; }
2026-03-07 22:06:06 +09:00
if (( e . ctrlKey || e . metaKey ) && e . key === 's' ) { e . preventDefault (); this . saveProject (); }
if (( e . ctrlKey || e . metaKey ) && e . key === 'd' ) { e . preventDefault (); this . duplicateNode (); }
2026-03-08 00:26:26 +09:00
// 스토리보드 블록 Ctrl+C / Ctrl+V / Delete (단일 + 다중)
if ( this . viewMode === 'storyboard' && ( this . sbSelectedBlock || this . sbMultiSelected . length > 0 )) {
2026-03-07 23:43:16 +09:00
if (( e . ctrlKey || e . metaKey ) && ( e . key === 'c' || e . key === 'C' )) {
e . preventDefault ();
this . sbCopyBlock ();
return ;
}
2026-03-08 00:31:06 +09:00
if (( e . ctrlKey || e . metaKey ) && ( e . key === 'x' || e . key === 'X' )) {
e . preventDefault ();
this . sbCutBlock ();
return ;
}
2026-03-07 23:43:16 +09:00
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 ;
}
}
2026-03-08 00:26:26 +09:00
if ( this . viewMode === 'storyboard' && ! this . sbSelectedBlock && this . sbMultiSelected . length === 0 ) {
2026-03-07 23:43:16 +09:00
if (( e . ctrlKey || e . metaKey ) && ( e . key === 'v' || e . key === 'V' ) && this . _sbClipboard ) {
e . preventDefault ();
this . sbPasteBlock ();
return ;
}
}
2026-03-08 00:26:26 +09:00
// 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 ;
}
}
2026-03-07 22:16:52 +09:00
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 ();
});
}
}
2026-03-07 22:06:06 +09:00
},
2026-03-07 22:28:40 +09:00
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 ();
},
2026-03-07 22:55:39 +09:00
// ===== 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 : '' ,
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
blocks : [],
2026-03-07 22:55:39 +09:00
descriptions : [],
};
},
sbAddPage () {
const newPage = this . sbNewPageData ();
this . sb . pages . push ( newPage );
this . sb . currentPageIndex = this . sb . pages . length - 1 ;
this . autoSave ();
},
2026-03-07 23:51:53 +09:00
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 ();
},
2026-03-07 22:55:39 +09:00
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 ();
},
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
// ===== Block Editor =====
sbNewBlock ( type ) {
const id = 'blk_' + Date . now () + '_' + Math . random () . toString ( 36 ) . slice ( 2 , 5 );
2026-03-07 23:40:14 +09:00
// 자동 배치: 기존 블록 아래에 겹치지 않게
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 ;
}
2026-03-08 00:41:32 +09:00
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 };
2026-03-07 23:40:14 +09:00
const base = { id , type , content : '' , x : 16 , y : autoY , w : defW [ type ] || 240 , h : defH [ type ] || 40 };
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
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 : '' };
2026-03-08 00:41:32 +09:00
case 'marker' : return { ... base , content : base . content || '01' };
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
default : return base ;
}
},
sbAddBlock ( type ) {
const page = this . sbCurrentPage ;
if ( ! page ) return ;
if ( ! page . blocks ) page . blocks = [];
2026-03-07 23:49:34 +09:00
this . sbPushHistory ();
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
const blk = this . sbNewBlock ( type );
page . blocks . push ( blk );
this . sbSelectedBlock = blk . id ;
this . autoSave ();
},
2026-03-08 00:41:32 +09:00
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 ();
},
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
sbAddBlockAfter ( idx , type ) {
const page = this . sbCurrentPage ;
if ( ! page || ! page . blocks ) return ;
2026-03-07 23:49:34 +09:00
this . sbPushHistory ();
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
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 ;
2026-03-07 23:49:34 +09:00
this . sbPushHistory ();
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
page . blocks . splice ( idx , 1 );
this . sbSelectedBlock = null ;
this . autoSave ();
},
sbDuplicateBlock ( idx ) {
const page = this . sbCurrentPage ;
if ( ! page || ! page . blocks ) return ;
2026-03-07 23:49:34 +09:00
this . sbPushHistory ();
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
const copy = JSON . parse ( JSON . stringify ( page . blocks [ idx ]));
copy . id = 'blk_' + Date . now () + '_' + Math . random () . toString ( 36 ) . slice ( 2 , 5 );
2026-03-07 23:40:14 +09:00
copy . x = ( copy . x || 0 ) + 20 ;
copy . y = ( copy . y || 0 ) + 20 ;
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
page . blocks . splice ( idx + 1 , 0 , copy );
this . sbSelectedBlock = copy . id ;
this . autoSave ();
},
2026-03-07 23:40:14 +09:00
// ===== Free Canvas: Drag & Resize =====
sbBlockMouseDown ( blk , bi , e ) {
// 더블클릭 편집 모드에서는 드래그 안 함
if ( this . sbEditingBlock === blk . id ) return ;
2026-03-07 23:49:34 +09:00
this . sbPushHistory ();
2026-03-08 00:26:26 +09:00
// 다중 선택된 블록 중 하나를 클릭하면 그룹 드래그
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 ;
2026-03-08 01:22:06 +09:00
this . sbCtxMenu = null ; // 컨텍스트 메뉴 닫기
this . sbFmtDropdown = null ;
2026-03-07 23:40:14 +09:00
this . _sbDrag = {
blk ,
startX : e . clientX ,
startY : e . clientY ,
origX : blk . x || 0 ,
origY : blk . y || 0 ,
};
2026-03-08 01:22:06 +09:00
this . sbUpdateFormatBar ();
2026-03-07 23:40:14 +09:00
e . preventDefault ();
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
},
2026-03-07 23:40:14 +09:00
sbResizeStart ( blk , dir , e ) {
this . sbSelectedBlock = blk . id ;
2026-03-07 23:49:34 +09:00
this . sbPushHistory ();
2026-03-07 23:40:14 +09:00
this . _sbResize = {
blk ,
dir ,
startX : e . clientX ,
startY : e . clientY ,
origW : blk . w || 240 ,
origH : blk . h || 40 ,
};
e . preventDefault ();
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
},
2026-03-07 23:40:14 +09:00
sbCanvasMouseDown ( e ) {
2026-03-08 00:29:01 +09:00
// 블록이나 리사이즈 핸들 위가 아니면 올가미 시작
const onBlock = e . target . closest ( '.sb-block' );
if ( ! onBlock ) {
2026-03-07 23:40:14 +09:00
this . sbEditingBlock = null ;
2026-03-08 00:26:26 +09:00
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 ;
2026-03-07 23:40:14 +09:00
}
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
},
2026-03-07 23:40:14 +09:00
sbCanvasMouseMove ( e ) {
2026-03-08 01:22:06 +09:00
// 드래그/리사이즈 중에는 서식 툴바 숨기기
if ( this . _sbDrag || this . _sbResize || this . _sbMultiDrag || this . _sbLasso ) {
this . sbFormatBar = null ;
}
2026-03-08 00:26:26 +09:00
// 올가미 드래그
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 ;
}
// 단일 블록 드래그
2026-03-07 23:40:14 +09:00
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 );
}
2026-03-08 00:26:26 +09:00
// 리사이즈
2026-03-07 23:40:14 +09:00
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 );
}
}
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
},
2026-03-07 23:40:14 +09:00
sbCanvasMouseUp ( e ) {
2026-03-08 00:26:26 +09:00
// 올가미 완료 → 범위 내 블록 선택
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 ;
2026-03-08 00:29:01 +09:00
this . _sbLassoDone = true ;
2026-03-08 00:26:26 +09:00
return ;
}
// 다중 드래그 완료
if ( this . _sbMultiDrag ) {
this . _sbMultiDrag = null ;
this . autoSave ();
return ;
}
2026-03-07 23:40:14 +09:00
if ( this . _sbDrag ) {
this . _sbDrag = null ;
this . autoSave ();
2026-03-08 01:22:06 +09:00
this . sbUpdateFormatBar ();
2026-03-07 23:40:14 +09:00
}
if ( this . _sbResize ) {
this . _sbResize = null ;
this . autoSave ();
2026-03-08 01:22:06 +09:00
this . sbUpdateFormatBar ();
2026-03-07 23:40:14 +09:00
}
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
},
2026-03-07 23:43:16 +09:00
sbCopyBlock () {
const page = this . sbCurrentPage ;
if ( ! page || ! page . blocks ) return ;
2026-03-08 00:26:26 +09:00
// 다중 선택 복사
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 ;
}
// 단일 복사
2026-03-07 23:43:16 +09:00
const blk = page . blocks . find ( b => b . id === this . sbSelectedBlock );
if ( ! blk ) return ;
this . _sbClipboard = JSON . parse ( JSON . stringify ( blk ));
},
2026-03-08 00:31:06 +09:00
sbCutBlock () {
this . sbCopyBlock ();
this . sbDeleteSelectedBlock ();
},
2026-03-07 23:43:16 +09:00
sbPasteBlock () {
if ( ! this . _sbClipboard ) return ;
const page = this . sbCurrentPage ;
if ( ! page ) return ;
if ( ! page . blocks ) page . blocks = [];
2026-03-07 23:49:34 +09:00
this . sbPushHistory ();
2026-03-08 00:26:26 +09:00
// 다중 붙여넣기
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 ;
}
// 단일 붙여넣기
2026-03-07 23:43:16 +09:00
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 ;
2026-03-08 00:26:26 +09:00
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 ;
}
// 단일 삭제
2026-03-07 23:43:16 +09:00
const idx = page . blocks . findIndex ( b => b . id === this . sbSelectedBlock );
if ( idx < 0 ) return ;
page . blocks . splice ( idx , 1 );
this . sbSelectedBlock = null ;
this . autoSave ();
},
2026-03-07 23:49:34 +09:00
// ===== 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 () {
2026-03-08 00:34:11 +09:00
if ( this . _sbHistory . length === 0 ) return ;
2026-03-07 23:49:34 +09:00
const page = this . sbCurrentPage ;
if ( ! page ) return ;
2026-03-08 00:34:11 +09:00
// 처음 undo 시 현재(변경 후) 상태를 히스토리 끝에 저장
const curSnap = JSON . parse ( JSON . stringify ( page . blocks || []));
2026-03-07 23:49:34 +09:00
if ( this . _sbHistoryIdx === this . _sbHistory . length - 1 ) {
2026-03-08 00:34:11 +09:00
const lastSnap = JSON . stringify ( this . _sbHistory [ this . _sbHistoryIdx ]);
if ( lastSnap !== JSON . stringify ( curSnap )) {
this . _sbHistory . push ( curSnap );
2026-03-07 23:49:34 +09:00
this . _sbHistoryIdx = this . _sbHistory . length - 1 ;
}
}
2026-03-08 00:34:11 +09:00
if ( this . _sbHistoryIdx <= 0 ) return ;
2026-03-07 23:49:34 +09:00
this . _sbHistoryIdx -- ;
this . _sbHistoryPaused = true ;
page . blocks = JSON . parse ( JSON . stringify ( this . _sbHistory [ this . _sbHistoryIdx ]));
this . sbSelectedBlock = null ;
2026-03-08 00:34:11 +09:00
this . sbMultiSelected = [];
2026-03-07 23:49:34 +09:00
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 ]));
2026-03-08 00:34:11 +09:00
this . sbMultiSelected = [];
2026-03-07 23:49:34 +09:00
this . sbSelectedBlock = null ;
this . _sbHistoryPaused = false ;
this . autoSave ();
},
2026-03-08 01:22:06 +09:00
// ===== 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 ();
},
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
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 ) {
2026-03-07 22:55:39 +09:00
const file = e . target . files [ 0 ];
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
if ( ! file || ! this . sbBlockImageTarget ) return ;
2026-03-07 22:55:39 +09:00
const reader = new FileReader ();
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
const target = this . sbBlockImageTarget ;
2026-03-07 22:55:39 +09:00
reader . onload = () => {
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
target . src = reader . result ;
this . autoSave ();
2026-03-07 22:55:39 +09:00
};
reader . readAsDataURL ( file );
e . target . value = '' ;
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
this . sbBlockImageTarget = null ;
2026-03-07 22:55:39 +09:00
},
2026-03-07 23:29:18 +09:00
// ===== Template System =====
sbInsertTemplate ( tpl ) {
const page = this . sbCurrentPage ;
if ( ! page ) return ;
if ( ! page . blocks ) page . blocks = [];
2026-03-07 23:49:34 +09:00
this . sbPushHistory ();
2026-03-07 23:29:18 +09:00
const blocks = JSON . parse ( JSON . stringify ( tpl . blocks ));
2026-03-07 23:40:14 +09:00
// 기존 블록 아래에 배치
let curY = 16 ;
if ( page . blocks . length > 0 ) {
curY = Math . max ( ... page . blocks . map ( b => ( b . y || 0 ) + ( b . h || 40 ))) + 16 ;
}
2026-03-08 00:41:32 +09:00
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 };
2026-03-07 23:29:18 +09:00
blocks . forEach ( blk => {
blk . id = 'blk_' + Date . now () + '_' + Math . random () . toString ( 36 ) . slice ( 2 , 5 );
2026-03-07 23:40:14 +09:00
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 ;
2026-03-07 23:29:18 +09:00
});
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 ));
},
2026-03-07 23:34:37 +09:00
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' ;
});
}
},
2026-03-07 22:55:39 +09:00
sbEditMenu () {
2026-03-07 23:07:17 +09:00
// 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 );
}
2026-03-07 22:55:39 +09:00
}
},
2026-03-07 23:07:17 +09:00
sbMenuDragEnd () {
this . _sbMenuDrag = null ;
},
2026-03-07 22:55:39 +09:00
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">' ;
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
if ( pg . blocks && pg . blocks . length > 0 ) {
2026-03-08 00:51:12 +09:00
// 캔버스 높이 계산
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>' ;
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
} 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>' ;
}
2026-03-07 22:55:39 +09:00
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 );
},
2026-03-08 00:51:12 +09:00
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 (); };
},
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
sbExportBlock ( blk ) {
const esc = ( s ) => ( s || '' ) . replace ( /</ g , '<' ) . replace ( />/ g , '>' );
2026-03-08 01:22:06 +09:00
// 블록 스타일 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 + '"' : '' ;
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
switch ( blk . type ) {
2026-03-08 01:22:06 +09:00
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>' ;
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
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>' : '' ;
2026-03-08 00:41:32 +09:00
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>' ;
feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
default : return '<p>' + esc ( blk . content ) + '</p>' ;
}
},
2026-03-07 22:06:06 +09:00
// ===== 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' );
},
2026-03-07 22:16:52 +09:00
// ===== 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 ];
}
},
2026-03-07 22:06:06 +09:00
// ===== 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' ;
},
2026-03-07 22:16:52 +09:00
statusLabel ( status ) {
const map = { todo : '대기' , progress : '진행중' , review : '검토중' , done : '완료' };
return map [ status ] || status ;
},
2026-03-07 22:06:06 +09:00
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' );
},
2026-03-07 22:16:52 +09:00
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 ;
},
2026-03-07 22:06:06 +09:00
};
}
</ script >
@ endpush