Spaces:
Paused
Paused
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Structured Prompt Studio</title> | |
| <style> | |
| /* ==================== Reset & Variables ==================== */ | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| :root { | |
| --bg: #f6f7fb; | |
| --bg-gradient: radial-gradient(1200px 800px at 15% -10%, #eef2ff 0%, transparent 55%), | |
| radial-gradient(1000px 700px at 110% 110%, #fdf4ff 0%, transparent 60%), | |
| #f6f7fb; | |
| --panel-bg: #ffffff; | |
| --panel-bg-subtle: #f8fafc; | |
| --border: #e5e7eb; | |
| --border-light: #eef0f4; | |
| --border-hover: #d1d5db; | |
| --text: #0f172a; | |
| --text-secondary: #64748b; | |
| --text-placeholder: #94a3b8; | |
| --primary: #6366f1; | |
| --primary-hover: #4f46e5; | |
| --primary-light: #eef2ff; | |
| --primary-dark: #4338ca; | |
| --primary-ring: rgba(99, 102, 241, 0.18); | |
| --primary-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); | |
| --success: #10b981; | |
| --success-light: #ecfdf5; | |
| --warning: #f59e0b; | |
| --warning-light: #fffbeb; | |
| --danger: #ef4444; | |
| --danger-light: #fef2f2; | |
| --info: #64748b; | |
| --info-light: #f1f5f9; | |
| --shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.04); | |
| --shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 2px 8px rgba(15, 23, 42, 0.04); | |
| --shadow-lg: 0 4px 20px rgba(15, 23, 42, 0.08); | |
| --shadow-primary: 0 4px 14px rgba(99, 102, 241, 0.25); | |
| --radius-sm: 6px; | |
| --radius: 10px; | |
| --radius-lg: 14px; | |
| --transition: all 160ms cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| body { | |
| font-family: 'Inter', ui-sans-serif, -apple-system, BlinkMacSystemFont, | |
| 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif, | |
| 'Apple Color Emoji', 'Segoe UI Emoji'; | |
| background: var(--bg-gradient); | |
| color: var(--text); | |
| height: 100vh; | |
| overflow: hidden; | |
| font-size: 14px; | |
| line-height: 1.5; | |
| -webkit-font-smoothing: antialiased; | |
| -moz-osx-font-smoothing: grayscale; | |
| } | |
| /* ==================== App Layout ==================== */ | |
| .app { | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| padding: 14px; | |
| gap: 12px; | |
| } | |
| /* ==================== History Bar ==================== */ | |
| .history-bar { | |
| flex-shrink: 0; | |
| background: var(--panel-bg); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-lg); | |
| box-shadow: var(--shadow); | |
| display: flex; | |
| align-items: center; | |
| padding: 0 18px; | |
| height: 52px; | |
| gap: 14px; | |
| } | |
| .history-title { | |
| font-weight: 700; | |
| font-size: 11.5px; | |
| color: var(--text-secondary); | |
| flex-shrink: 0; | |
| user-select: none; | |
| letter-spacing: 0.7px; | |
| text-transform: uppercase; | |
| } | |
| .history-scroll { | |
| flex: 1; | |
| display: flex; | |
| gap: 6px; | |
| overflow-x: auto; | |
| overflow-y: hidden; | |
| align-items: center; | |
| padding: 4px 0; | |
| } | |
| .history-scroll::-webkit-scrollbar { | |
| height: 3px; | |
| } | |
| .history-scroll::-webkit-scrollbar-thumb { | |
| background: var(--border); | |
| border-radius: 2px; | |
| } | |
| .history-empty { | |
| color: var(--text-placeholder); | |
| font-size: 13px; | |
| white-space: nowrap; | |
| } | |
| .history-clear-btn { | |
| flex-shrink: 0; | |
| } | |
| /* History Item */ | |
| .history-item { | |
| flex-shrink: 0; | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| padding: 4px 8px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| transition: var(--transition); | |
| border: 1px solid var(--border); | |
| background: var(--bg); | |
| font-size: 11px; | |
| white-space: nowrap; | |
| } | |
| .history-item:hover { | |
| border-color: var(--primary); | |
| background: var(--primary-light); | |
| } | |
| .history-thumb { | |
| width: 26px; | |
| height: 26px; | |
| border-radius: 3px; | |
| object-fit: cover; | |
| border: 1px solid var(--border); | |
| } | |
| .history-thumb.repeated { | |
| border: 2px solid #333; | |
| } | |
| .history-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 3px; | |
| font-size: 10px; | |
| font-weight: 700; | |
| color: white; | |
| flex-shrink: 0; | |
| } | |
| .history-badge.badge-p { background: var(--primary); } | |
| .history-badge.badge-c { background: var(--warning); } | |
| .history-badge.badge-s { background: var(--success); } | |
| .history-type-label { | |
| font-size: 10px; | |
| font-weight: 600; | |
| color: var(--text-secondary); | |
| } | |
| .history-separator { | |
| width: 1px; | |
| height: 22px; | |
| background: var(--border); | |
| flex-shrink: 0; | |
| margin: 0 2px; | |
| } | |
| /* History group tinting — one muted palette with a subtle left-accent bar */ | |
| .history-item.hc-0 { background: var(--panel-bg); border-color: var(--border); border-left: 3px solid #6366f1; } | |
| .history-item.hc-1 { background: var(--panel-bg); border-color: var(--border); border-left: 3px solid #10b981; } | |
| .history-item.hc-2 { background: var(--panel-bg); border-color: var(--border); border-left: 3px solid #f59e0b; } | |
| .history-item.hc-3 { background: var(--panel-bg); border-color: var(--border); border-left: 3px solid #ef4444; } | |
| .history-item.hc-4 { background: var(--panel-bg); border-color: var(--border); border-left: 3px solid #64748b; } | |
| .history-item.hc-5 { background: var(--panel-bg); border-color: var(--border); border-left: 3px solid #06b6d4; } | |
| .history-item.hc-0:hover, | |
| .history-item.hc-1:hover, | |
| .history-item.hc-2:hover, | |
| .history-item.hc-3:hover, | |
| .history-item.hc-4:hover, | |
| .history-item.hc-5:hover { | |
| background: var(--primary-light); | |
| border-color: var(--primary); | |
| } | |
| /* ==================== Workspace (CSS Grid) ==================== */ | |
| .workspace { | |
| flex: 1; | |
| display: grid; | |
| grid-template-columns: 1fr minmax(280px, 0.45fr); | |
| grid-template-rows: 1fr auto auto; | |
| gap: 10px; | |
| min-height: 0; | |
| } | |
| /* Grid placement */ | |
| .image-panel { grid-row: 1; grid-column: 1; } | |
| .sp-panel { grid-row: 1; grid-column: 2; } | |
| .params-bar { grid-row: 2; grid-column: 1; } | |
| .gen-sp-btn { grid-row: 2; grid-column: 2; } | |
| .prompt-bar { grid-row: 3; grid-column: 1; } | |
| .gen-image-btn { grid-row: 3; grid-column: 2; } | |
| /* ==================== Panel Base ==================== */ | |
| .panel { | |
| background: var(--panel-bg); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-lg); | |
| box-shadow: var(--shadow); | |
| overflow: hidden; | |
| } | |
| .panel-toolbar { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 8px 14px; | |
| border-bottom: 1px solid var(--border-light); | |
| background: var(--panel-bg-subtle); | |
| flex-shrink: 0; | |
| } | |
| .panel-label { | |
| font-weight: 700; | |
| font-size: 11.5px; | |
| color: var(--text-secondary); | |
| letter-spacing: 0.7px; | |
| text-transform: uppercase; | |
| user-select: none; | |
| } | |
| .panel-actions { | |
| display: flex; | |
| gap: 2px; | |
| } | |
| /* Tool Buttons */ | |
| .tool-btn { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 30px; | |
| height: 30px; | |
| border: 1px solid transparent; | |
| background: transparent; | |
| color: var(--text-secondary); | |
| border-radius: var(--radius-sm); | |
| cursor: pointer; | |
| transition: var(--transition); | |
| } | |
| .tool-btn:hover { | |
| background: var(--primary-light); | |
| color: var(--primary); | |
| border-color: var(--border-light); | |
| } | |
| /* ==================== Image Panel ==================== */ | |
| .image-panel { | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 0; | |
| } | |
| .image-area { | |
| flex: 1; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| position: relative; | |
| min-height: 0; | |
| overflow: hidden; | |
| } | |
| /* Dropzone */ | |
| .dropzone { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 14px; | |
| width: calc(100% - 28px); | |
| height: calc(100% - 28px); | |
| margin: 14px; | |
| cursor: pointer; | |
| border: 1.5px dashed var(--border-hover); | |
| border-radius: var(--radius); | |
| transition: var(--transition); | |
| background: var(--panel-bg-subtle); | |
| } | |
| .dropzone:hover, | |
| .dropzone.dragover { | |
| border-color: var(--primary); | |
| background: var(--primary-light); | |
| transform: scale(1.005); | |
| } | |
| .dropzone-icon { | |
| font-size: 44px; | |
| opacity: 0.55; | |
| transition: var(--transition); | |
| } | |
| .dropzone:hover .dropzone-icon { | |
| opacity: 0.85; | |
| transform: translateY(-2px); | |
| } | |
| .dropzone-text { | |
| color: var(--text-secondary); | |
| font-size: 13px; | |
| font-weight: 500; | |
| letter-spacing: 0.1px; | |
| } | |
| /* Image Display */ | |
| .image-display { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 8px; | |
| } | |
| .image-display img { | |
| max-width: 100%; | |
| max-height: 100%; | |
| object-fit: contain; | |
| cursor: pointer; | |
| border-radius: 4px; | |
| transition: opacity 0.2s; | |
| } | |
| .image-display img:hover { | |
| opacity: 0.95; | |
| } | |
| /* BBox Overlay */ | |
| .bbox-container { | |
| position: absolute; | |
| pointer-events: none; | |
| display: none; | |
| } | |
| .bbox-box { | |
| position: absolute; | |
| border: 2px solid; | |
| pointer-events: auto; | |
| cursor: default; | |
| border-radius: 2px; | |
| } | |
| .bbox-box.focused { | |
| border-width: 3px; | |
| z-index: 10; | |
| } | |
| .bbox-box.active { | |
| border-width: 3px; | |
| z-index: 10; | |
| cursor: move; | |
| } | |
| .bbox-box .bbox-handle { | |
| display: none; | |
| } | |
| .bbox-box.active .bbox-handle { | |
| display: block; | |
| } | |
| .bbox-box .bbox-label { | |
| position: absolute; | |
| top: -20px; | |
| left: -1px; | |
| padding: 1px 6px; | |
| font-size: 10px; | |
| color: white; | |
| border-radius: 3px 3px 0 0; | |
| white-space: nowrap; | |
| max-width: 140px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| font-weight: 500; | |
| line-height: 16px; | |
| pointer-events: none; | |
| } | |
| /* BBox Resize Handles */ | |
| .bbox-handle { | |
| position: absolute; | |
| width: 8px; | |
| height: 8px; | |
| background: white; | |
| border: 2px solid; | |
| border-radius: 2px; | |
| pointer-events: auto; | |
| z-index: 11; | |
| } | |
| .bbox-handle.h-nw { top: -5px; left: -5px; cursor: nw-resize; } | |
| .bbox-handle.h-ne { top: -5px; right: -5px; cursor: ne-resize; } | |
| .bbox-handle.h-sw { bottom: -5px; left: -5px; cursor: sw-resize; } | |
| .bbox-handle.h-se { bottom: -5px; right: -5px; cursor: se-resize; } | |
| .bbox-handle.h-n { top: -5px; left: 50%; margin-left: -4px; cursor: n-resize; } | |
| .bbox-handle.h-s { bottom: -5px; left: 50%; margin-left: -4px; cursor: s-resize; } | |
| .bbox-handle.h-w { top: 50%; margin-top: -4px; left: -5px; cursor: w-resize; } | |
| .bbox-handle.h-e { top: 50%; margin-top: -4px; right: -5px; cursor: e-resize; } | |
| /* ==================== Structured Prompt Panel ==================== */ | |
| .sp-panel { | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 0; | |
| } | |
| .sp-area { | |
| flex: 1; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 0; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .sp-empty { | |
| text-align: center; | |
| color: var(--text-placeholder); | |
| padding: 20px; | |
| } | |
| .sp-empty-icon { | |
| font-size: 36px; | |
| margin-bottom: 8px; | |
| opacity: 0.5; | |
| } | |
| .sp-empty-hint { | |
| font-size: 12px; | |
| margin-top: 6px; | |
| color: var(--text-placeholder); | |
| } | |
| .sp-area textarea { | |
| width: 100%; | |
| height: 100%; | |
| border: none; | |
| outline: none; | |
| resize: none; | |
| padding: 12px; | |
| font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', 'Liberation Mono', monospace; | |
| font-size: 12px; | |
| line-height: 1.65; | |
| color: var(--text); | |
| background: transparent; | |
| tab-size: 2; | |
| } | |
| .sp-area textarea::placeholder { | |
| color: var(--text-placeholder); | |
| } | |
| /* ==================== SP Structured View ==================== */ | |
| .sp-structured { | |
| width: 100%; | |
| height: 100%; | |
| overflow-y: auto; | |
| padding: 8px; | |
| } | |
| /* Section (collapsible) */ | |
| .sp-section { | |
| border: 1px solid var(--border-light); | |
| border-radius: 6px; | |
| margin-bottom: 6px; | |
| overflow: hidden; | |
| background: var(--panel-bg); | |
| } | |
| .sp-section:last-child { | |
| margin-bottom: 0; | |
| } | |
| .sp-section-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 7px 10px; | |
| cursor: pointer; | |
| user-select: none; | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--text); | |
| transition: var(--transition); | |
| } | |
| .sp-section-header:hover { | |
| background: var(--bg); | |
| } | |
| .sp-section-title { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .sp-section-badge { | |
| background: var(--primary); | |
| color: white; | |
| border-radius: 8px; | |
| padding: 0 6px; | |
| font-size: 10px; | |
| font-weight: 700; | |
| line-height: 16px; | |
| min-width: 16px; | |
| text-align: center; | |
| } | |
| .sp-section-actions { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .sp-section-add-btn { | |
| width: 20px; | |
| height: 20px; | |
| border: 1px dashed var(--border); | |
| background: transparent; | |
| color: var(--text-secondary); | |
| border-radius: 3px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 14px; | |
| transition: var(--transition); | |
| padding: 0; | |
| line-height: 1; | |
| } | |
| .sp-section-add-btn:hover { | |
| border-color: var(--primary); | |
| color: var(--primary); | |
| background: var(--primary-light); | |
| } | |
| .sp-section-clear-btn { | |
| width: 20px; | |
| height: 20px; | |
| border: none; | |
| background: transparent; | |
| color: var(--text-placeholder); | |
| border-radius: 3px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 11px; | |
| transition: var(--transition); | |
| padding: 0; | |
| opacity: 0; | |
| } | |
| .sp-section-header:hover .sp-section-clear-btn { | |
| opacity: 1; | |
| } | |
| .sp-section-clear-btn:hover { | |
| background: var(--danger-light); | |
| color: var(--danger); | |
| } | |
| .sp-section-arrow { | |
| font-size: 12px; | |
| color: var(--text-secondary); | |
| transition: transform 0.2s; | |
| } | |
| .sp-section.collapsed .sp-section-arrow { | |
| transform: rotate(-90deg); | |
| } | |
| .sp-section-body { | |
| padding: 4px 8px 8px; | |
| overflow: hidden; | |
| } | |
| .sp-section.collapsed .sp-section-body { | |
| display: none; | |
| } | |
| /* Field row */ | |
| .sp-field { | |
| display: flex; | |
| align-items: flex-start; | |
| padding: 4px 6px; | |
| border-radius: 4px; | |
| transition: background 0.15s; | |
| gap: 8px; | |
| position: relative; | |
| } | |
| .sp-field:hover { | |
| background: #f5f7fa; | |
| } | |
| .sp-field-key { | |
| font-size: 11px; | |
| font-weight: 600; | |
| color: var(--primary-dark); | |
| min-width: 80px; | |
| flex-shrink: 0; | |
| padding-top: 1px; | |
| font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; | |
| cursor: default; | |
| border-radius: 3px; | |
| transition: background 0.15s; | |
| } | |
| .sp-field-key[contenteditable="true"] { | |
| outline: none; | |
| background: #fff; | |
| border: 1px solid var(--warning); | |
| padding: 0 3px; | |
| box-shadow: 0 0 0 2px rgba(230, 162, 60, 0.12); | |
| user-select: text; | |
| } | |
| .sp-field-value { | |
| flex: 1; | |
| font-size: 12px; | |
| line-height: 1.5; | |
| color: var(--text); | |
| word-break: break-word; | |
| cursor: default; | |
| min-height: 18px; | |
| border-radius: 3px; | |
| transition: background 0.15s; | |
| } | |
| .sp-field-value[contenteditable="true"] { | |
| outline: none; | |
| background: #fff; | |
| border: 1px solid var(--primary); | |
| border-radius: 3px; | |
| padding: 1px 4px; | |
| box-shadow: 0 0 0 2px var(--primary-ring); | |
| } | |
| .sp-field-actions { | |
| display: none; | |
| gap: 2px; | |
| flex-shrink: 0; | |
| align-items: center; | |
| } | |
| .sp-field:hover .sp-field-actions { | |
| display: flex; | |
| } | |
| .sp-field-btn { | |
| width: 20px; | |
| height: 20px; | |
| border: none; | |
| background: transparent; | |
| color: var(--text-secondary); | |
| border-radius: 3px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 12px; | |
| transition: var(--transition); | |
| padding: 0; | |
| } | |
| .sp-field-btn:hover { | |
| background: var(--danger-light); | |
| color: var(--danger); | |
| } | |
| .sp-field-btn.edit-btn:hover, | |
| .sp-field-btn.sp-elem-add-btn:hover { | |
| background: var(--primary-light); | |
| color: var(--primary); | |
| } | |
| /* Dict field (nested) */ | |
| .sp-dict-block { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1px; | |
| } | |
| .sp-dict-row { | |
| display: flex; | |
| gap: 6px; | |
| padding: 2px 0; | |
| align-items: flex-start; | |
| } | |
| .sp-dict-key { | |
| font-size: 11px; | |
| color: var(--text-secondary); | |
| min-width: 70px; | |
| flex-shrink: 0; | |
| font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; | |
| cursor: default; | |
| border-radius: 3px; | |
| } | |
| .sp-dict-key[contenteditable="true"] { | |
| outline: none; | |
| background: #fff; | |
| border: 1px solid var(--warning); | |
| padding: 0 3px; | |
| box-shadow: 0 0 0 2px rgba(230, 162, 60, 0.12); | |
| } | |
| .sp-dict-value { | |
| flex: 1; | |
| font-size: 12px; | |
| color: var(--text); | |
| word-break: break-word; | |
| cursor: default; | |
| border-radius: 3px; | |
| } | |
| .sp-dict-value[contenteditable="true"] { | |
| outline: none; | |
| background: #fff; | |
| border: 1px solid var(--primary); | |
| border-radius: 3px; | |
| padding: 0 4px; | |
| box-shadow: 0 0 0 2px var(--primary-ring); | |
| } | |
| /* Element card */ | |
| .sp-element { | |
| border: 1px solid var(--border-light); | |
| border-radius: 6px; | |
| margin-bottom: 5px; | |
| overflow: hidden; | |
| transition: border-color 0.15s, box-shadow 0.15s; | |
| } | |
| .sp-element:last-child { | |
| margin-bottom: 0; | |
| } | |
| .sp-element:hover { | |
| border-color: var(--border); | |
| } | |
| .sp-element.highlight { | |
| border-color: var(--primary); | |
| box-shadow: 0 0 0 2px var(--primary-ring); | |
| } | |
| .sp-element-header { | |
| display: flex; | |
| align-items: center; | |
| padding: 5px 8px; | |
| gap: 6px; | |
| cursor: pointer; | |
| user-select: none; | |
| transition: background 0.15s; | |
| } | |
| .sp-element-header:hover { | |
| background: #f5f7fa; | |
| } | |
| .sp-element-color { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| } | |
| .sp-element-id { | |
| font-size: 11px; | |
| font-weight: 700; | |
| color: var(--text-secondary); | |
| font-family: monospace; | |
| flex-shrink: 0; | |
| } | |
| .sp-element-caption { | |
| flex: 1; | |
| font-size: 12px; | |
| color: var(--text); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| font-weight: 500; | |
| } | |
| .sp-element-actions { | |
| display: none; | |
| gap: 2px; | |
| } | |
| .sp-element-header:hover .sp-element-actions { | |
| display: flex; | |
| } | |
| .sp-element-body { | |
| padding: 2px 8px 6px; | |
| border-top: 1px solid var(--border-light); | |
| } | |
| .sp-element.collapsed .sp-element-body { | |
| display: none; | |
| } | |
| /* Position badge (bbox) */ | |
| .sp-pos-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| background: var(--info-light); | |
| padding: 1px 6px; | |
| border-radius: 3px; | |
| font-family: monospace; | |
| font-size: 11px; | |
| color: var(--text-secondary); | |
| cursor: pointer; | |
| transition: var(--transition); | |
| } | |
| .sp-pos-badge:hover { | |
| background: var(--primary-light); | |
| color: var(--primary); | |
| } | |
| .sp-pos-badge .pos-icon { | |
| font-size: 10px; | |
| } | |
| .sp-bbox-edit-btn { | |
| margin-left: 4px; | |
| padding: 0 4px; | |
| font-size: 11px; | |
| line-height: 18px; | |
| vertical-align: middle; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| border: 1px solid transparent; | |
| background: transparent; | |
| opacity: 0.5; | |
| transition: var(--transition); | |
| } | |
| .sp-bbox-edit-btn:hover { | |
| opacity: 1; | |
| background: var(--primary-light); | |
| border-color: var(--primary); | |
| } | |
| /* Relationship list */ | |
| .sp-rel-list { | |
| list-style: none; | |
| padding: 0; | |
| margin: 0; | |
| } | |
| .sp-rel-item { | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 6px; | |
| padding: 3px 6px; | |
| border-radius: 4px; | |
| transition: background 0.15s; | |
| font-size: 12px; | |
| color: var(--text); | |
| } | |
| .sp-rel-item:hover { | |
| background: #f5f7fa; | |
| } | |
| .sp-rel-bullet { | |
| color: var(--text-placeholder); | |
| flex-shrink: 0; | |
| } | |
| .sp-rel-text { | |
| flex: 1; | |
| word-break: break-word; | |
| cursor: default; | |
| border-radius: 3px; | |
| } | |
| /* Active state for raw toggle button */ | |
| .tool-btn.active { | |
| background: var(--primary-light); | |
| color: var(--primary); | |
| } | |
| /* ==================== Params Bar ==================== */ | |
| .params-bar { | |
| display: flex; | |
| align-items: center; | |
| padding: 0 12px; | |
| gap: 10px; | |
| min-height: 46px; | |
| flex-wrap: wrap; | |
| } | |
| .params-label { | |
| font-size: 11.5px; | |
| font-weight: 700; | |
| color: var(--text-secondary); | |
| white-space: nowrap; | |
| user-select: none; | |
| letter-spacing: 0.7px; | |
| text-transform: uppercase; | |
| } | |
| .params-separator { | |
| width: 1px; | |
| height: 20px; | |
| background: var(--border); | |
| flex-shrink: 0; | |
| } | |
| .params-spacer { | |
| flex: 1; | |
| } | |
| .params-group { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .params-group label { | |
| font-size: 11px; | |
| font-weight: 500; | |
| color: var(--text-secondary); | |
| white-space: nowrap; | |
| user-select: none; | |
| } | |
| .params-group input[type="number"] { | |
| width: 66px; | |
| height: 28px; | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| padding: 0 6px; | |
| font-size: 12px; | |
| color: var(--text); | |
| background: var(--panel-bg); | |
| text-align: center; | |
| outline: none; | |
| transition: var(--transition); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .params-group input[type="number"]:hover:not(:disabled) { | |
| border-color: var(--border-hover); | |
| } | |
| .params-group input[type="number"]:focus { | |
| border-color: var(--primary); | |
| box-shadow: 0 0 0 3px var(--primary-ring); | |
| } | |
| .params-group input[type="number"]:disabled { | |
| background: var(--bg); | |
| color: var(--text-placeholder); | |
| cursor: not-allowed; | |
| } | |
| .params-group select { | |
| height: 26px; | |
| border: 1px solid var(--border); | |
| border-radius: 4px; | |
| padding: 0 4px; | |
| font-size: 12px; | |
| color: var(--text); | |
| background: var(--panel-bg); | |
| outline: none; | |
| cursor: pointer; | |
| transition: var(--transition); | |
| } | |
| .params-group select:focus { | |
| border-color: var(--primary); | |
| box-shadow: 0 0 0 3px var(--primary-ring); | |
| } | |
| .params-checkbox { | |
| gap: 3px ; | |
| } | |
| .params-checkbox input[type="checkbox"] { | |
| width: 14px; | |
| height: 14px; | |
| cursor: pointer; | |
| accent-color: var(--primary); | |
| } | |
| /* ==================== Prompt Bar ==================== */ | |
| .prompt-bar { | |
| display: flex; | |
| align-items: center; | |
| padding: 0 16px; | |
| gap: 12px; | |
| min-height: 50px; | |
| } | |
| .prompt-label { | |
| font-size: 11.5px; | |
| font-weight: 700; | |
| color: var(--text-secondary); | |
| white-space: nowrap; | |
| user-select: none; | |
| flex-shrink: 0; | |
| letter-spacing: 0.7px; | |
| text-transform: uppercase; | |
| } | |
| .prompt-bar textarea { | |
| flex: 1; | |
| border: none; | |
| outline: none; | |
| resize: none; | |
| font-size: 14px; | |
| line-height: 1.5; | |
| color: var(--text); | |
| background: transparent; | |
| height: 32px; | |
| font-family: inherit; | |
| padding: 4px 0; | |
| } | |
| .prompt-bar textarea::placeholder { | |
| color: var(--text-placeholder); | |
| } | |
| /* ==================== Action Buttons ==================== */ | |
| .action-btn { | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| font-size: 14px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: var(--transition); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| box-shadow: var(--shadow-xs); | |
| user-select: none; | |
| background: var(--panel-bg); | |
| color: var(--text); | |
| letter-spacing: 0.15px; | |
| } | |
| .action-btn:hover:not(:disabled) { | |
| transform: translateY(-1px); | |
| box-shadow: var(--shadow); | |
| border-color: var(--primary); | |
| color: var(--primary); | |
| } | |
| .action-btn:active:not(:disabled) { | |
| transform: translateY(0); | |
| box-shadow: var(--shadow-xs); | |
| } | |
| .action-btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| /* Secondary action — Generate SP */ | |
| .gen-sp-btn { | |
| min-height: 46px; | |
| font-size: 14px; | |
| } | |
| /* Primary action — the CTA */ | |
| .gen-image-btn { | |
| min-height: 52px; | |
| font-size: 14.5px; | |
| font-weight: 600; | |
| background: var(--primary-gradient); | |
| color: #ffffff; | |
| border: 1px solid transparent; | |
| box-shadow: var(--shadow-primary); | |
| } | |
| .gen-image-btn:hover:not(:disabled) { | |
| color: #ffffff; | |
| border-color: transparent; | |
| filter: brightness(1.06); | |
| box-shadow: 0 6px 22px rgba(99, 102, 241, 0.32); | |
| transform: translateY(-1px); | |
| } | |
| .gen-image-btn:active:not(:disabled) { | |
| filter: brightness(0.98); | |
| box-shadow: var(--shadow-primary); | |
| } | |
| .gen-sp-btn.loading, | |
| .gen-image-btn.loading { | |
| pointer-events: none; | |
| opacity: 0.75; | |
| } | |
| /* ==================== Modals ==================== */ | |
| .modal-overlay { | |
| display: none; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.55); | |
| z-index: 1000; | |
| align-items: center; | |
| justify-content: center; | |
| backdrop-filter: blur(2px); | |
| } | |
| .modal-overlay.visible { | |
| display: flex; | |
| } | |
| .modal-close-btn { | |
| position: fixed; | |
| top: 16px; | |
| right: 16px; | |
| width: 36px; | |
| height: 36px; | |
| border: none; | |
| background: rgba(255, 255, 255, 0.92); | |
| color: #333; | |
| border-radius: 50%; | |
| font-size: 18px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 1001; | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); | |
| transition: var(--transition); | |
| } | |
| .modal-close-btn:hover { | |
| background: white; | |
| transform: scale(1.1); | |
| } | |
| .modal-close-btn-sm { | |
| width: 28px; | |
| height: 28px; | |
| border: none; | |
| background: transparent; | |
| color: var(--text-secondary); | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 16px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: var(--transition); | |
| } | |
| .modal-close-btn-sm:hover { | |
| background: var(--bg); | |
| color: var(--text); | |
| } | |
| /* ==================== Diff Dialog ==================== */ | |
| .diff-dialog { | |
| background: var(--panel-bg); | |
| border-radius: var(--radius-lg); | |
| width: 80%; | |
| max-width: 800px; | |
| max-height: 80vh; | |
| display: flex; | |
| flex-direction: column; | |
| box-shadow: var(--shadow-lg); | |
| overflow: hidden; | |
| } | |
| .diff-header { | |
| padding: 16px 20px; | |
| font-weight: 600; | |
| font-size: 15px; | |
| border-bottom: 1px solid var(--border); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| flex-shrink: 0; | |
| } | |
| .diff-content { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 16px 20px; | |
| } | |
| .diff-section-title { | |
| font-weight: 600; | |
| font-size: 13px; | |
| color: var(--text); | |
| margin-bottom: 8px; | |
| } | |
| .diff-block { | |
| font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; | |
| font-size: 12px; | |
| line-height: 1.6; | |
| margin-bottom: 20px; | |
| border: 1px solid var(--border-light); | |
| border-radius: 6px; | |
| overflow: hidden; | |
| } | |
| .diff-line { | |
| padding: 1px 10px; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .diff-line.diff-added { | |
| background: #e6ffed; | |
| color: #22863a; | |
| } | |
| .diff-line.diff-removed { | |
| background: #ffeef0; | |
| color: #cb2431; | |
| } | |
| .diff-line.diff-same { | |
| color: var(--text-secondary); | |
| } | |
| .diff-no-change { | |
| color: var(--text-placeholder); | |
| font-size: 13px; | |
| padding: 8px 0; | |
| } | |
| .diff-actions { | |
| padding: 12px 20px; | |
| border-top: 1px solid var(--border); | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 10px; | |
| flex-shrink: 0; | |
| } | |
| .cancel-btn { | |
| padding: 8px 20px; | |
| background: var(--panel-bg); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| font-size: 13px; | |
| cursor: pointer; | |
| } | |
| .cancel-btn:hover { | |
| background: var(--bg); | |
| } | |
| .confirm-btn { | |
| padding: 8px 20px; | |
| background: var(--primary); | |
| color: white; | |
| border: none; | |
| border-radius: 6px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: var(--transition); | |
| } | |
| .confirm-btn:hover { | |
| background: var(--primary-dark); | |
| } | |
| /* ==================== History Detail Modal ==================== */ | |
| .history-detail { | |
| background: var(--panel-bg); | |
| border-radius: var(--radius-lg); | |
| width: 60%; | |
| max-width: 640px; | |
| max-height: 80vh; | |
| display: flex; | |
| flex-direction: column; | |
| box-shadow: var(--shadow-lg); | |
| overflow: hidden; | |
| } | |
| .history-detail-header { | |
| padding: 16px 20px; | |
| font-weight: 600; | |
| font-size: 15px; | |
| border-bottom: 1px solid var(--border); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| flex-shrink: 0; | |
| } | |
| .history-detail-body { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 20px; | |
| } | |
| .history-detail-footer { | |
| padding: 12px 20px; | |
| border-top: 1px solid var(--border); | |
| display: flex; | |
| justify-content: flex-end; | |
| flex-shrink: 0; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| } | |
| .restore-part-btn { | |
| padding: 6px 14px; | |
| background: var(--panel-bg); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| font-size: 12px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: var(--transition); | |
| } | |
| .restore-part-btn:hover { | |
| border-color: var(--primary); | |
| color: var(--primary); | |
| background: var(--primary-light); | |
| } | |
| .restore-btn { | |
| padding: 6px 20px; | |
| background: var(--primary); | |
| color: white; | |
| border: none; | |
| border-radius: 6px; | |
| font-size: 12px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: var(--transition); | |
| } | |
| .restore-btn:hover { | |
| background: var(--primary-dark); | |
| } | |
| .detail-section { | |
| margin-bottom: 16px; | |
| } | |
| .detail-section:last-child { | |
| margin-bottom: 0; | |
| } | |
| .detail-section-title { | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--text-secondary); | |
| margin-bottom: 6px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| .detail-section-content { | |
| font-size: 13px; | |
| line-height: 1.6; | |
| color: var(--text); | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| background: var(--bg); | |
| padding: 10px 12px; | |
| border-radius: 6px; | |
| max-height: 200px; | |
| overflow-y: auto; | |
| border: 1px solid var(--border-light); | |
| } | |
| .detail-section-content.mono { | |
| font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; | |
| font-size: 11px; | |
| } | |
| .detail-image { | |
| max-width: 100%; | |
| max-height: 200px; | |
| border-radius: 6px; | |
| border: 1px solid var(--border); | |
| } | |
| /* ==================== Toast ==================== */ | |
| .toast-container { | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| z-index: 2000; | |
| display: flex; | |
| flex-direction: column-reverse; | |
| gap: 8px; | |
| pointer-events: none; | |
| } | |
| .toast { | |
| padding: 10px 18px; | |
| border-radius: var(--radius); | |
| font-size: 13px; | |
| font-weight: 500; | |
| box-shadow: var(--shadow-lg); | |
| animation: toast-in 0.3s ease; | |
| max-width: 380px; | |
| word-break: break-word; | |
| pointer-events: auto; | |
| } | |
| .toast.toast-info { | |
| background: #edf2fc; | |
| color: #606266; | |
| border: 1px solid #d9ecff; | |
| } | |
| .toast.toast-success { | |
| background: #f0f9eb; | |
| color: #529b2e; | |
| border: 1px solid #e1f3d8; | |
| } | |
| .toast.toast-warning { | |
| background: #fdf6ec; | |
| color: #b88230; | |
| border: 1px solid #faecd8; | |
| } | |
| .toast.toast-error { | |
| background: #fef0f0; | |
| color: #c45656; | |
| border: 1px solid #fde2e2; | |
| } | |
| @keyframes toast-in { | |
| from { opacity: 0; transform: translateX(24px); } | |
| to { opacity: 1; transform: translateX(0); } | |
| } | |
| @keyframes toast-out { | |
| from { opacity: 1; transform: translateX(0); } | |
| to { opacity: 0; transform: translateX(24px); } | |
| } | |
| /* ==================== Add Field Dropdown ==================== */ | |
| .sp-add-dropdown { | |
| position: fixed; | |
| z-index: 3000; | |
| background: var(--panel-bg); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| box-shadow: var(--shadow-lg); | |
| padding: 4px; | |
| min-width: 140px; | |
| } | |
| .sp-add-option { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 6px 10px; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| color: var(--text); | |
| cursor: pointer; | |
| transition: var(--transition); | |
| white-space: nowrap; | |
| } | |
| .sp-add-option:hover { | |
| background: var(--primary-light); | |
| color: var(--primary); | |
| } | |
| /* Inline add buttons (in dict blocks / element bodies) */ | |
| .sp-inline-add-btn { | |
| border: 1px dashed var(--border); | |
| background: transparent; | |
| color: var(--text-placeholder); | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 11px; | |
| padding: 2px 8px; | |
| transition: var(--transition); | |
| } | |
| .sp-inline-add-btn:hover { | |
| border-color: var(--primary); | |
| color: var(--primary); | |
| background: var(--primary-light); | |
| } | |
| .sp-dict-add-row { | |
| padding: 2px 0; | |
| } | |
| .sp-elem-add-row { | |
| padding: 4px 0; | |
| text-align: center; | |
| } | |
| /* Dict sub-key delete */ | |
| .sp-dict-del { | |
| width: 16px; | |
| height: 16px; | |
| border: none; | |
| background: transparent; | |
| color: transparent; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| font-size: 10px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| transition: var(--transition); | |
| padding: 0; | |
| } | |
| .sp-dict-row:hover .sp-dict-del { | |
| color: var(--text-secondary); | |
| } | |
| .sp-dict-del:hover { | |
| background: var(--danger-light); | |
| color: var(--danger); | |
| } | |
| /* ==================== Modal Image Zoom ==================== */ | |
| .modal-image-container { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| max-width: 92vw; | |
| max-height: 92vh; | |
| overflow: hidden; | |
| } | |
| .modal-image { | |
| max-width: 92vw; | |
| max-height: 92vh; | |
| object-fit: contain; | |
| border-radius: 8px; | |
| box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3); | |
| transition: none; | |
| transform-origin: center center; | |
| user-select: none; | |
| -webkit-user-drag: none; | |
| } | |
| /* ==================== Loading Spinner ==================== */ | |
| .spinner { | |
| display: inline-block; | |
| width: 14px; | |
| height: 14px; | |
| border: 2px solid rgba(0, 0, 0, 0.12); | |
| border-radius: 50%; | |
| border-top-color: var(--primary); | |
| animation: spin 0.8s linear infinite; | |
| margin-right: 6px; | |
| vertical-align: middle; | |
| } | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| /* ==================== Scrollbar ==================== */ | |
| ::-webkit-scrollbar { | |
| width: 6px; | |
| height: 6px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: var(--border); | |
| border-radius: 3px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: var(--text-secondary); | |
| } | |
| /* ==================== Selection ==================== */ | |
| ::selection { | |
| background: rgba(99, 102, 241, 0.22); | |
| } | |
| /* ==================== Responsive ==================== */ | |
| @media (max-width: 900px) { | |
| .workspace { | |
| grid-template-columns: 1fr; | |
| grid-template-rows: 1fr 1fr auto auto auto auto; | |
| } | |
| .image-panel { grid-row: 1; grid-column: 1; min-height: 250px; } | |
| .sp-panel { grid-row: 2; grid-column: 1; min-height: 200px; } | |
| .params-bar { grid-row: 3; grid-column: 1; } | |
| .gen-sp-btn { grid-row: 4; grid-column: 1; } | |
| .prompt-bar { grid-row: 5; grid-column: 1; } | |
| .gen-image-btn { grid-row: 6; grid-column: 1; } | |
| .right-col { | |
| max-width: none; | |
| } | |
| .params-bar { | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| } | |
| .history-detail, | |
| .diff-dialog { | |
| width: 95%; | |
| } | |
| } | |
| /* ==================== Floating Input Popup ==================== */ | |
| .sp-input-popup { | |
| position: fixed; | |
| z-index: 3000; | |
| background: var(--panel-bg); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| box-shadow: var(--shadow-lg); | |
| padding: 12px; | |
| min-width: 240px; | |
| max-width: 320px; | |
| animation: popup-in 0.15s ease; | |
| } | |
| @keyframes popup-in { | |
| from { opacity: 0; transform: translateY(-4px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .sp-input-popup .popup-title { | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--text); | |
| margin-bottom: 10px; | |
| padding-bottom: 6px; | |
| border-bottom: 1px solid var(--border-light); | |
| } | |
| .sp-input-popup .popup-field { | |
| margin-bottom: 8px; | |
| } | |
| .sp-input-popup .popup-field label { | |
| display: block; | |
| font-size: 11px; | |
| font-weight: 500; | |
| color: var(--text-secondary); | |
| margin-bottom: 3px; | |
| } | |
| .sp-input-popup .popup-field input { | |
| width: 100%; | |
| height: 28px; | |
| border: 1px solid var(--border); | |
| border-radius: 4px; | |
| padding: 0 8px; | |
| font-size: 12px; | |
| color: var(--text); | |
| background: var(--panel-bg); | |
| outline: none; | |
| transition: var(--transition); | |
| } | |
| .sp-input-popup .popup-field input:focus { | |
| border-color: var(--primary); | |
| box-shadow: 0 0 0 2px var(--primary-ring); | |
| } | |
| .sp-input-popup .popup-field input.input-error { | |
| border-color: var(--danger); | |
| box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.12); | |
| } | |
| .sp-input-popup .popup-actions { | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 6px; | |
| margin-top: 10px; | |
| padding-top: 8px; | |
| border-top: 1px solid var(--border-light); | |
| } | |
| .sp-input-popup .popup-btn { | |
| height: 26px; | |
| padding: 0 14px; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| border: 1px solid var(--border); | |
| background: var(--panel-bg); | |
| color: var(--text); | |
| transition: var(--transition); | |
| } | |
| .sp-input-popup .popup-btn:hover { | |
| border-color: var(--text-secondary); | |
| color: var(--text); | |
| } | |
| .sp-input-popup .popup-btn-primary { | |
| background: var(--primary); | |
| color: white; | |
| border-color: var(--primary); | |
| } | |
| .sp-input-popup .popup-btn-primary:hover { | |
| background: var(--primary-dark); | |
| border-color: var(--primary-dark); | |
| color: white; | |
| } | |
| </style> | |
| </head> | |
| <body data-generated="2026-07-29T06:50:38.723695Z"> | |
| <div class="app"> | |
| <!-- ==================== History Bar ==================== --> | |
| <div class="history-bar"> | |
| <div class="history-title">History</div> | |
| <div class="history-scroll" id="historyScroll"> | |
| <div class="history-empty" id="historyEmpty">No history yet</div> | |
| </div> | |
| <button class="tool-btn history-clear-btn" id="historyClearBtn" onclick="clearHistory()" title="Clear history" style="display:none;"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg> | |
| </button> | |
| </div> | |
| <!-- ==================== Workspace (CSS Grid) ==================== --> | |
| <div class="workspace"> | |
| <!-- Image Panel (row 1, col 1) --> | |
| <div class="panel image-panel" id="imagePanel"> | |
| <div class="panel-toolbar"> | |
| <span class="panel-label">Image</span> | |
| <div class="panel-actions"> | |
| <button class="tool-btn" onclick="triggerImageUpload()" title="Import image"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> | |
| </button> | |
| <button class="tool-btn" id="btnBboxShow" onclick="showAllBboxes()" title="Show all bboxes" style="display:none;"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> | |
| </button> | |
| <button class="tool-btn" id="btnBboxHide" onclick="hideAllBboxes()" title="Hide all bboxes" style="display:none;"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg> | |
| </button> | |
| <button class="tool-btn" id="btnImageZoom" onclick="handleImageEnlarge()" title="Zoom" style="display:none;"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg> | |
| </button> | |
| <button class="tool-btn" id="btnImageDelete" onclick="handleImageDelete()" title="Delete image" style="display:none;"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="image-area" id="imageArea"> | |
| <div class="dropzone" id="dropzone" onclick="triggerImageUpload()"> | |
| <div class="dropzone-icon">🖼️</div> | |
| <div class="dropzone-text">Drop an image here or click to upload</div> | |
| </div> | |
| <div class="image-display" id="imageDisplay" style="display:none;"> | |
| <img id="displayImage" onclick="handleImageClick(event)" title="Click to select a bbox" /> | |
| <div class="bbox-container" id="bboxContainer"></div> | |
| </div> | |
| </div> | |
| <input type="file" id="imageFileInput" accept="image/*" onchange="handleImageFileSelected(event)" style="display:none;"> | |
| </div> | |
| <!-- Structured Prompt Panel (row 1, col 2) --> | |
| <div class="panel sp-panel" id="spPanel"> | |
| <div class="panel-toolbar"> | |
| <span class="panel-label">Structured Prompt</span> | |
| <div class="panel-actions"> | |
| <button class="tool-btn" id="btnSPRaw" onclick="toggleSPRawMode()" title="Toggle raw JSON"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> | |
| </button> | |
| <button class="tool-btn" onclick="triggerSPImport()" title="Import JSON"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> | |
| </button> | |
| <button class="tool-btn" id="btnSPUndo" onclick="handleSPUndo()" title="Undo" style="display:none;"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg> | |
| </button> | |
| <button class="tool-btn" id="btnSPDelete" onclick="handleSPDelete()" title="Clear" style="display:none;"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="sp-area" id="spArea"> | |
| <div class="sp-empty" id="spEmpty"> | |
| <div class="sp-empty-icon">📋</div> | |
| <div>Structured Prompt is empty</div> | |
| <div class="sp-empty-hint">Generate one with the button, or paste / import JSON</div> | |
| </div> | |
| <div class="sp-structured" id="spStructured" style="display:none;"></div> | |
| <textarea id="spTextarea" style="display:none;" spellcheck="false" | |
| placeholder="Paste Structured Prompt JSON here..."></textarea> | |
| </div> | |
| <input type="file" id="spFileInput" accept=".json,.txt" onchange="handleSPFileSelected(event)" style="display:none;"> | |
| </div> | |
| <!-- Params Bar (row 2, col 1) --> | |
| <div class="panel params-bar"> | |
| <div class="params-label">Params</div> | |
| <div class="params-group"> | |
| <label>CFG</label> | |
| <input type="number" id="paramCfgScale" value="4.0" min="0" max="20" step="0.1"> | |
| </div> | |
| <div class="params-group"> | |
| <label>Steps</label> | |
| <input type="number" id="paramSteps" value="25" min="1" max="100" step="1"> | |
| </div> | |
| <div class="params-spacer"></div> | |
| <div class="params-group"> | |
| <label>H</label> | |
| <input type="number" id="paramHeight" value="1024" min="64" max="4096" step="32"> | |
| </div> | |
| <div class="params-group"> | |
| <label>W</label> | |
| <input type="number" id="paramWidth" value="1024" min="64" max="4096" step="32"> | |
| </div> | |
| <div class="params-group"> | |
| <label>Seed</label> | |
| <input type="number" id="paramSeed" value="42" min="-1" max="999999"> | |
| </div> | |
| </div> | |
| <!-- Generate SP Button (row 2, col 2) --> | |
| <button class="action-btn gen-sp-btn" id="genSPBtn" onclick="handleGenerateSP()"> | |
| Generate SP | |
| </button> | |
| <!-- User Prompt Bar (row 3, col 1) --> | |
| <div class="panel prompt-bar"> | |
| <div class="prompt-label">User Prompt</div> | |
| <textarea id="userPromptInput" placeholder="Describe the image you want..." rows="1"></textarea> | |
| </div> | |
| <!-- Generate Image Button (row 3, col 2) --> | |
| <button class="action-btn gen-image-btn" id="genImageBtn" onclick="handleGenerateImage()"> | |
| Generate Image | |
| </button> | |
| </div> | |
| </div> | |
| <!-- ==================== Image Enlarge Modal ==================== --> | |
| <div class="modal-overlay" id="imageModal" onclick="closeImageModal()"> | |
| <div class="modal-image-container" id="modalImageContainer" onclick="event.stopPropagation()"> | |
| <img id="modalImage" class="modal-image" /> | |
| </div> | |
| <button class="modal-close-btn" onclick="closeImageModal()">✕</button> | |
| </div> | |
| <!-- ==================== Diff Confirmation Dialog ==================== --> | |
| <div class="modal-overlay" id="diffModal"> | |
| <div class="diff-dialog" onclick="event.stopPropagation()"> | |
| <div class="diff-header"> | |
| <span>Review changes</span> | |
| <button class="modal-close-btn-sm" onclick="cancelGeneration()">✕</button> | |
| </div> | |
| <div class="diff-content" id="diffContent"></div> | |
| <div class="diff-actions"> | |
| <button class="action-btn cancel-btn" onclick="cancelGeneration()">Cancel</button> | |
| <button class="action-btn confirm-btn" onclick="confirmGeneration()">Confirm & generate</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ==================== History Detail Modal ==================== --> | |
| <div class="modal-overlay" id="historyModal" onclick="closeHistoryModal()"> | |
| <div class="history-detail" onclick="event.stopPropagation()"> | |
| <div class="history-detail-header"> | |
| <span>History entry</span> | |
| <button class="modal-close-btn-sm" onclick="closeHistoryModal()">✕</button> | |
| </div> | |
| <div class="history-detail-body" id="historyDetailBody"></div> | |
| <div class="history-detail-footer"> | |
| <button class="action-btn restore-part-btn" onclick="restoreImageFromHistory()" title="Restore image only">🖼️ Image</button> | |
| <button class="action-btn restore-part-btn" onclick="restorePromptFromHistory()" title="Restore User Prompt & SP">📝 Prompt</button> | |
| <button class="action-btn restore-part-btn" onclick="restoreParamsFromHistory()" title="Restore params only">⚙️ Params</button> | |
| <button class="action-btn restore-btn" onclick="restoreFromHistory()">Restore all</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ==================== Toast Container ==================== --> | |
| <div class="toast-container" id="toastContainer"></div> | |
| <!-- ==================== Add Field Dropdown ==================== --> | |
| <div class="sp-add-dropdown" id="spAddDropdown" style="display:none;"></div> | |
| <!-- ==================== Scripts ==================== --> | |
| <script src="https://cdn.jsdelivr.net/npm/jsonrepair@3/lib/umd/jsonrepair.min.js"></script> | |
| <script> | |
| // ==================== Client-side config ==================== | |
| // This demo talks to a local FastAPI backend (see demo/app.py). All model | |
| // inference — Structured Prompt generation via a text LLM AND image | |
| // rendering via the QwenImage DiT — happens on the server side, so the | |
| // browser never sees an API key or an SP system prompt. | |
| // | |
| // These constants are kept only so the rest of the (studio-derived) | |
| // script continues to reference them without changes. | |
| const DEFAULT_SYSTEM_PROMPT = ""; | |
| const DEFAULT_API_KEYS = []; | |
| // ==================== Constants ==================== | |
| const BBOX_COLORS = [ | |
| '#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', | |
| '#1abc9c', '#e67e22', '#34495e', '#16a085', '#c0392b', | |
| '#2980b9', '#27ae60', '#d35400', '#8e44ad', '#17a2b8', | |
| '#ff6b6b', '#4ecdc4', '#45b7d1', '#f7b731', '#5f27cd' | |
| ]; | |
| const HISTORY_COLORS = ['hc-0', 'hc-1', 'hc-2', 'hc-3', 'hc-4', 'hc-5']; | |
| // ==================== Stable JSON Stringify ==================== | |
| // Preserve object key order based on previous JSON (including nested objects in arrays). | |
| // This avoids JS's default integer-like key reordering (e.g. "123" jumping to the front). | |
| function _joinPath(pathKeys) { | |
| return pathKeys.join('\0'); | |
| } | |
| // 直接扫描 JSON 原始字符串来提取 key 顺序,避免 JSON.parse 自动把数字 key 排到前面 | |
| function _extractKeyOrdersFromJSON(jsonStr) { | |
| if (!jsonStr || !jsonStr.trim()) return {}; | |
| try { | |
| const keyOrders = {}; | |
| const pathStack = []; // {type:'obj'|'arr', keys:[], currentKey:null, index:0} | |
| let i = 0; | |
| const len = jsonStr.length; | |
| function skipWS() { while (i < len && /\s/.test(jsonStr[i])) i++; } | |
| function readStr() { | |
| if (jsonStr[i] !== '"') return null; | |
| i++; // skip " | |
| let s = ''; | |
| while (i < len && jsonStr[i] !== '"') { | |
| if (jsonStr[i] === '\\') { s += jsonStr[i] + jsonStr[i + 1]; i += 2; } | |
| else { s += jsonStr[i]; i++; } | |
| } | |
| i++; // skip closing " | |
| try { return JSON.parse('"' + s + '"'); } catch { return s; } | |
| } | |
| function curPath() { | |
| const parts = []; | |
| for (const f of pathStack) { | |
| if (f.type === 'arr') parts.push(String(f.index)); | |
| else if (f.currentKey !== null) parts.push(f.currentKey); | |
| } | |
| return parts; | |
| } | |
| while (i < len) { | |
| skipWS(); | |
| if (i >= len) break; | |
| const ch = jsonStr[i]; | |
| if (ch === '{') { | |
| const p = curPath(); | |
| const frame = { type: 'obj', keys: [], currentKey: null }; | |
| pathStack.push(frame); | |
| keyOrders[_joinPath(p)] = frame.keys; | |
| i++; | |
| } else if (ch === '}') { | |
| pathStack.pop(); | |
| i++; | |
| if (pathStack.length > 0 && pathStack[pathStack.length - 1].type === 'obj') { | |
| pathStack[pathStack.length - 1].currentKey = null; | |
| } | |
| } else if (ch === '[') { | |
| pathStack.push({ type: 'arr', index: 0 }); | |
| i++; | |
| } else if (ch === ']') { | |
| pathStack.pop(); | |
| i++; | |
| if (pathStack.length > 0 && pathStack[pathStack.length - 1].type === 'obj') { | |
| pathStack[pathStack.length - 1].currentKey = null; | |
| } | |
| } else if (ch === ',') { | |
| i++; | |
| if (pathStack.length > 0) { | |
| const top = pathStack[pathStack.length - 1]; | |
| if (top.type === 'arr') top.index++; | |
| else top.currentKey = null; | |
| } | |
| } else if (ch === ':') { | |
| i++; | |
| } else if (ch === '"') { | |
| const str = readStr(); | |
| skipWS(); | |
| if (i < len && jsonStr[i] === ':') { | |
| // It's an object key | |
| const top = pathStack[pathStack.length - 1]; | |
| if (top && top.type === 'obj') { | |
| top.keys.push(str); | |
| top.currentKey = str; | |
| } | |
| } | |
| // else it's a string value — do nothing | |
| } else { | |
| // number / boolean / null | |
| const m = jsonStr.substring(i).match(/^(-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/); | |
| if (m) i += m[0].length; else i++; | |
| } | |
| } | |
| return keyOrders; | |
| } catch (e) { | |
| return {}; | |
| } | |
| } | |
| function _stableStr(val, keyOrders, pathKeys, level) { | |
| if (val === null || val === undefined) return 'null'; | |
| if (typeof val === 'boolean') return val ? 'true' : 'false'; | |
| if (typeof val === 'number') return isFinite(val) ? String(val) : 'null'; | |
| if (typeof val === 'string') return JSON.stringify(val); | |
| const indentStr = ' '.repeat(level); | |
| const childIndent = ' '.repeat(level + 1); | |
| if (Array.isArray(val)) { | |
| if (val.length === 0) return '[]'; | |
| const items = val.map((item, i) => | |
| childIndent + _stableStr(item, keyOrders, [...pathKeys, String(i)], level + 1) | |
| ); | |
| return '[\n' + items.join(',\n') + '\n' + indentStr + ']'; | |
| } | |
| // Object | |
| const allKeys = Object.keys(val); | |
| if (allKeys.length === 0) return '{}'; | |
| // Get reference key order for this path | |
| const parentPath = _joinPath(pathKeys); | |
| const refKeys = keyOrders[parentPath] || []; | |
| // Order: reference keys first (in reference order), then remaining keys (appended) | |
| const orderedKeys = []; | |
| refKeys.forEach(k => { if (allKeys.includes(k)) orderedKeys.push(k); }); | |
| allKeys.forEach(k => { if (!orderedKeys.includes(k)) orderedKeys.push(k); }); | |
| const items = orderedKeys.map(k => | |
| childIndent + JSON.stringify(k) + ': ' + _stableStr(val[k], keyOrders, [...pathKeys, k], level + 1) | |
| ); | |
| return '{\n' + items.join(',\n') + '\n' + indentStr + '}'; | |
| } | |
| function stableJSONStringify(obj, referenceStr) { | |
| try { | |
| const keyOrders = _extractKeyOrdersFromJSON(referenceStr); | |
| return _stableStr(obj, keyOrders, [], 0); | |
| } catch (e) { | |
| return JSON.stringify(obj, null, 2); | |
| } | |
| } | |
| // ==================== Global State ==================== | |
| const state = { | |
| // Current image: { src: string (dataURL/URL), width: number, height: number } | null | |
| currentImage: null, | |
| // Structured Prompt (JSON string) | |
| structuredPrompt: '', | |
| // User Prompt | |
| userPrompt: '', | |
| // Generation parameters | |
| params: { | |
| forceT2I: false, | |
| ditEndpoint: '', | |
| cfgScale: 4.0, | |
| steps: 25, | |
| height: 1024, | |
| width: 1024, | |
| seed: 42, | |
| }, | |
| // History: array of { type, inputImage, outputImage, userPrompt, structuredPrompt, params, timestamp, colorIndex } | |
| history: [], | |
| selectedHistoryIndex: -1, | |
| generationCount: 0, | |
| // SP undo stack | |
| undoStack: [], | |
| // UI flags | |
| bboxVisible: false, | |
| isGenerating: false, | |
| pendingGeneration: null, // callback for diff confirmation | |
| spRawMode: false, // true = raw textarea, false = structured view | |
| activeBboxIndex: -1, // highlighted bbox element index | |
| bboxEditIndex: -1, // bbox in edit mode (double-click to activate) | |
| focusedBboxIndex: -1, // 被点击锁定的 bbox index(持久显示) | |
| // BBox drag state | |
| bboxDrag: null, // { elemIndex, handle, startX, startY, startBbox:[x1,y1,x2,y2], containerRect } | |
| // Parsed SP data | |
| parsedSP: null, // parsed JSON object | |
| // Parsed elements from SP (elements + scene.elements with bbox) | |
| parsedElements: [], | |
| // Image modal zoom state | |
| zoomState: { scale: 1, translateX: 0, translateY: 0 }, | |
| }; | |
| // ==================== Initialization ==================== | |
| document.addEventListener('DOMContentLoaded', init); | |
| function init() { | |
| setupDragDrop(); | |
| setupAutoSync(); | |
| setupKeyboardShortcuts(); | |
| } | |
| function setupDragDrop() { | |
| const imageArea = document.getElementById('imageArea'); | |
| const dropzone = document.getElementById('dropzone'); | |
| ['dragenter', 'dragover'].forEach(evt => { | |
| imageArea.addEventListener(evt, e => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (dropzone) dropzone.classList.add('dragover'); | |
| }); | |
| }); | |
| ['dragleave', 'drop'].forEach(evt => { | |
| imageArea.addEventListener(evt, e => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (dropzone) dropzone.classList.remove('dragover'); | |
| }); | |
| }); | |
| imageArea.addEventListener('drop', e => { | |
| const files = e.dataTransfer.files; | |
| if (files.length > 0 && files[0].type.startsWith('image/')) { | |
| loadImageFile(files[0]); | |
| } | |
| }); | |
| // Also support paste | |
| document.addEventListener('paste', e => { | |
| const items = e.clipboardData && e.clipboardData.items; | |
| if (!items) return; | |
| for (const item of items) { | |
| if (item.type.startsWith('image/')) { | |
| const file = item.getAsFile(); | |
| if (file) loadImageFile(file); | |
| break; | |
| } | |
| } | |
| }); | |
| } | |
| function setupAutoSync() { | |
| // Sync userPrompt from input | |
| document.getElementById('userPromptInput').addEventListener('input', function () { | |
| state.userPrompt = this.value; | |
| }); | |
| // Sync SP from textarea | |
| document.getElementById('spTextarea').addEventListener('input', function () { | |
| state.structuredPrompt = this.value; | |
| updateSPButtons(); | |
| parseSPAndUpdateBboxes(); | |
| }); | |
| // Sync params on change | |
| ['paramCfgScale', 'paramSteps', 'paramHeight', 'paramWidth', 'paramSeed'].forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el) el.addEventListener('change', syncParamsFromUI); | |
| }); | |
| } | |
| function setupKeyboardShortcuts() { | |
| // Ctrl+Z when focused on SP textarea => undo | |
| document.getElementById('spTextarea').addEventListener('keydown', function (e) { | |
| if (e.ctrlKey && e.key === 'z') { | |
| e.preventDefault(); | |
| handleSPUndo(); | |
| } | |
| }); | |
| // Ctrl+Enter in user prompt => generate SP | |
| document.getElementById('userPromptInput').addEventListener('keydown', function (e) { | |
| if (e.ctrlKey && e.key === 'Enter') { | |
| e.preventDefault(); | |
| handleGenerateSP(); | |
| } | |
| }); | |
| } | |
| // ==================== Params Sync ==================== | |
| // Small helpers so removing a UI element (e.g. Force-T2I, URL) doesn't | |
| // crash the whole flow with "Cannot read properties of null". | |
| function _byId(id) { return document.getElementById(id); } | |
| function _valOr(id, dflt) { const el = _byId(id); return el ? el.value : dflt; } | |
| function _checkedOr(id, dflt) { const el = _byId(id); return el ? el.checked : dflt; } | |
| function syncParamsFromUI() { | |
| state.params.forceT2I = _checkedOr('paramForceT2I', false); | |
| state.params.ditEndpoint = (_valOr('paramDitEndpoint', '') || '').trim(); | |
| state.params.cfgScale = parseFloat(_valOr('paramCfgScale', 4.0)) || 4.0; | |
| state.params.steps = parseInt(_valOr('paramSteps', 25)) || 25; | |
| state.params.height = parseInt(_valOr('paramHeight', 1024)) || 1024; | |
| state.params.width = parseInt(_valOr('paramWidth', 1024)) || 1024; | |
| state.params.seed = parseInt(_valOr('paramSeed', 42)) || 42; | |
| } | |
| function _setIfExists(id, val, prop) { | |
| const el = _byId(id); | |
| if (el) el[prop] = val; | |
| } | |
| function syncParamsToUI() { | |
| _setIfExists('paramForceT2I', state.params.forceT2I || false, 'checked'); | |
| _setIfExists('paramDitEndpoint', state.params.ditEndpoint || '', 'value'); | |
| document.getElementById('paramCfgScale').value = state.params.cfgScale !== undefined ? state.params.cfgScale : 4.0; | |
| document.getElementById('paramSteps').value = state.params.steps; | |
| document.getElementById('paramHeight').value = state.params.height; | |
| document.getElementById('paramWidth').value = state.params.width; | |
| document.getElementById('paramSeed').value = state.params.seed; | |
| } | |
| // ==================== Image Panel ==================== | |
| function triggerImageUpload() { | |
| document.getElementById('imageFileInput').click(); | |
| } | |
| function handleImageFileSelected(event) { | |
| const file = event.target.files[0]; | |
| if (file) loadImageFile(file); | |
| event.target.value = ''; | |
| } | |
| function loadImageFile(file) { | |
| const reader = new FileReader(); | |
| reader.onload = function (e) { | |
| const img = new Image(); | |
| img.onload = function () { | |
| state.currentImage = { | |
| src: e.target.result, | |
| width: img.naturalWidth, | |
| height: img.naturalHeight, | |
| }; | |
| updateImagePanel(); | |
| autoUpdateHW(); | |
| showToast('Image loaded', 'success'); | |
| }; | |
| img.onerror = function () { | |
| showToast('Failed to load image', 'error'); | |
| }; | |
| img.src = e.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function loadImageFromSrc(src) { | |
| const img = new Image(); | |
| img.onload = function () { | |
| state.currentImage = { | |
| src: src, | |
| width: img.naturalWidth, | |
| height: img.naturalHeight, | |
| }; | |
| updateImagePanel(); | |
| }; | |
| img.src = src; | |
| } | |
| function autoUpdateHW() { | |
| if (!state.currentImage) return; | |
| // Round to nearest 32 | |
| state.params.height = Math.round(state.currentImage.height / 32) * 32; | |
| state.params.width = Math.round(state.currentImage.width / 32) * 32; | |
| syncParamsToUI(); | |
| } | |
| function handleImageDelete() { | |
| state.currentImage = null; | |
| state.bboxVisible = false; | |
| state.activeBboxIndex = -1; | |
| updateImagePanel(); | |
| showToast('Image removed', 'info'); | |
| } | |
| function handleImageEnlarge() { | |
| if (!state.currentImage) return; | |
| const modal = document.getElementById('imageModal'); | |
| const img = document.getElementById('modalImage'); | |
| img.src = state.currentImage.src; | |
| // Reset zoom | |
| state.zoomState = { scale: 1, translateX: 0, translateY: 0 }; | |
| applyModalZoom(); | |
| modal.classList.add('visible'); | |
| // Setup zoom listeners | |
| const container = document.getElementById('modalImageContainer'); | |
| container.addEventListener('wheel', onModalWheel, { passive: false }); | |
| container.addEventListener('mousedown', onModalPanStart); | |
| } | |
| function closeImageModal() { | |
| const modal = document.getElementById('imageModal'); | |
| modal.classList.remove('visible'); | |
| const container = document.getElementById('modalImageContainer'); | |
| container.removeEventListener('wheel', onModalWheel); | |
| container.removeEventListener('mousedown', onModalPanStart); | |
| state.zoomState = { scale: 1, translateX: 0, translateY: 0 }; | |
| } | |
| function onModalWheel(e) { | |
| e.preventDefault(); | |
| const z = state.zoomState; | |
| const factor = e.deltaY > 0 ? 0.9 : 1.1; | |
| const newScale = Math.max(0.5, Math.min(20, z.scale * factor)); | |
| // Zoom towards mouse position | |
| const container = document.getElementById('modalImageContainer'); | |
| const rect = container.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left - rect.width / 2; | |
| const my = e.clientY - rect.top - rect.height / 2; | |
| const sf = newScale / z.scale; | |
| z.translateX = mx - sf * (mx - z.translateX); | |
| z.translateY = my - sf * (my - z.translateY); | |
| z.scale = newScale; | |
| applyModalZoom(); | |
| } | |
| function onModalPanStart(e) { | |
| if (e.button !== 0) return; | |
| if (state.zoomState.scale <= 1.01) return; | |
| e.preventDefault(); | |
| const z = state.zoomState; | |
| const startX = e.clientX, startY = e.clientY; | |
| const startTX = z.translateX, startTY = z.translateY; | |
| const onMove = (ev) => { | |
| z.translateX = startTX + (ev.clientX - startX); | |
| z.translateY = startTY + (ev.clientY - startY); | |
| applyModalZoom(); | |
| }; | |
| const onUp = () => { | |
| document.removeEventListener('mousemove', onMove); | |
| document.removeEventListener('mouseup', onUp); | |
| }; | |
| document.addEventListener('mousemove', onMove); | |
| document.addEventListener('mouseup', onUp); | |
| } | |
| function applyModalZoom() { | |
| const img = document.getElementById('modalImage'); | |
| const z = state.zoomState; | |
| img.style.transform = `translate(${z.translateX}px, ${z.translateY}px) scale(${z.scale})`; | |
| img.style.cursor = z.scale > 1.01 ? 'grab' : 'default'; | |
| } | |
| function updateImagePanel() { | |
| const dropzone = document.getElementById('dropzone'); | |
| const display = document.getElementById('imageDisplay'); | |
| const btnZoom = document.getElementById('btnImageZoom'); | |
| const btnDelete = document.getElementById('btnImageDelete'); | |
| const btnBboxShow = document.getElementById('btnBboxShow'); | |
| const btnBboxHide = document.getElementById('btnBboxHide'); | |
| if (state.currentImage) { | |
| dropzone.style.display = 'none'; | |
| display.style.display = 'flex'; | |
| document.getElementById('displayImage').src = state.currentImage.src; | |
| btnZoom.style.display = ''; | |
| btnDelete.style.display = ''; | |
| updateBboxButtons(); | |
| if (state.bboxVisible) { | |
| requestAnimationFrame(() => renderBboxes()); | |
| } | |
| } else { | |
| dropzone.style.display = ''; | |
| display.style.display = 'none'; | |
| btnZoom.style.display = 'none'; | |
| btnDelete.style.display = 'none'; | |
| btnBboxShow.style.display = 'none'; | |
| btnBboxHide.style.display = 'none'; | |
| clearBboxes(); | |
| } | |
| } | |
| function updateBboxButtons() { | |
| const hasElems = state.parsedElements.length > 0; | |
| const btnBboxShow = document.getElementById('btnBboxShow'); | |
| const btnBboxHide = document.getElementById('btnBboxHide'); | |
| if (!state.currentImage || !hasElems) { | |
| btnBboxShow.style.display = 'none'; | |
| btnBboxHide.style.display = 'none'; | |
| return; | |
| } | |
| if (state.bboxVisible) { | |
| btnBboxShow.style.display = 'none'; | |
| btnBboxHide.style.display = ''; | |
| } else { | |
| btnBboxShow.style.display = ''; | |
| btnBboxHide.style.display = 'none'; | |
| } | |
| } | |
| // ==================== BBox Visualization ==================== | |
| function showAllBboxes() { | |
| if (state.parsedElements.length === 0) { | |
| parseSPAndUpdateBboxes(); | |
| if (state.parsedElements.length === 0) return; | |
| } | |
| state.focusedBboxIndex = -1; | |
| state.bboxVisible = true; | |
| state.activeBboxIndex = -1; | |
| state.bboxEditIndex = -1; | |
| renderBboxes(-1); // -1 means render all | |
| updateBboxButtons(); | |
| document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight')); | |
| } | |
| function hideAllBboxes() { | |
| state.focusedBboxIndex = -1; | |
| state.bboxVisible = false; | |
| state.activeBboxIndex = -1; | |
| state.bboxEditIndex = -1; | |
| clearBboxes(); | |
| updateBboxButtons(); | |
| document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight')); | |
| } | |
| function handleImageClick(event) { | |
| // 检测点击位置是否有 element 的 bbox | |
| if (state.parsedElements.length > 0 && state.currentImage) { | |
| const imgEl = document.getElementById('displayImage'); | |
| const imgRect = imgEl.getBoundingClientRect(); | |
| const clickX = ((event.clientX - imgRect.left) / imgRect.width) * 1000; | |
| const clickY = ((event.clientY - imgRect.top) / imgRect.height) * 1000; | |
| // 找所有包含点击坐标的 bbox | |
| const overlapping = []; | |
| state.parsedElements.forEach((elem, i) => { | |
| if (!elem.bbox) return; | |
| const [x1, y1, x2, y2] = elem.bbox; | |
| if (clickX >= x1 && clickX <= x2 && clickY >= y1 && clickY <= y2) { | |
| overlapping.push(i); | |
| } | |
| }); | |
| if (overlapping.length > 0) { | |
| // 在重叠列表中循环切换 | |
| let nextIndex; | |
| if (state.focusedBboxIndex >= 0 && overlapping.includes(state.focusedBboxIndex)) { | |
| const currentPos = overlapping.indexOf(state.focusedBboxIndex); | |
| nextIndex = overlapping[(currentPos + 1) % overlapping.length]; | |
| } else { | |
| nextIndex = overlapping[0]; | |
| } | |
| // 聚焦该 element 的 bbox | |
| focusBbox(nextIndex); | |
| // 滚动到对应的 element 卡片 | |
| if (state.parsedElements[nextIndex]) { | |
| const card = document.querySelector(`.sp-element[data-elem-id="${state.parsedElements[nextIndex].id}"]`); | |
| if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| return; | |
| } | |
| } | |
| // 点击空白区域 => 清除锁定的 bbox | |
| state.focusedBboxIndex = -1; | |
| state.activeBboxIndex = -1; | |
| state.bboxEditIndex = -1; | |
| if (state.bboxVisible) { | |
| clearBboxes(); | |
| state.bboxVisible = false; | |
| updateBboxButtons(); | |
| } | |
| document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight')); | |
| } | |
| // filterIndex: 如果 >= 0,只渲染该索引的 bbox;否则渲染全部 | |
| function renderBboxes(filterIndex) { | |
| clearBboxes(); | |
| if (!state.currentImage || state.parsedElements.length === 0) return; | |
| const imgEl = document.getElementById('displayImage'); | |
| const container = document.getElementById('bboxContainer'); | |
| if (!imgEl || !imgEl.offsetWidth) return; | |
| const imgRect = imgEl.getBoundingClientRect(); | |
| const parentRect = imgEl.parentElement.getBoundingClientRect(); | |
| container.style.left = (imgRect.left - parentRect.left) + 'px'; | |
| container.style.top = (imgRect.top - parentRect.top) + 'px'; | |
| container.style.width = imgRect.width + 'px'; | |
| container.style.height = imgRect.height + 'px'; | |
| container.style.display = 'block'; | |
| state.parsedElements.forEach((elem, i) => { | |
| if (!elem.bbox) return; | |
| // 如果指定了 filterIndex,只渲染该元素 | |
| if (filterIndex !== undefined && filterIndex >= 0 && i !== filterIndex) return; | |
| const [x1, y1, x2, y2] = elem.bbox; | |
| const color = getColorForElemId(elem.id); | |
| const isActive = (i === state.bboxEditIndex); | |
| const box = document.createElement('div'); | |
| box.className = 'bbox-box' + (isActive ? ' active' : ''); | |
| box.dataset.elemIndex = i; | |
| box.style.borderColor = color; | |
| box.style.left = (x1 / 1000 * 100) + '%'; | |
| box.style.top = (y1 / 1000 * 100) + '%'; | |
| box.style.width = ((x2 - x1) / 1000 * 100) + '%'; | |
| box.style.height = ((y2 - y1) / 1000 * 100) + '%'; | |
| // Semi-transparent background | |
| const r = parseInt(color.slice(1, 3), 16); | |
| const g = parseInt(color.slice(3, 5), 16); | |
| const b = parseInt(color.slice(5, 7), 16); | |
| box.style.background = `rgba(${r},${g},${b},0.12)`; | |
| // Label | |
| const label = document.createElement('div'); | |
| label.className = 'bbox-label'; | |
| label.style.background = color; | |
| label.textContent = `${elem.id}: ${elem.caption || ''}`.substring(0, 30); | |
| box.appendChild(label); | |
| // Drag to move / click to highlight (mousedown on box body) | |
| box.addEventListener('mousedown', (e) => { | |
| if (e.target.classList.contains('bbox-handle')) return; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| // 仅在编辑模式下允许拖拽移动,否则仅处理点击 | |
| if (state.bboxEditIndex !== i) { | |
| handleBboxClick(i, e); | |
| return; | |
| } | |
| state.bboxClickStart = { x: e.clientX, y: e.clientY, index: i }; | |
| initBboxDrag(e, i, 'move'); | |
| }); | |
| // Resize handles (8 directions) — only visible when active | |
| const handles = ['nw', 'ne', 'sw', 'se', 'n', 's', 'w', 'e']; | |
| handles.forEach(h => { | |
| const handle = document.createElement('div'); | |
| handle.className = `bbox-handle h-${h}`; | |
| handle.style.borderColor = color; | |
| handle.addEventListener('mousedown', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| highlightBbox(i); | |
| initBboxDrag(e, i, h); | |
| }); | |
| box.appendChild(handle); | |
| }); | |
| container.appendChild(box); | |
| }); | |
| } | |
| // 点击 bbox 时处理重叠切换 | |
| function handleBboxClick(clickedIndex, e) { | |
| const container = document.getElementById('bboxContainer'); | |
| const containerRect = container.getBoundingClientRect(); | |
| const clickX = ((e.clientX - containerRect.left) / containerRect.width) * 1000; | |
| const clickY = ((e.clientY - containerRect.top) / containerRect.height) * 1000; | |
| // 找所有包含点击坐标的 bbox | |
| const overlapping = []; | |
| state.parsedElements.forEach((elem, i) => { | |
| if (!elem.bbox) return; | |
| const [x1, y1, x2, y2] = elem.bbox; | |
| if (clickX >= x1 && clickX <= x2 && clickY >= y1 && clickY <= y2) { | |
| overlapping.push(i); | |
| } | |
| }); | |
| if (overlapping.length <= 1) { | |
| // 单个 bbox:聚焦并显示该 bbox(不进入编辑模式) | |
| focusBbox(clickedIndex); | |
| return; | |
| } | |
| // 在重叠列表中循环切换 | |
| const currentIdx = state.focusedBboxIndex >= 0 ? state.focusedBboxIndex : state.activeBboxIndex; | |
| const currentPos = overlapping.indexOf(currentIdx); | |
| const nextPos = (currentPos + 1) % overlapping.length; | |
| const nextIdx = overlapping[nextPos]; | |
| // 重叠切换时必须用 focus,确保左侧图像 bbox 与右侧选中项一致 | |
| focusBbox(nextIdx); | |
| } | |
| function clearBboxes() { | |
| const container = document.getElementById('bboxContainer'); | |
| container.innerHTML = ''; | |
| container.style.display = 'none'; | |
| } | |
| function parseSPAndUpdateBboxes() { | |
| state.parsedElements = []; | |
| state.parsedSP = null; | |
| if (!state.structuredPrompt.trim()) { | |
| if (state.bboxVisible) clearBboxes(); | |
| updateBboxButtons(); | |
| return; | |
| } | |
| try { | |
| const data = JSON.parse(state.structuredPrompt); | |
| state.parsedSP = data; | |
| state.parsedElements = extractElements(data); | |
| } catch (e) { | |
| // Not valid JSON yet | |
| } | |
| if (state.bboxVisible && state.currentImage) { | |
| renderBboxes(); | |
| } | |
| updateBboxButtons(); | |
| } | |
| // 基于元素 ID 的稳定颜色映射(不因数组顺序变化而改变) | |
| function getColorForElemId(id) { | |
| const numId = parseInt(id) || 0; | |
| return BBOX_COLORS[numId % BBOX_COLORS.length]; | |
| } | |
| function parseBbox(positionStr) { | |
| if (!positionStr) return null; | |
| const match = positionStr.match(/<bbox>([^<]+)<\/bbox>/) || positionStr.match(/([\d\s]+)/); | |
| if (!match) return null; | |
| const coords = match[1].trim().split(/\s+/).map(Number); | |
| if (coords.length !== 4) return null; | |
| return coords; | |
| } | |
| function extractElements(data) { | |
| const elements = []; | |
| if (data.elements && Array.isArray(data.elements)) { | |
| data.elements.forEach(elem => { | |
| if (elem.position) { | |
| const bbox = parseBbox(elem.position); | |
| if (bbox) { | |
| elements.push({ id: elem.id, caption: elem.caption || '', bbox, source: 'elements' }); | |
| } | |
| } | |
| }); | |
| } | |
| if (data.scene && data.scene.elements && Array.isArray(data.scene.elements)) { | |
| data.scene.elements.forEach(elem => { | |
| if (elem.position) { | |
| const bbox = parseBbox(elem.position); | |
| if (bbox) { | |
| elements.push({ id: elem.id, caption: elem.caption || '', bbox, source: 'scene' }); | |
| } | |
| } | |
| }); | |
| } | |
| return elements; | |
| } | |
| // ==================== SP Panel ==================== | |
| function triggerSPImport() { | |
| document.getElementById('spFileInput').click(); | |
| } | |
| function handleSPFileSelected(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = function (e) { | |
| pushSPUndo(); | |
| state.structuredPrompt = e.target.result; | |
| parseSPAndUpdateBboxes(); | |
| updateSPPanel(); | |
| showToast('Structured Prompt imported', 'success'); | |
| }; | |
| reader.readAsText(file); | |
| event.target.value = ''; | |
| } | |
| function handleSPDelete() { | |
| if (!state.structuredPrompt.trim()) return; | |
| pushSPUndo(); | |
| state.structuredPrompt = ''; | |
| state.parsedSP = null; | |
| updateSPPanel(); | |
| clearBboxes(); | |
| state.parsedElements = []; | |
| state.bboxVisible = false; | |
| showToast('Structured Prompt cleared', 'info'); | |
| } | |
| function handleSPUndo() { | |
| if (state.undoStack.length === 0) { | |
| showToast('Nothing to undo', 'info'); | |
| return; | |
| } | |
| state.structuredPrompt = state.undoStack.pop(); | |
| parseSPAndUpdateBboxes(); | |
| updateSPPanel(); | |
| showToast('Undone', 'success'); | |
| } | |
| function pushSPUndo() { | |
| state.undoStack.push(state.structuredPrompt); | |
| if (state.undoStack.length > 50) state.undoStack.shift(); | |
| } | |
| function toggleSPRawMode() { | |
| state.spRawMode = !state.spRawMode; | |
| const btn = document.getElementById('btnSPRaw'); | |
| if (state.spRawMode) { | |
| btn.classList.add('active'); | |
| } else { | |
| btn.classList.remove('active'); | |
| // Sync textarea back to state when switching to structured view | |
| const ta = document.getElementById('spTextarea'); | |
| if (ta.style.display !== 'none') { | |
| state.structuredPrompt = ta.value; | |
| parseSPAndUpdateBboxes(); | |
| } | |
| } | |
| updateSPPanel(); | |
| } | |
| function updateSPPanel() { | |
| const spEmpty = document.getElementById('spEmpty'); | |
| const spTextarea = document.getElementById('spTextarea'); | |
| const spStructured = document.getElementById('spStructured'); | |
| const btnUndo = document.getElementById('btnSPUndo'); | |
| const btnDelete = document.getElementById('btnSPDelete'); | |
| if (state.structuredPrompt.trim()) { | |
| spEmpty.style.display = 'none'; | |
| if (state.spRawMode) { | |
| spTextarea.style.display = 'block'; | |
| spStructured.style.display = 'none'; | |
| spTextarea.value = state.structuredPrompt; | |
| } else { | |
| spTextarea.style.display = 'none'; | |
| spStructured.style.display = 'block'; | |
| renderSPStructured(); | |
| } | |
| btnDelete.style.display = ''; | |
| } else { | |
| // SP 为空时显示 textarea 供直接输入 | |
| spEmpty.style.display = 'none'; | |
| spTextarea.style.display = 'block'; | |
| spStructured.style.display = 'none'; | |
| spTextarea.value = ''; | |
| btnDelete.style.display = 'none'; | |
| } | |
| btnUndo.style.display = state.undoStack.length > 0 ? '' : 'none'; | |
| } | |
| function updateSPButtons() { | |
| const btnUndo = document.getElementById('btnSPUndo'); | |
| const btnDelete = document.getElementById('btnSPDelete'); | |
| btnUndo.style.display = state.undoStack.length > 0 ? '' : 'none'; | |
| btnDelete.style.display = state.structuredPrompt.trim() ? '' : 'none'; | |
| } | |
| // Set SP content programmatically (e.g., from LLM result) | |
| function setSPContent(text) { | |
| pushSPUndo(); | |
| state.structuredPrompt = text; | |
| parseSPAndUpdateBboxes(); | |
| updateSPPanel(); | |
| } | |
| // ==================== SP Structured View ==================== | |
| // Key fields displayed in the first section | |
| const SP_KEY_FIELDS = ['intent', 'style', 'atmosphere', 'lighting', 'photography', 'relationships']; | |
| function formatDisplayValue(value) { | |
| return value === '' ? '""' : value; | |
| } | |
| // 根据 reference JSON 字符串中的 key 出现顺序,返回有序 key 列表 | |
| let _renderKeyOrders = {}; | |
| function getStableKeys(obj, pathKeys) { | |
| const allKeys = Object.keys(obj); | |
| const pathStr = _joinPath(pathKeys); | |
| const refKeys = _renderKeyOrders[pathStr] || []; | |
| const ordered = []; | |
| refKeys.forEach(k => { if (allKeys.includes(k)) ordered.push(k); }); | |
| allKeys.forEach(k => { if (!ordered.includes(k)) ordered.push(k); }); | |
| return ordered; | |
| } | |
| function renderSPStructured() { | |
| const container = document.getElementById('spStructured'); | |
| container.innerHTML = ''; | |
| // 先确保 parsedElements 已就绪 | |
| if (state.parsedElements.length === 0 && state.structuredPrompt.trim()) { | |
| parseSPAndUpdateBboxes(); | |
| } | |
| // 从原始 JSON 字符串提取 key 顺序(避免 Object.keys 自动排序数字 key) | |
| _renderKeyOrders = _extractKeyOrdersFromJSON(state.structuredPrompt); | |
| let data; | |
| try { | |
| data = JSON.parse(state.structuredPrompt); | |
| state.parsedSP = data; | |
| } catch (e) { | |
| container.innerHTML = '<div style="padding:12px;color:var(--danger);font-size:12px;">JSON parse failed — switch to raw mode to edit</div>'; | |
| return; | |
| } | |
| // ===== Section 1: Global Properties ===== | |
| const keySection = createSection('Global', '🔑', undefined, 'global'); | |
| const keyBody = keySection.querySelector('.sp-section-body'); | |
| SP_KEY_FIELDS.forEach(key => { | |
| if (data[key] === undefined) return; | |
| const val = data[key]; | |
| if (typeof val === 'object' && !Array.isArray(val)) { | |
| keyBody.appendChild(createDictField(key, val, [key])); | |
| } else if (key === 'relationships' && Array.isArray(val)) { | |
| keyBody.appendChild(createRelationshipsField(val)); | |
| } else { | |
| keyBody.appendChild(createStringField(key, String(val), [key])); | |
| } | |
| }); | |
| // Other top-level keys not in SP_KEY_FIELDS, elements, scene | |
| getStableKeys(data, []).forEach(key => { | |
| if (SP_KEY_FIELDS.includes(key) || key === 'elements' || key === 'scene') return; | |
| const val = data[key]; | |
| if (typeof val === 'object' && !Array.isArray(val)) { | |
| keyBody.appendChild(createDictField(key, val, [key])); | |
| } else { | |
| const displayVal = typeof val === 'string' ? val : JSON.stringify(val); | |
| keyBody.appendChild(createStringField(key, displayVal, [key])); | |
| } | |
| }); | |
| container.appendChild(keySection); | |
| // ===== Section 2: Elements ===== | |
| const elemArr = data.elements || []; | |
| const elemSection = createSection('Elements', '🎯', elemArr.length, 'elements'); | |
| const elemBody = elemSection.querySelector('.sp-section-body'); | |
| elemArr.forEach((elem, idx) => { | |
| elemBody.appendChild(createElementCard(elem, idx, ['elements', idx])); | |
| }); | |
| container.appendChild(elemSection); | |
| // ===== Section 3: Scene ===== | |
| const scene = data.scene || {}; | |
| const sceneElems = scene.elements || []; | |
| const sceneSection = createSection('Scene', '🏞️', sceneElems.length, 'scene'); | |
| const sceneBody = sceneSection.querySelector('.sp-section-body'); | |
| // Scene setting | |
| if (scene.setting) { | |
| sceneBody.appendChild(createStringField('setting', scene.setting, ['scene', 'setting'])); | |
| } | |
| // Scene other keys | |
| getStableKeys(scene, ['scene']).forEach(key => { | |
| if (key === 'setting' || key === 'elements') return; | |
| const val = scene[key]; | |
| if (typeof val === 'object' && !Array.isArray(val)) { | |
| sceneBody.appendChild(createDictField(key, val, ['scene', key])); | |
| } else { | |
| sceneBody.appendChild(createStringField(key, String(val), ['scene', key])); | |
| } | |
| }); | |
| // Scene elements | |
| sceneElems.forEach((elem, idx) => { | |
| sceneBody.appendChild(createElementCard(elem, idx, ['scene', 'elements', idx], true)); | |
| }); | |
| container.appendChild(sceneSection); | |
| } | |
| function createSection(title, icon, count, sectionType) { | |
| const section = document.createElement('div'); | |
| section.className = 'sp-section'; | |
| const header = document.createElement('div'); | |
| header.className = 'sp-section-header'; | |
| header.onclick = function(e) { | |
| if (e.target.closest('.sp-section-add-btn') || e.target.closest('.sp-section-clear-btn')) return; | |
| section.classList.toggle('collapsed'); | |
| }; | |
| const titleSpan = document.createElement('span'); | |
| titleSpan.className = 'sp-section-title'; | |
| titleSpan.innerHTML = `${icon} ${title}${count !== undefined ? ` <span class='sp-section-badge'>${count}</span>` : ''}`; | |
| const actionsDiv = document.createElement('div'); | |
| actionsDiv.className = 'sp-section-actions'; | |
| // 统一的 "+" Add按钮 | |
| const addBtn = document.createElement('button'); | |
| addBtn.className = 'sp-section-add-btn'; | |
| addBtn.textContent = '+'; | |
| addBtn.title = 'Add'; | |
| addBtn.onclick = function(e) { | |
| e.stopPropagation(); | |
| showSectionAddDropdown(addBtn, sectionType); | |
| }; | |
| actionsDiv.appendChild(addBtn); | |
| // 🗑️ clear all button | |
| const clearBtn = document.createElement('button'); | |
| clearBtn.className = 'sp-section-clear-btn'; | |
| clearBtn.innerHTML = '🗑'; | |
| clearBtn.title = 'Clear section'; | |
| clearBtn.onclick = function(e) { | |
| e.stopPropagation(); | |
| clearSectionFields(sectionType); | |
| }; | |
| actionsDiv.appendChild(clearBtn); | |
| const arrow = document.createElement('span'); | |
| arrow.className = 'sp-section-arrow'; | |
| arrow.textContent = '▾'; | |
| actionsDiv.appendChild(arrow); | |
| header.appendChild(titleSpan); | |
| header.appendChild(actionsDiv); | |
| section.appendChild(header); | |
| const body = document.createElement('div'); | |
| body.className = 'sp-section-body'; | |
| section.appendChild(body); | |
| return section; | |
| } | |
| function createStringField(key, value, path) { | |
| const field = document.createElement('div'); | |
| field.className = 'sp-field'; | |
| const keySpan = document.createElement('span'); | |
| keySpan.className = 'sp-field-key'; | |
| keySpan.textContent = key; | |
| keySpan.title = 'Double-click to edit key'; | |
| keySpan.dataset.path = JSON.stringify(path.slice(0, -1)); | |
| keySpan.dataset.oldKey = key; | |
| keySpan.ondblclick = function() { startKeyEdit(this); }; | |
| field.appendChild(keySpan); | |
| const valSpan = document.createElement('span'); | |
| valSpan.className = 'sp-field-value'; | |
| const renderedVal = formatDisplayValue(value); | |
| valSpan.textContent = renderedVal; | |
| if (value === '') { | |
| valSpan.dataset.emptyPlaceholder = '1'; | |
| } else { | |
| delete valSpan.dataset.emptyPlaceholder; | |
| } | |
| valSpan.title = 'Double-click to edit'; | |
| valSpan.dataset.path = JSON.stringify(path); | |
| valSpan.ondblclick = function() { startFieldEdit(this); }; | |
| field.appendChild(valSpan); | |
| const actions = document.createElement('div'); | |
| actions.className = 'sp-field-actions'; | |
| const delBtn = document.createElement('button'); | |
| delBtn.className = 'sp-field-btn'; | |
| delBtn.textContent = '✕'; | |
| delBtn.title = 'Remove'; | |
| delBtn.onclick = function() { deleteSPField(path); }; | |
| actions.appendChild(delBtn); | |
| field.appendChild(actions); | |
| return field; | |
| } | |
| function createDictField(key, dictObj, basePath) { | |
| const field = document.createElement('div'); | |
| field.className = 'sp-field sp-field-dict'; | |
| const keySpan = document.createElement('span'); | |
| keySpan.className = 'sp-field-key'; | |
| keySpan.textContent = key; | |
| keySpan.title = 'Double-click to edit key'; | |
| keySpan.dataset.path = JSON.stringify(basePath.slice(0, -1).length ? basePath.slice(0, -1) : []); | |
| keySpan.dataset.oldKey = key; | |
| keySpan.ondblclick = function() { startKeyEdit(this); }; | |
| field.appendChild(keySpan); | |
| const block = document.createElement('div'); | |
| block.className = 'sp-dict-block'; | |
| getStableKeys(dictObj, basePath).forEach(subKey => { | |
| const subVal = dictObj[subKey]; | |
| const row = document.createElement('div'); | |
| row.className = 'sp-dict-row'; | |
| const subPath = [...basePath, subKey]; | |
| const dKey = document.createElement('span'); | |
| dKey.className = 'sp-dict-key'; | |
| dKey.textContent = subKey; | |
| dKey.title = 'Double-click to edit key'; | |
| dKey.dataset.path = JSON.stringify(basePath); | |
| dKey.dataset.oldKey = subKey; | |
| dKey.ondblclick = function() { startKeyEdit(this); }; | |
| row.appendChild(dKey); | |
| const dVal = document.createElement('span'); | |
| dVal.className = 'sp-dict-value'; | |
| if (typeof subVal === 'string') { | |
| dVal.textContent = formatDisplayValue(subVal); | |
| if (subVal === '') { | |
| dVal.dataset.emptyPlaceholder = '1'; | |
| } else { | |
| delete dVal.dataset.emptyPlaceholder; | |
| } | |
| } else { | |
| dVal.textContent = JSON.stringify(subVal); | |
| delete dVal.dataset.emptyPlaceholder; | |
| } | |
| dVal.title = 'Double-click to edit'; | |
| dVal.dataset.path = JSON.stringify(subPath); | |
| dVal.ondblclick = function() { startFieldEdit(this); }; | |
| row.appendChild(dVal); | |
| // Delete sub-key button | |
| const delSub = document.createElement('button'); | |
| delSub.className = 'sp-dict-del'; | |
| delSub.textContent = '✕'; | |
| delSub.title = 'Remove'; | |
| delSub.onclick = function(e) { e.stopPropagation(); deleteSPField(subPath); }; | |
| row.appendChild(delSub); | |
| block.appendChild(row); | |
| }); | |
| // Inline add row for dict — 字典内只Add key-value(字符串) | |
| const addRow = document.createElement('div'); | |
| addRow.className = 'sp-dict-add-row'; | |
| const addBtnInline = document.createElement('button'); | |
| addBtnInline.className = 'sp-inline-add-btn'; | |
| addBtnInline.textContent = '+'; | |
| addBtnInline.title = 'Add key-value'; | |
| addBtnInline.onclick = function(e) { | |
| e.stopPropagation(); | |
| promptAddDictEntry(basePath, addBtnInline); | |
| }; | |
| addRow.appendChild(addBtnInline); | |
| block.appendChild(addRow); | |
| field.appendChild(block); | |
| const actions = document.createElement('div'); | |
| actions.className = 'sp-field-actions'; | |
| const delBtn = document.createElement('button'); | |
| delBtn.className = 'sp-field-btn'; | |
| delBtn.textContent = '✕'; | |
| delBtn.title = 'Remove'; | |
| delBtn.onclick = function() { deleteSPField(basePath); }; | |
| actions.appendChild(delBtn); | |
| field.appendChild(actions); | |
| return field; | |
| } | |
| function createRelationshipsField(relArr) { | |
| const field = document.createElement('div'); | |
| field.className = 'sp-field'; | |
| const keySpan = document.createElement('span'); | |
| keySpan.className = 'sp-field-key'; | |
| keySpan.textContent = 'relationships'; | |
| field.appendChild(keySpan); | |
| const list = document.createElement('ul'); | |
| list.className = 'sp-rel-list'; | |
| relArr.forEach((rel, i) => { | |
| const li = document.createElement('li'); | |
| li.className = 'sp-rel-item'; | |
| const bullet = document.createElement('span'); | |
| bullet.className = 'sp-rel-bullet'; | |
| bullet.textContent = '•'; | |
| li.appendChild(bullet); | |
| const text = document.createElement('span'); | |
| text.className = 'sp-rel-text'; | |
| text.textContent = rel; | |
| text.title = 'Double-click to edit'; | |
| text.dataset.path = JSON.stringify(['relationships', i]); | |
| text.ondblclick = function() { startFieldEdit(this); }; | |
| li.appendChild(text); | |
| list.appendChild(li); | |
| }); | |
| field.appendChild(list); | |
| const actions = document.createElement('div'); | |
| actions.className = 'sp-field-actions'; | |
| const delBtn = document.createElement('button'); | |
| delBtn.className = 'sp-field-btn'; | |
| delBtn.textContent = '✕'; | |
| delBtn.title = 'Remove'; | |
| delBtn.onclick = function() { deleteSPField(['relationships']); }; | |
| actions.appendChild(delBtn); | |
| field.appendChild(actions); | |
| return field; | |
| } | |
| function createElementCard(elem, idx, basePath, isScene) { | |
| // 使用 ID 稳定颜色映射 | |
| const color = getColorForElemId(elem.id); | |
| // 动态查找 globalIdx,确保即使 parsedElements 延迟填充也能正确工作 | |
| const elemId = elem.id; | |
| function _getGlobalIdx() { return findParsedElementIndex(elemId, isScene); } | |
| const card = document.createElement('div'); | |
| card.className = 'sp-element'; | |
| card.dataset.elemId = elem.id; | |
| // Header | |
| const header = document.createElement('div'); | |
| header.className = 'sp-element-header'; | |
| const colorDot = document.createElement('span'); | |
| colorDot.className = 'sp-element-color'; | |
| colorDot.style.background = color; | |
| header.appendChild(colorDot); | |
| const idSpan = document.createElement('span'); | |
| idSpan.className = 'sp-element-id'; | |
| idSpan.textContent = 'ID:' + elem.id; | |
| header.appendChild(idSpan); | |
| const captionSpan = document.createElement('span'); | |
| captionSpan.className = 'sp-element-caption'; | |
| captionSpan.textContent = elem.caption || ''; | |
| header.appendChild(captionSpan); | |
| const elemActions = document.createElement('div'); | |
| elemActions.className = 'sp-element-actions'; | |
| const elemAddBtn = document.createElement('button'); | |
| elemAddBtn.className = 'sp-field-btn sp-elem-add-btn'; | |
| elemAddBtn.textContent = '+'; | |
| elemAddBtn.title = 'Add field'; | |
| elemAddBtn.addEventListener('click', function(e) { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| showAddDropdown(elemAddBtn, basePath); | |
| }); | |
| elemActions.appendChild(elemAddBtn); | |
| const elemDelBtn = document.createElement('button'); | |
| elemDelBtn.className = 'sp-field-btn'; | |
| elemDelBtn.textContent = '✕'; | |
| elemDelBtn.title = 'Remove element'; | |
| elemDelBtn.addEventListener('click', function(e) { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| deleteSPField(basePath); | |
| }); | |
| elemActions.appendChild(elemDelBtn); | |
| header.appendChild(elemActions); | |
| header.addEventListener('click', function(e) { | |
| if (e.target.closest('.sp-field-btn')) return; | |
| card.classList.toggle('collapsed'); | |
| // 点击 element → 立即显示对应 bbox | |
| focusBbox(_getGlobalIdx()); | |
| }); | |
| // Mouse enter => 只显示该 element 的 bbox(hover 预览),保存之前状态 | |
| card.addEventListener('mouseenter', () => { | |
| const gIdx = _getGlobalIdx(); | |
| if (!state.currentImage || gIdx < 0) return; | |
| card._savedBboxState = { | |
| bboxVisible: state.bboxVisible, | |
| activeBboxIndex: state.activeBboxIndex, | |
| focusedBboxIndex: state.focusedBboxIndex, | |
| bboxEditIndex: state.bboxEditIndex, | |
| }; | |
| state.activeBboxIndex = gIdx; | |
| state.bboxEditIndex = -1; | |
| state.bboxVisible = true; | |
| renderBboxes(gIdx); | |
| updateBboxButtons(); | |
| document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight')); | |
| card.classList.add('highlight'); | |
| }); | |
| card.addEventListener('mouseleave', () => { | |
| if (!state.currentImage) return; | |
| const saved = card._savedBboxState; | |
| card._savedBboxState = null; | |
| if (!saved) return; | |
| // 如果 hover 期间用户点击锁定了新的 bbox,保持新状态 | |
| if (state.focusedBboxIndex >= 0 && state.focusedBboxIndex !== saved.focusedBboxIndex) { | |
| state.activeBboxIndex = state.focusedBboxIndex; | |
| state.bboxEditIndex = state.bboxEditIndex; // 保持编辑状态 | |
| renderBboxes(state.focusedBboxIndex); | |
| updateBboxButtons(); | |
| document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight')); | |
| if (state.parsedElements[state.focusedBboxIndex]) { | |
| const fc = document.querySelector(`.sp-element[data-elem-id="${state.parsedElements[state.focusedBboxIndex].id}"]`); | |
| if (fc) fc.classList.add('highlight'); | |
| } | |
| return; | |
| } | |
| // 恢复之前的状态 | |
| state.bboxVisible = saved.bboxVisible; | |
| state.activeBboxIndex = saved.activeBboxIndex; | |
| state.focusedBboxIndex = saved.focusedBboxIndex; | |
| state.bboxEditIndex = saved.bboxEditIndex; | |
| if (saved.bboxVisible) { | |
| if (saved.focusedBboxIndex >= 0) { | |
| renderBboxes(saved.focusedBboxIndex); | |
| } else { | |
| renderBboxes(-1); | |
| } | |
| } else { | |
| clearBboxes(); | |
| } | |
| updateBboxButtons(); | |
| document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight')); | |
| if (saved.focusedBboxIndex >= 0 && state.parsedElements[saved.focusedBboxIndex]) { | |
| const fc = document.querySelector(`.sp-element[data-elem-id="${state.parsedElements[saved.focusedBboxIndex].id}"]`); | |
| if (fc) fc.classList.add('highlight'); | |
| } | |
| }); | |
| card.appendChild(header); | |
| // Body | |
| const body = document.createElement('div'); | |
| body.className = 'sp-element-body'; | |
| const skipKeys = ['id']; | |
| getStableKeys(elem, basePath).forEach(key => { | |
| if (skipKeys.includes(key)) return; | |
| const val = elem[key]; | |
| const fieldPath = [...basePath, key]; | |
| if (key === 'position') { | |
| const posField = document.createElement('div'); | |
| posField.className = 'sp-field'; | |
| const posKeySpan = document.createElement('span'); | |
| posKeySpan.className = 'sp-field-key'; | |
| posKeySpan.textContent = 'position'; | |
| posField.appendChild(posKeySpan); | |
| const posBadge = document.createElement('span'); | |
| posBadge.className = 'sp-pos-badge'; | |
| posBadge.title = 'Click to locate'; | |
| posBadge.innerHTML = '<span class="pos-icon">📍</span> ' + escapeHtml(extractBboxText(val)); | |
| posBadge.onclick = function(ev) { ev.stopPropagation(); focusBbox(_getGlobalIdx()); }; | |
| posField.appendChild(posBadge); | |
| // 编辑按钮 — 进入 bbox 编辑模式(可拖拽/缩放) | |
| const editBtn = document.createElement('button'); | |
| editBtn.className = 'sp-field-btn sp-bbox-edit-btn'; | |
| editBtn.textContent = '✏️'; | |
| editBtn.title = 'Edit bbox position'; | |
| editBtn.onclick = function(ev) { | |
| ev.stopPropagation(); | |
| const gIdx = _getGlobalIdx(); | |
| if (gIdx < 0) { | |
| showToast("BBox data isn't ready yet, please retry", 'warning'); | |
| return; | |
| } | |
| activateBboxEdit(gIdx); | |
| }; | |
| posField.appendChild(editBtn); | |
| body.appendChild(posField); | |
| } else if (typeof val === 'object' && val !== null && !Array.isArray(val)) { | |
| body.appendChild(createDictField(key, val, fieldPath)); | |
| } else { | |
| body.appendChild(createStringField(key, typeof val === 'string' ? val : JSON.stringify(val), fieldPath)); | |
| } | |
| }); | |
| card.appendChild(body); | |
| return card; | |
| } | |
| function extractBboxText(posStr) { | |
| if (!posStr) return ''; | |
| const match = posStr.match(/<bbox>([^<]+)<\/bbox>/); | |
| return match ? match[1].trim() : posStr; | |
| } | |
| function findParsedElementIndex(elemId, isScene) { | |
| // Find element index in state.parsedElements by id | |
| for (let i = 0; i < state.parsedElements.length; i++) { | |
| if (state.parsedElements[i].id === elemId) return i; | |
| } | |
| return -1; | |
| } | |
| function highlightBbox(globalIdx) { | |
| state.activeBboxIndex = globalIdx; | |
| // Update bbox visual highlighting (使用 data-elemIndex 匹配) | |
| const container = document.getElementById('bboxContainer'); | |
| container.querySelectorAll('.bbox-box').forEach(box => { | |
| const idx = parseInt(box.dataset.elemIndex); | |
| box.classList.remove('active', 'focused'); | |
| if (idx === globalIdx) { | |
| if (state.bboxEditIndex === globalIdx) { | |
| box.classList.add('active'); | |
| } else { | |
| box.classList.add('focused'); | |
| } | |
| } | |
| }); | |
| // Also highlight SP element card | |
| document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight')); | |
| if (globalIdx >= 0 && state.parsedElements[globalIdx]) { | |
| const card = document.querySelector(`.sp-element[data-elem-id="${state.parsedElements[globalIdx].id}"]`); | |
| if (card) card.classList.add('highlight'); | |
| } | |
| } | |
| function focusBbox(globalIdx) { | |
| if (globalIdx < 0 || !state.currentImage) return; | |
| // 点击 element => 锁定只显示该 element 的 bbox(仅查看,不带编辑手柄) | |
| state.focusedBboxIndex = globalIdx; | |
| state.activeBboxIndex = globalIdx; | |
| state.bboxEditIndex = -1; | |
| state.bboxVisible = true; | |
| renderBboxes(globalIdx); | |
| updateBboxButtons(); | |
| // highlight SP card | |
| document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight')); | |
| if (state.parsedElements[globalIdx]) { | |
| const card = document.querySelector(`.sp-element[data-elem-id="${state.parsedElements[globalIdx].id}"]`); | |
| if (card) card.classList.add('highlight'); | |
| } | |
| } | |
| function activateBboxEdit(globalIdx) { | |
| if (globalIdx < 0 || !state.currentImage) return; | |
| // 双击 => 激活 bbox 编辑模式(显示拖拽/缩放手柄) | |
| state.focusedBboxIndex = globalIdx; | |
| state.activeBboxIndex = globalIdx; | |
| state.bboxEditIndex = globalIdx; | |
| state.bboxVisible = true; | |
| renderBboxes(globalIdx); | |
| updateBboxButtons(); | |
| // highlight SP card | |
| document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight')); | |
| if (state.parsedElements[globalIdx]) { | |
| const card = document.querySelector(`.sp-element[data-elem-id="${state.parsedElements[globalIdx].id}"]`); | |
| if (card) card.classList.add('highlight'); | |
| } | |
| showToast('BBox edit mode: drag or resize', 'info'); | |
| } | |
| // ==================== SP Field Editing ==================== | |
| function startFieldEdit(el) { | |
| if (el.contentEditable === 'true') return; | |
| if (el.dataset.emptyPlaceholder === '1') { | |
| el.textContent = ''; | |
| delete el.dataset.emptyPlaceholder; | |
| } | |
| el.contentEditable = 'true'; | |
| el.focus(); | |
| // Select all text | |
| const range = document.createRange(); | |
| range.selectNodeContents(el); | |
| const sel = window.getSelection(); | |
| sel.removeAllRanges(); | |
| sel.addRange(range); | |
| // Save on blur or Enter | |
| const finish = () => { | |
| el.contentEditable = 'false'; | |
| el.removeEventListener('blur', onBlur); | |
| el.removeEventListener('keydown', onKey); | |
| commitFieldEdit(el); | |
| }; | |
| const onBlur = () => finish(); | |
| const onKey = (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| finish(); | |
| } | |
| if (e.key === 'Escape') { | |
| el.contentEditable = 'false'; | |
| el.removeEventListener('blur', onBlur); | |
| el.removeEventListener('keydown', onKey); | |
| // Revert — re-render | |
| renderSPStructured(); | |
| } | |
| }; | |
| el.addEventListener('blur', onBlur); | |
| el.addEventListener('keydown', onKey); | |
| } | |
| function commitFieldEdit(el) { | |
| const path = JSON.parse(el.dataset.path); | |
| const newValue = el.textContent.trim(); | |
| if (!state.parsedSP) return; | |
| pushSPUndo(); | |
| // Navigate to parent and set value | |
| let obj = state.parsedSP; | |
| for (let i = 0; i < path.length - 1; i++) { | |
| obj = obj[path[i]]; | |
| if (!obj) return; | |
| } | |
| const lastKey = path[path.length - 1]; | |
| obj[lastKey] = newValue; | |
| // Sync back to state | |
| state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt); | |
| parseSPAndUpdateBboxes(); | |
| renderSPStructured(); | |
| } | |
| // ==================== Key Editing ==================== | |
| function startKeyEdit(el) { | |
| if (el.contentEditable === 'true') return; | |
| el.contentEditable = 'true'; | |
| el.focus(); | |
| const range = document.createRange(); | |
| range.selectNodeContents(el); | |
| const sel = window.getSelection(); | |
| sel.removeAllRanges(); | |
| sel.addRange(range); | |
| const finish = () => { | |
| el.contentEditable = 'false'; | |
| el.removeEventListener('blur', onBlur); | |
| el.removeEventListener('keydown', onKey); | |
| commitKeyEdit(el); | |
| }; | |
| const onBlur = () => finish(); | |
| const onKey = (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| finish(); | |
| } | |
| if (e.key === 'Escape') { | |
| el.contentEditable = 'false'; | |
| el.removeEventListener('blur', onBlur); | |
| el.removeEventListener('keydown', onKey); | |
| renderSPStructured(); | |
| } | |
| }; | |
| el.addEventListener('blur', onBlur); | |
| el.addEventListener('keydown', onKey); | |
| } | |
| function commitKeyEdit(el) { | |
| const oldKey = el.dataset.oldKey; | |
| const newKey = el.textContent.trim(); | |
| if (!newKey || newKey === oldKey || !state.parsedSP) return; | |
| const parentPath = JSON.parse(el.dataset.path); | |
| pushSPUndo(); | |
| // Navigate to parent | |
| let obj = state.parsedSP; | |
| for (let i = 0; i < parentPath.length; i++) { | |
| obj = obj[parentPath[i]]; | |
| if (!obj) return; | |
| } | |
| if (typeof obj === 'object' && !Array.isArray(obj) && oldKey in obj) { | |
| // Rename: preserve order by rebuilding object | |
| const entries = Object.entries(obj); | |
| const newEntries = entries.map(([k, v]) => k === oldKey ? [newKey, v] : [k, v]); | |
| // Clear and repopulate | |
| for (const k of Object.keys(obj)) delete obj[k]; | |
| for (const [k, v] of newEntries) obj[k] = v; | |
| } | |
| state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt); | |
| parseSPAndUpdateBboxes(); | |
| renderSPStructured(); | |
| showToast(`Field renamed: ${oldKey} → ${newKey}`, 'success'); | |
| } | |
| // ==================== Add Dropdown ==================== | |
| let _addDropdownCleanup = null; | |
| let _addDropdownAnchor = null; | |
| let _savedAnchorRect = null; // 保存按钮位置,防止 DOM 变化后丢失 | |
| function showSectionAddDropdown(anchorEl, sectionType) { | |
| hideAddDropdown(); | |
| hideInputPopup(); | |
| _addDropdownAnchor = anchorEl; | |
| _savedAnchorRect = anchorEl.getBoundingClientRect(); | |
| const dd = document.getElementById('spAddDropdown'); | |
| dd.innerHTML = ''; | |
| const options = []; | |
| if (sectionType === 'global') { | |
| options.push({ label: 'Field', icon: '📝', action: () => promptAddField([], 'string', anchorEl) }); | |
| options.push({ label: 'Dict field', icon: '📦', action: () => promptAddField([], 'dict', anchorEl) }); | |
| } else if (sectionType === 'elements') { | |
| options.push({ label: 'Add element', icon: '🎯', action: () => addSPElement('') }); | |
| } else if (sectionType === 'scene') { | |
| options.push({ label: 'Field', icon: '📝', action: () => promptAddField(['scene'], 'string', anchorEl) }); | |
| options.push({ label: 'Dict field', icon: '📦', action: () => promptAddField(['scene'], 'dict', anchorEl) }); | |
| options.push({ label: 'Add element', icon: '🎯', action: () => addSPSceneElement('') }); | |
| } | |
| options.forEach(opt => { | |
| const item = document.createElement('div'); | |
| item.className = 'sp-add-option'; | |
| item.innerHTML = `<span>${opt.icon}</span> ${opt.label}`; | |
| item.onclick = function(e) { | |
| e.stopPropagation(); | |
| hideAddDropdown(); | |
| opt.action(); | |
| }; | |
| dd.appendChild(item); | |
| }); | |
| // Position near anchor | |
| const rect = anchorEl.getBoundingClientRect(); | |
| dd.style.top = (rect.bottom + 4) + 'px'; | |
| dd.style.left = Math.min(rect.left, window.innerWidth - 160) + 'px'; | |
| dd.style.display = 'block'; | |
| // Close on outside click | |
| _addDropdownCleanup = (e) => { | |
| if (!dd.contains(e.target) && e.target !== anchorEl) { | |
| hideAddDropdown(); | |
| } | |
| }; | |
| setTimeout(() => document.addEventListener('mousedown', _addDropdownCleanup), 0); | |
| } | |
| function showAddDropdown(anchorEl, basePath) { | |
| hideAddDropdown(); | |
| hideInputPopup(); | |
| _addDropdownAnchor = anchorEl; | |
| _savedAnchorRect = anchorEl.getBoundingClientRect(); | |
| const dd = document.getElementById('spAddDropdown'); | |
| dd.innerHTML = ''; | |
| const options = [ | |
| { label: 'Field', icon: '📝', type: 'string' }, | |
| { label: 'Dict field', icon: '📦', type: 'dict' }, | |
| ]; | |
| options.forEach(opt => { | |
| const item = document.createElement('div'); | |
| item.className = 'sp-add-option'; | |
| item.innerHTML = `<span>${opt.icon}</span> ${opt.label}`; | |
| item.onclick = function(e) { | |
| e.stopPropagation(); | |
| const savedAnchor = anchorEl; // capture before hiding | |
| hideAddDropdown(); | |
| promptAddField(basePath, opt.type, savedAnchor); | |
| }; | |
| dd.appendChild(item); | |
| }); | |
| // Position near anchor | |
| const rect = anchorEl.getBoundingClientRect(); | |
| dd.style.top = (rect.bottom + 4) + 'px'; | |
| dd.style.left = Math.min(rect.left, window.innerWidth - 160) + 'px'; | |
| dd.style.display = 'block'; | |
| // Close on outside click | |
| _addDropdownCleanup = (e) => { | |
| if (!dd.contains(e.target) && e.target !== anchorEl) { | |
| hideAddDropdown(); | |
| } | |
| }; | |
| setTimeout(() => document.addEventListener('mousedown', _addDropdownCleanup), 0); | |
| } | |
| function hideAddDropdown() { | |
| const dd = document.getElementById('spAddDropdown'); | |
| if (dd) dd.style.display = 'none'; | |
| if (_addDropdownCleanup) { | |
| document.removeEventListener('mousedown', _addDropdownCleanup); | |
| _addDropdownCleanup = null; | |
| } | |
| } | |
| // ==================== Floating Input Popup ==================== | |
| let _inputPopupEl = null; | |
| let _inputPopupCleanup = null; | |
| let _lastPopupAnchor = null; | |
| function resolvePopupAnchor(anchorEl) { | |
| const candidates = [anchorEl, _addDropdownAnchor, _lastPopupAnchor]; | |
| for (const candidate of candidates) { | |
| if (!candidate || !document.body.contains(candidate)) continue; | |
| const rect = candidate.getBoundingClientRect(); | |
| if (rect.width > 0 || rect.height > 0) return candidate; | |
| } | |
| return null; | |
| } | |
| function showInputPopup(anchorEl, title, fields, onSubmit) { | |
| hideInputPopup(); | |
| hideAddDropdown(); | |
| const resolvedAnchor = resolvePopupAnchor(anchorEl); | |
| const popup = document.createElement('div'); | |
| popup.className = 'sp-input-popup'; | |
| // Title | |
| const titleEl = document.createElement('div'); | |
| titleEl.className = 'popup-title'; | |
| titleEl.textContent = title; | |
| popup.appendChild(titleEl); | |
| // Fields | |
| const inputs = {}; | |
| fields.forEach(f => { | |
| const fieldDiv = document.createElement('div'); | |
| fieldDiv.className = 'popup-field'; | |
| const label = document.createElement('label'); | |
| label.textContent = f.label; | |
| fieldDiv.appendChild(label); | |
| const input = document.createElement('input'); | |
| input.type = 'text'; | |
| input.placeholder = f.placeholder || ''; | |
| input.dataset.fieldKey = f.key; | |
| input.addEventListener('input', () => { | |
| input.classList.remove('input-error'); | |
| }); | |
| fieldDiv.appendChild(input); | |
| inputs[f.key] = input; | |
| popup.appendChild(fieldDiv); | |
| }); | |
| // Actions | |
| const actions = document.createElement('div'); | |
| actions.className = 'popup-actions'; | |
| const cancelBtn = document.createElement('button'); | |
| cancelBtn.className = 'popup-btn'; | |
| cancelBtn.textContent = 'Cancel'; | |
| cancelBtn.onclick = (e) => { e.stopPropagation(); hideInputPopup(); }; | |
| actions.appendChild(cancelBtn); | |
| const okBtn = document.createElement('button'); | |
| okBtn.className = 'popup-btn popup-btn-primary'; | |
| okBtn.textContent = 'OK'; | |
| okBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| const values = {}; | |
| let valid = true; | |
| fields.forEach(f => { | |
| values[f.key] = inputs[f.key].value.trim(); | |
| if (f.required && !values[f.key]) { | |
| inputs[f.key].classList.add('input-error'); | |
| valid = false; | |
| } | |
| }); | |
| if (!valid) return; | |
| hideInputPopup(); | |
| onSubmit(values); | |
| }; | |
| actions.appendChild(okBtn); | |
| popup.appendChild(actions); | |
| // Position near anchor (with viewport clamping and fallback) | |
| document.body.appendChild(popup); | |
| _inputPopupEl = popup; | |
| // 优先用 resolvedAnchor 的实时位置,否则用之前保存的 _savedAnchorRect | |
| let rect; | |
| if (resolvedAnchor) { | |
| rect = resolvedAnchor.getBoundingClientRect(); | |
| } else if (_savedAnchorRect && (_savedAnchorRect.width > 0 || _savedAnchorRect.height > 0)) { | |
| rect = _savedAnchorRect; | |
| } else { | |
| rect = { top: 0, left: 0, width: 0, height: 0, bottom: 0 }; | |
| } | |
| const popupRect = popup.getBoundingClientRect(); | |
| const popupW = popupRect.width || 260; | |
| const popupH = popupRect.height || 150; | |
| let top, left; | |
| // If anchor has zero size (detached from DOM or hidden), center in viewport | |
| if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) { | |
| top = Math.max(10, (window.innerHeight - popupH) / 2); | |
| left = Math.max(10, (window.innerWidth - popupW) / 2); | |
| } else { | |
| top = rect.bottom + 4; | |
| left = rect.left; | |
| } | |
| // Clamp to viewport bounds | |
| if (top + popupH > window.innerHeight - 10) { | |
| top = Math.max(10, rect.top - popupH - 4); | |
| } | |
| if (left + popupW > window.innerWidth - 10) { | |
| left = Math.max(10, window.innerWidth - popupW - 10); | |
| } | |
| if (top < 10) top = 10; | |
| if (left < 10) left = 10; | |
| popup.style.top = top + 'px'; | |
| popup.style.left = left + 'px'; | |
| // Focus first input | |
| const firstInput = popup.querySelector('input'); | |
| if (firstInput) setTimeout(() => firstInput.focus(), 50); | |
| // Keyboard shortcuts | |
| popup.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| okBtn.click(); | |
| } | |
| if (e.key === 'Escape') { | |
| hideInputPopup(); | |
| } | |
| }); | |
| // Close on outside click | |
| _inputPopupCleanup = (e) => { | |
| if (!popup.contains(e.target) && (!resolvedAnchor || e.target !== resolvedAnchor)) { | |
| hideInputPopup(); | |
| } | |
| }; | |
| setTimeout(() => document.addEventListener('mousedown', _inputPopupCleanup), 0); | |
| } | |
| function hideInputPopup() { | |
| if (_inputPopupEl) { | |
| _inputPopupEl.remove(); | |
| _inputPopupEl = null; | |
| } | |
| if (_inputPopupCleanup) { | |
| document.removeEventListener('mousedown', _inputPopupCleanup); | |
| _inputPopupCleanup = null; | |
| } | |
| } | |
| // 弹出悬浮输入框让用户填写 key 和 value | |
| function promptAddField(basePath, type, anchorOverride) { | |
| const anchor = anchorOverride || _addDropdownAnchor || document.body; | |
| _lastPopupAnchor = anchor; | |
| if (type === 'string') { | |
| showInputPopup(anchor, 'Add field', [ | |
| { key: 'fieldKey', label: 'Key', placeholder: 'Field name', required: true }, | |
| { key: 'fieldValue', label: 'Value', placeholder: 'Field value', required: false }, | |
| ], (values) => { | |
| addFieldToPath(basePath, 'string', values.fieldKey, values.fieldValue); | |
| }); | |
| } else if (type === 'dict') { | |
| showInputPopup(anchor, 'Add dict field', [ | |
| { key: 'dictName', label: 'Dict name', placeholder: 'Dict name', required: true }, | |
| { key: 'innerKey', label: 'Inner key', placeholder: 'key', required: true }, | |
| { key: 'innerValue', label: 'Inner value', placeholder: 'value', required: false }, | |
| ], (values) => { | |
| addFieldToPath(basePath, 'dict', values.dictName, null, values.innerKey, values.innerValue); | |
| }); | |
| } | |
| } | |
| // 直接向字典内Add一对 key-value(通过悬浮输入框) | |
| function promptAddDictEntry(basePath, anchorEl) { | |
| _lastPopupAnchor = anchorEl || document.body; | |
| if (anchorEl) _savedAnchorRect = anchorEl.getBoundingClientRect(); | |
| showInputPopup(anchorEl || document.body, 'Add field', [ | |
| { key: 'fieldKey', label: 'Key', placeholder: 'key', required: true }, | |
| { key: 'fieldValue', label: 'Value', placeholder: 'value', required: false }, | |
| ], (values) => { | |
| addFieldToPath(basePath, 'string', values.fieldKey, values.fieldValue); | |
| }); | |
| } | |
| function addFieldToPath(basePath, type, fieldKey, fieldValue, innerKey, innerValue) { | |
| if (!state.parsedSP) return; | |
| // Navigate to target object | |
| let target = state.parsedSP; | |
| for (const key of basePath) { | |
| if (target[key] === undefined) target[key] = {}; | |
| target = target[key]; | |
| } | |
| if (typeof target !== 'object' || Array.isArray(target)) { | |
| showToast("Can't add a field here", 'warning'); | |
| return; | |
| } | |
| if (fieldKey in target) { | |
| showToast(`Field "${fieldKey}" already exists`, 'warning'); | |
| return; | |
| } | |
| pushSPUndo(); | |
| if (type === 'dict') { | |
| // 创建字典,带初始 key-value | |
| const dictObj = {}; | |
| if (innerKey) dictObj[innerKey] = innerValue || ''; | |
| target[fieldKey] = dictObj; | |
| } else { | |
| target[fieldKey] = fieldValue || ''; | |
| } | |
| state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt); | |
| parseSPAndUpdateBboxes(); | |
| updateSPPanel(); | |
| const isEmptyValue = type === 'dict' ? (!innerValue && !!innerKey) : !fieldValue; | |
| const emptySuffix = isEmptyValue ? ' (empty value)' : ''; | |
| showToast(`Added ${type === 'dict' ? 'dict ' : ''}field: ${fieldKey}${emptySuffix}`, 'success'); | |
| } | |
| // ==================== Section Clear ==================== | |
| function clearSectionFields(sectionType) { | |
| if (!state.parsedSP) return; | |
| pushSPUndo(); | |
| if (sectionType === 'global') { | |
| // Remove all top-level keys except elements and scene | |
| Object.keys(state.parsedSP).forEach(key => { | |
| if (key !== 'elements' && key !== 'scene') { | |
| delete state.parsedSP[key]; | |
| } | |
| }); | |
| showToast('Cleared Global fields', 'info'); | |
| } else if (sectionType === 'elements') { | |
| // Remove整个 elements key | |
| delete state.parsedSP.elements; | |
| showToast('Removed Elements', 'info'); | |
| } else if (sectionType === 'scene') { | |
| // Remove整个 scene key | |
| delete state.parsedSP.scene; | |
| showToast('Removed Scene', 'info'); | |
| } | |
| state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt); | |
| parseSPAndUpdateBboxes(); | |
| updateSPPanel(); | |
| } | |
| function addSPElement(caption) { | |
| if (!state.parsedSP) return; | |
| if (!state.parsedSP.elements) state.parsedSP.elements = []; | |
| pushSPUndo(); | |
| // 自动递增 ID | |
| let maxId = 0; | |
| state.parsedSP.elements.forEach(e => { | |
| const id = parseInt(e.id) || 0; | |
| if (id > maxId) maxId = id; | |
| }); | |
| if (state.parsedSP.scene && state.parsedSP.scene.elements) { | |
| state.parsedSP.scene.elements.forEach(e => { | |
| const id = parseInt(e.id) || 0; | |
| if (id > maxId) maxId = id; | |
| }); | |
| } | |
| const newElem = { | |
| id: maxId + 1, | |
| caption: caption || '', | |
| position: '<bbox>0 0 500 500</bbox>', | |
| }; | |
| state.parsedSP.elements.push(newElem); | |
| state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt); | |
| parseSPAndUpdateBboxes(); | |
| updateSPPanel(); | |
| showToast(`Added element (id: ${newElem.id})`, 'success'); | |
| } | |
| function addSPSceneElement(caption) { | |
| if (!state.parsedSP) return; | |
| if (!state.parsedSP.scene) state.parsedSP.scene = {}; | |
| if (!state.parsedSP.scene.elements) state.parsedSP.scene.elements = []; | |
| pushSPUndo(); | |
| let maxId = 0; | |
| if (state.parsedSP.elements) { | |
| state.parsedSP.elements.forEach(e => { | |
| const id = parseInt(e.id) || 0; | |
| if (id > maxId) maxId = id; | |
| }); | |
| } | |
| state.parsedSP.scene.elements.forEach(e => { | |
| const id = parseInt(e.id) || 0; | |
| if (id > maxId) maxId = id; | |
| }); | |
| const newElem = { | |
| id: maxId + 1, | |
| caption: caption || '', | |
| position: '<bbox>0 0 500 500</bbox>', | |
| }; | |
| state.parsedSP.scene.elements.push(newElem); | |
| state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt); | |
| parseSPAndUpdateBboxes(); | |
| updateSPPanel(); | |
| showToast(`Added scene element (id: ${newElem.id})`, 'success'); | |
| } | |
| // ==================== Deleting Fields ==================== | |
| function deleteSPField(path) { | |
| if (!state.parsedSP) return; | |
| if (typeof path === 'string') path = JSON.parse(path); | |
| pushSPUndo(); | |
| let obj = state.parsedSP; | |
| for (let i = 0; i < path.length - 1; i++) { | |
| obj = obj[path[i]]; | |
| if (!obj) return; | |
| } | |
| const lastKey = path[path.length - 1]; | |
| if (Array.isArray(obj)) { | |
| obj.splice(lastKey, 1); | |
| } else { | |
| delete obj[lastKey]; | |
| } | |
| state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt); | |
| parseSPAndUpdateBboxes(); | |
| updateSPPanel(); | |
| showToast('Removed', 'info'); | |
| } | |
| // ==================== Generation Logic (per DEFINITION.md) ==================== | |
| /** | |
| * Generate SP按钮逻辑: | |
| * - 当Image不存在时: User Prompt => SP (Text System Prompt) | |
| * - 当Image存在时: | |
| * - H&W会被覆盖 | |
| * - 没有User Prompt => 根据图像Generate SP (Image System Prompt) | |
| * - 只有User Prompt (无SP) => 根据图像Generate SP (Image System Prompt) | |
| * - 有User Prompt和SP => 根据User Prompt修改SP (Editing System Prompt) | |
| */ | |
| async function handleGenerateSP() { | |
| if (state.isGenerating) return; | |
| const hasImage = !!state.currentImage; | |
| const hasUserPrompt = !!state.userPrompt.trim(); | |
| const hasSP = !!state.structuredPrompt.trim(); | |
| if (!hasImage) { | |
| // ---- Text → SP ---- | |
| if (!hasUserPrompt) { | |
| showToast('Please enter a User Prompt', 'warning'); | |
| return; | |
| } | |
| setGenerating(true, 'sp'); | |
| showToast('Generating Structured Prompt from User Prompt...', 'info'); | |
| try { | |
| const result = await callLLMForSP({ | |
| type: 'text', | |
| userPrompt: state.userPrompt, | |
| }); | |
| if (result) { | |
| setSPContent(result); | |
| showToast('Structured Prompt generated', 'success'); | |
| } | |
| addHistoryEntry('sp'); | |
| } catch (err) { | |
| showToast('Generation failed: ' + err.message, 'error'); | |
| } finally { | |
| setGenerating(false); | |
| } | |
| } else { | |
| // ---- Image exists ---- | |
| autoUpdateHW(); // H&W被覆盖 | |
| if (hasUserPrompt && hasSP) { | |
| // Case 3: 有 User Prompt 和 SP → 修改 SP | |
| setGenerating(true, 'sp'); | |
| showToast('Editing Structured Prompt from User Prompt...', 'info'); | |
| try { | |
| const result = await callLLMForSP({ | |
| type: 'edit', | |
| image: state.currentImage.src, | |
| userPrompt: state.userPrompt, | |
| structuredPrompt: state.structuredPrompt, | |
| }); | |
| if (result) { | |
| setSPContent(result); | |
| showToast('Structured Prompt updated', 'success'); | |
| } | |
| addHistoryEntry('sp'); | |
| } catch (err) { | |
| showToast('Edit failed: ' + err.message, 'error'); | |
| } finally { | |
| setGenerating(false); | |
| } | |
| } else { | |
| // Case 1 & 2: 根据图像生成 SP | |
| setGenerating(true, 'sp'); | |
| showToast('Generating Structured Prompt from image...', 'info'); | |
| try { | |
| const result = await callLLMForSP({ | |
| type: 'image', | |
| image: state.currentImage.src, | |
| }); | |
| if (result) { | |
| setSPContent(result); | |
| showToast('Structured Prompt generated', 'success'); | |
| } | |
| addHistoryEntry('sp'); | |
| } catch (err) { | |
| showToast('Generation failed: ' + err.message, 'error'); | |
| } finally { | |
| setGenerating(false); | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Generate Image按钮逻辑: | |
| * - 当Image不存在时 (T2I): | |
| * - 有SP => 根据SP生成 | |
| * - 无SP => 根据User Prompt生成 | |
| * - 当Image存在时 ((I+T)2I): | |
| * - 有SP => 根据SP修改图像 | |
| * - 无SP => 根据User Prompt修改图像 | |
| * | |
| * 生成前: 与上轮对比 User & SP,高亮修改,点击确认才提交 | |
| */ | |
| async function handleGenerateImage() { | |
| if (state.isGenerating) return; | |
| const hasImage = !!state.currentImage && !state.params.forceT2I; | |
| const hasUserPrompt = !!state.userPrompt.trim(); | |
| const hasSP = !!state.structuredPrompt.trim(); | |
| if (!hasSP && !hasUserPrompt) { | |
| showToast('Please enter a User Prompt or Structured Prompt', 'warning'); | |
| return; | |
| } | |
| syncParamsFromUI(); | |
| let prompt, message; | |
| if (!hasImage) { | |
| // T2I | |
| if (hasSP) { | |
| prompt = state.structuredPrompt; | |
| message = 'Generating image from Structured Prompt...'; | |
| } else { | |
| prompt = state.userPrompt; | |
| message = 'Generating image from User Prompt...'; | |
| } | |
| } else { | |
| // (I+T)2I | |
| if (hasSP) { | |
| prompt = state.structuredPrompt; | |
| message = 'Editing image with Structured Prompt...'; | |
| } else { | |
| prompt = state.userPrompt; | |
| message = 'Editing image with User Prompt...'; | |
| } | |
| } | |
| // 如果有历史记录,展示 diff 对比 | |
| if (state.history.length > 0) { | |
| const lastEntry = state.history[state.history.length - 1]; | |
| const upChanged = (lastEntry.userPrompt || '') !== (state.userPrompt || ''); | |
| const spChanged = (lastEntry.structuredPrompt || '') !== (state.structuredPrompt || ''); | |
| if (upChanged || spChanged) { | |
| showDiffDialog(lastEntry, () => { | |
| doGenerateImage(prompt, message, hasImage); | |
| }); | |
| return; | |
| } | |
| } | |
| doGenerateImage(prompt, message, hasImage); | |
| } | |
| async function doGenerateImage(prompt, message, hasImage) { | |
| setGenerating(true, 'image'); | |
| showToast(message, 'info'); | |
| try { | |
| const result = await callDiTForImage({ | |
| type: hasImage ? 'i2i' : 't2i', | |
| prompt: prompt, | |
| image: hasImage ? state.currentImage.src : null, | |
| params: { ...state.params }, | |
| }); | |
| if (result) { | |
| // result: image data URL or URL — 替换当前图片 | |
| const img = new Image(); | |
| img.onload = function () { | |
| state.currentImage = { | |
| src: result, | |
| width: img.naturalWidth, | |
| height: img.naturalHeight, | |
| }; | |
| updateImagePanel(); | |
| addHistoryEntry('image'); | |
| showToast('Image generated', 'success'); | |
| }; | |
| img.onerror = function () { | |
| showToast('Failed to load generated image', 'error'); | |
| }; | |
| img.src = result; | |
| } else { | |
| // DiT 未实现时: 直接将当前状态存入历史(Debug 模式) | |
| addHistoryEntry('image'); | |
| showToast('Current state saved to history (debug)', 'success'); | |
| } | |
| } catch (err) { | |
| showToast('Image generation failed: ' + err.message, 'error'); | |
| } finally { | |
| setGenerating(false); | |
| } | |
| } | |
| function setGenerating(isGenerating, type) { | |
| state.isGenerating = isGenerating; | |
| const spBtn = document.getElementById('genSPBtn'); | |
| const imgBtn = document.getElementById('genImageBtn'); | |
| if (isGenerating) { | |
| spBtn.disabled = true; | |
| imgBtn.disabled = true; | |
| if (type === 'sp') { | |
| spBtn.classList.add('loading'); | |
| spBtn.innerHTML = '<span class="spinner"></span>Generating...'; | |
| } else if (type === 'image') { | |
| imgBtn.classList.add('loading'); | |
| imgBtn.innerHTML = '<span class="spinner"></span>Generating...'; | |
| } | |
| } else { | |
| spBtn.disabled = false; | |
| imgBtn.disabled = false; | |
| spBtn.classList.remove('loading'); | |
| imgBtn.classList.remove('loading'); | |
| spBtn.textContent = 'Generate SP'; | |
| imgBtn.textContent = 'Generate Image'; | |
| } | |
| } | |
| // ==================== Stub Functions (To Be Implemented) ==================== | |
| /** | |
| * 调用 LLM 生成 / 修改 Structured Prompt | |
| * | |
| * @param {Object} options | |
| * @param {string} options.type - 'text' | 'image' | 'edit' | |
| * | |
| * type='text': 纯文本 → SP | |
| * options.userPrompt: 用户输入 | |
| * 使用 Text System Prompt (DEFAULT_SYSTEM_PROMPT) | |
| * | |
| * type='image': 图片 → SP | |
| * options.image: 图片 data URL | |
| * 使用 Image System Prompt | |
| * | |
| * type='edit': 根据指令修改 SP | |
| * options.image: 图片 data URL | |
| * options.userPrompt: 修改指令 | |
| * options.structuredPrompt: 当前 SP | |
| * 使用 Editing System Prompt | |
| * | |
| * @returns {string|null} 生成的 SP (formatted JSON string) 或 null | |
| */ | |
| async function callLLMForSP(options) { | |
| // Streams the PE model's tokens via SSE into the SP editor's raw | |
| // textarea (so the user watches the CoT + JSON appear live), then | |
| // once the `done` frame arrives, swaps in the extracted SP JSON. | |
| if (options.type !== 'text') { | |
| showToast('That mode (image / edit) is not supported yet — text → SP only', 'warning'); | |
| return null; | |
| } | |
| const width = (Math.round((state.params.width || 1024) / 32) * 32) || 1024; | |
| const height = (Math.round((state.params.height || 1024) / 32) * 32) || 1024; | |
| // Prep the SP textarea for streaming — flip into raw mode, empty it. | |
| const spTextarea = document.getElementById('spTextarea'); | |
| const spEmpty = document.getElementById('spEmpty'); | |
| const spStructured = document.getElementById('spStructured'); | |
| const prevRaw = state.spRawMode; | |
| state.spRawMode = true; | |
| if (spTextarea) { | |
| spTextarea.style.display = ''; | |
| spTextarea.value = ''; | |
| } | |
| if (spEmpty) spEmpty.style.display = 'none'; | |
| if (spStructured) spStructured.style.display = 'none'; | |
| try { | |
| const resp = await fetch('/api/generate_sp/stream', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| user_prompt: options.userPrompt || '', | |
| width: width, height: height, | |
| }), | |
| }); | |
| if (!resp.ok || !resp.body) { | |
| const errText = await resp.text().catch(() => resp.statusText); | |
| throw new Error(`HTTP ${resp.status}: ${errText}`); | |
| } | |
| const reader = resp.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let sseBuffer = ''; | |
| let finalSp = null; | |
| let streamError = null; | |
| while (true) { | |
| const { value, done } = await reader.read(); | |
| if (done) break; | |
| sseBuffer += decoder.decode(value, { stream: true }); | |
| // Split on blank line — each block is one SSE event. | |
| let idx; | |
| while ((idx = sseBuffer.indexOf('\n\n')) !== -1) { | |
| const block = sseBuffer.slice(0, idx); | |
| sseBuffer = sseBuffer.slice(idx + 2); | |
| let evName = 'message', dataStr = ''; | |
| for (const line of block.split('\n')) { | |
| if (line.startsWith('event: ')) evName = line.slice(7).trim(); | |
| else if (line.startsWith('data: ')) dataStr += line.slice(6); | |
| } | |
| if (!dataStr) continue; | |
| let payload; | |
| try { payload = JSON.parse(dataStr); } catch { continue; } | |
| if (evName === 'token' && spTextarea && payload.text) { | |
| spTextarea.value += payload.text; | |
| spTextarea.scrollTop = spTextarea.scrollHeight; | |
| } else if (evName === 'waking') { | |
| showToast(payload.msg || 'PE endpoint cold-starting…', 'info'); | |
| } else if (evName === 'done') { | |
| finalSp = payload.sp; | |
| } else if (evName === 'error') { | |
| streamError = payload.error || 'unknown stream error'; | |
| } | |
| } | |
| } | |
| if (streamError) throw new Error(streamError); | |
| if (finalSp == null) throw new Error('stream closed without result'); | |
| // Restore raw-mode preference before returning; setSPContent will | |
| // populate the structured view. | |
| state.spRawMode = prevRaw; | |
| return typeof finalSp === 'string' ? finalSp : JSON.stringify(finalSp, null, 2); | |
| } catch (err) { | |
| console.error('[LLM] generate_sp stream failed:', err); | |
| state.spRawMode = prevRaw; | |
| showToast('SP generation failed: ' + err.message, 'error'); | |
| return null; | |
| } | |
| } | |
| // ==================== DiT Generation (QwenImage Serving API) ==================== | |
| /** | |
| * 将 JSON 对象转为 compact single-quote 格式字符串。 | |
| * 服务端 prompt 字段期望此格式的 Structured Prompt。 | |
| */ | |
| function compactSingleQuoteJSON(data) { | |
| const PH_S = '@@SQ@@', PH_D = '@@DQ@@'; | |
| const reS = /@@SQ@@/g, reD = /@@DQ@@/g; | |
| function protect(obj) { | |
| if (obj === null || obj === undefined) return obj; | |
| if (Array.isArray(obj)) return obj.map(protect); | |
| if (typeof obj === 'object') { | |
| const r = {}; | |
| for (const [k, v] of Object.entries(obj)) { | |
| const pk = typeof k === 'string' ? k.replace(/'/g, PH_S).replace(/"/g, PH_D) : k; | |
| r[pk] = protect(v); | |
| } | |
| return r; | |
| } | |
| if (typeof obj === 'string') return obj.replace(/'/g, PH_S).replace(/"/g, PH_D); | |
| return obj; | |
| } | |
| const jsonStr = JSON.stringify(protect(data)); | |
| return jsonStr.replace(/"/g, "'").replace(reS, "\\'").replace(reD, '"'); | |
| } | |
| /** | |
| * Call the local FastAPI backend which runs the QwenImage DiT in-process. | |
| * See demo/backend/dit.py for the model wrapper. | |
| * | |
| * @param {Object} options | |
| * @param {string} options.prompt - text prompt (SP JSON string or plain NL) | |
| * @param {Object} options.params - { cfgScale, steps, height, width, seed } | |
| * | |
| * @returns {string|null} generated image data URL, or null on failure. | |
| */ | |
| async function callDiTForImage(options) { | |
| const { prompt, params } = options; | |
| const width = (Math.round((params.width || 1024) / 32) * 32) || 1024; | |
| const height = (Math.round((params.height || 1024) / 32) * 32) || 1024; | |
| // SP JSON → compact single-quote format expected by the DiT; plain text as-is. | |
| let formattedPrompt; | |
| try { | |
| const parsed = typeof prompt === 'string' ? JSON.parse(prompt) : prompt; | |
| formattedPrompt = compactSingleQuoteJSON(parsed); | |
| } catch (_) { | |
| formattedPrompt = prompt; | |
| } | |
| const body = { | |
| prompt: formattedPrompt, | |
| height: height, | |
| width: width, | |
| num_steps: params.steps || 25, | |
| seed: (params.seed != null && params.seed !== -1) ? params.seed : 42, | |
| cfg_scale: params.cfgScale || 4.0, | |
| negative_prompt: "", | |
| }; | |
| try { | |
| // POST kicks off a background DiT job on the Space (bypasses HF's | |
| // 10-min HTTP/2 keep-alive cap — cold model load is 5min, first | |
| // generation another 40s). Then poll /api/task/{id}. | |
| const kickResp = await fetch('/api/generate_image_async', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body), | |
| }); | |
| if (!kickResp.ok) { | |
| const errText = await kickResp.text().catch(() => kickResp.statusText); | |
| throw new Error(`HTTP ${kickResp.status}: ${errText}`); | |
| } | |
| const { task_id } = await kickResp.json(); | |
| if (!task_id) throw new Error('Response missing task_id'); | |
| const startedAt = Date.now(); | |
| const timeoutMs = 20 * 60 * 1000; // 20-min ceiling | |
| while (true) { | |
| if (Date.now() - startedAt > timeoutMs) { | |
| throw new Error('DiT job timed out after 20 minutes'); | |
| } | |
| await simulateDelay(2000); | |
| let pollResp; | |
| try { | |
| pollResp = await fetch(`/api/task/${task_id}`); | |
| } catch (_) { | |
| continue; // transient network hiccup — keep trying | |
| } | |
| if (!pollResp.ok) continue; | |
| const st = await pollResp.json(); | |
| if (st.status === 'done' && st.image_base64) { | |
| return 'data:image/png;base64,' + st.image_base64; | |
| } | |
| if (st.status === 'failed') { | |
| throw new Error(st.error || 'DiT failed'); | |
| } | |
| // status === 'running' — surface elapsed time on the button | |
| const elapsed = Math.round((Date.now() - startedAt) / 1000); | |
| const btn = document.getElementById('genImageBtn'); | |
| if (btn) { | |
| const label = btn.querySelector('.btn-label') || btn; | |
| label.textContent = `Generating… ${elapsed}s`; | |
| } | |
| } | |
| } catch (err) { | |
| console.error('[DiT] generate_image failed:', err); | |
| showToast('Image generation failed: ' + err.message, 'error'); | |
| return null; | |
| } | |
| } | |
| function simulateDelay(ms) { | |
| return new Promise(resolve => setTimeout(resolve, ms)); | |
| } | |
| // ==================== Diff Dialog ==================== | |
| function showDiffDialog(lastEntry, onConfirm) { | |
| state.pendingGeneration = onConfirm; | |
| const diffContent = document.getElementById('diffContent'); | |
| let html = ''; | |
| // User Prompt diff | |
| html += '<div class="diff-section-title">User Prompt changes</div>'; | |
| html += '<div class="diff-block">'; | |
| html += generateDiffHTML(lastEntry.userPrompt || '', state.userPrompt || ''); | |
| html += '</div>'; | |
| // SP diff | |
| html += '<div class="diff-section-title">Structured Prompt changes</div>'; | |
| html += '<div class="diff-block">'; | |
| html += generateDiffHTML(lastEntry.structuredPrompt || '', state.structuredPrompt || ''); | |
| html += '</div>'; | |
| diffContent.innerHTML = html; | |
| document.getElementById('diffModal').classList.add('visible'); | |
| } | |
| function generateDiffHTML(oldText, newText) { | |
| if (oldText === newText) { | |
| return '<div class="diff-no-change">No changes</div>'; | |
| } | |
| const oldLines = oldText.split('\n'); | |
| const newLines = newText.split('\n'); | |
| let html = ''; | |
| const maxLen = Math.max(oldLines.length, newLines.length); | |
| for (let i = 0; i < maxLen; i++) { | |
| const oldLine = i < oldLines.length ? oldLines[i] : undefined; | |
| const newLine = i < newLines.length ? newLines[i] : undefined; | |
| if (oldLine === newLine) { | |
| html += `<div class="diff-line diff-same"> ${escapeHtml(oldLine)}</div>`; | |
| } else { | |
| if (oldLine !== undefined) { | |
| html += `<div class="diff-line diff-removed">-${escapeHtml(oldLine)}</div>`; | |
| } | |
| if (newLine !== undefined) { | |
| html += `<div class="diff-line diff-added">+${escapeHtml(newLine)}</div>`; | |
| } | |
| } | |
| } | |
| return html; | |
| } | |
| function escapeHtml(text) { | |
| if (!text) return ''; | |
| return text | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"'); | |
| } | |
| function cancelGeneration() { | |
| state.pendingGeneration = null; | |
| document.getElementById('diffModal').classList.remove('visible'); | |
| } | |
| function confirmGeneration() { | |
| document.getElementById('diffModal').classList.remove('visible'); | |
| if (state.pendingGeneration) { | |
| const fn = state.pendingGeneration; | |
| state.pendingGeneration = null; | |
| fn(); | |
| } | |
| } | |
| // ==================== History ==================== | |
| function addHistoryEntry(type) { | |
| const entry = { | |
| type: type, // 'sp' or 'image' | |
| inputImage: state.currentImage ? state.currentImage.src : null, | |
| userPrompt: state.userPrompt || '', | |
| structuredPrompt: state.structuredPrompt || '', | |
| params: { ...state.params }, | |
| timestamp: Date.now(), | |
| colorIndex: state.generationCount % HISTORY_COLORS.length, | |
| }; | |
| state.history.push(entry); | |
| state.generationCount++; | |
| renderHistory(); | |
| } | |
| function renderHistory() { | |
| const scroll = document.getElementById('historyScroll'); | |
| const empty = document.getElementById('historyEmpty'); | |
| const clearBtn = document.getElementById('historyClearBtn'); | |
| // 先清除已有 DOM 元素 | |
| scroll.querySelectorAll('.history-item, .history-separator').forEach(el => el.remove()); | |
| if (state.history.length === 0) { | |
| empty.style.display = ''; | |
| clearBtn.style.display = 'none'; | |
| return; | |
| } | |
| empty.style.display = 'none'; | |
| clearBtn.style.display = ''; | |
| let prevColorIndex = -1; | |
| let prevOutputImage = null; | |
| state.history.forEach((entry, index) => { | |
| // Separator between different generation groups | |
| if (index > 0 && entry.colorIndex !== prevColorIndex && prevColorIndex !== -1) { | |
| const sep = document.createElement('div'); | |
| sep.className = 'history-separator'; | |
| scroll.appendChild(sep); | |
| } | |
| prevColorIndex = entry.colorIndex; | |
| const item = document.createElement('div'); | |
| item.className = `history-item ${HISTORY_COLORS[entry.colorIndex]}`; | |
| item.onclick = () => showHistoryDetail(index); | |
| item.title = `#${index + 1} ${entry.type === 'sp' ? 'Generate SP' : 'Generate Image'}`; | |
| // Thumbnail | |
| if (entry.inputImage) { | |
| const isRepeated = prevOutputImage && entry.inputImage === prevOutputImage; | |
| const thumb = document.createElement('img'); | |
| thumb.className = 'history-thumb' + (isRepeated ? ' repeated' : ''); | |
| thumb.src = entry.inputImage; | |
| item.appendChild(thumb); | |
| } | |
| // Type label | |
| const typeLabel = document.createElement('span'); | |
| typeLabel.className = 'history-type-label'; | |
| typeLabel.textContent = entry.type === 'sp' ? 'SP' : 'IMG'; | |
| item.appendChild(typeLabel); | |
| // Prompt badge | |
| if (entry.userPrompt) { | |
| const badge = document.createElement('span'); | |
| badge.className = 'history-badge badge-p'; | |
| badge.textContent = 'P'; | |
| badge.title = entry.userPrompt.substring(0, 100); | |
| item.appendChild(badge); | |
| } | |
| // SP badge | |
| if (entry.structuredPrompt) { | |
| const sBadge = document.createElement('span'); | |
| sBadge.className = 'history-badge badge-s'; | |
| sBadge.textContent = 'S'; | |
| sBadge.title = 'Structured Prompt'; | |
| item.appendChild(sBadge); | |
| } | |
| // Config badge | |
| const cfgBadge = document.createElement('span'); | |
| cfgBadge.className = 'history-badge badge-c'; | |
| cfgBadge.textContent = 'C'; | |
| cfgBadge.title = `CFG:${entry.params.cfgScale || entry.params.cfgText || entry.params.cfg || '-'} Step:${entry.params.steps} ${entry.params.width}×${entry.params.height} Seed:${entry.params.seed}`; | |
| item.appendChild(cfgBadge); | |
| scroll.appendChild(item); | |
| // Track for "repeated" detection | |
| if (entry.type === 'image' && entry.inputImage) { | |
| prevOutputImage = entry.inputImage; | |
| } | |
| }); | |
| // Scroll to end | |
| requestAnimationFrame(() => { | |
| scroll.scrollLeft = scroll.scrollWidth; | |
| }); | |
| } | |
| function clearHistory() { | |
| if (state.history.length === 0) return; | |
| if (!confirm('Clear all history and reset every input?')) return; | |
| // 清空历史 | |
| state.history = []; | |
| state.generationCount = 0; | |
| renderHistory(); | |
| // 清空图片 | |
| state.currentImage = null; | |
| state.bboxVisible = false; | |
| state.activeBboxIndex = -1; | |
| state.bboxEditIndex = -1; | |
| state.focusedBboxIndex = -1; | |
| state.parsedElements = []; | |
| clearBboxes(); | |
| updateImagePanel(); | |
| // 清空 Structured Prompt | |
| state.structuredPrompt = ''; | |
| state.parsedSP = null; | |
| state.undoStack = []; | |
| updateSPPanel(); | |
| // 清空 User Prompt | |
| state.userPrompt = ''; | |
| const promptInput = document.getElementById('userPromptInput'); | |
| if (promptInput) promptInput.value = ''; | |
| showToast('Cleared all history and inputs', 'info'); | |
| } | |
| function showHistoryDetail(index) { | |
| state.selectedHistoryIndex = index; | |
| const entry = state.history[index]; | |
| const body = document.getElementById('historyDetailBody'); | |
| let html = ''; | |
| // Meta | |
| html += `<div class="detail-section">`; | |
| html += `<div class="detail-section-title">Basic info</div>`; | |
| html += `<div class="detail-section-content">`; | |
| html += `Type: ${entry.type === 'sp' ? 'Generate Structured Prompt' : 'Generate image'}\n`; | |
| html += `Time: ${new Date(entry.timestamp).toLocaleString()}`; | |
| html += `</div></div>`; | |
| // Image | |
| if (entry.inputImage) { | |
| html += `<div class="detail-section">`; | |
| html += `<div class="detail-section-title">Input image</div>`; | |
| html += `<img class="detail-image" src="${entry.inputImage}" />`; | |
| html += `</div>`; | |
| } | |
| // User Prompt | |
| if (entry.userPrompt) { | |
| html += `<div class="detail-section">`; | |
| html += `<div class="detail-section-title">User Prompt</div>`; | |
| html += `<div class="detail-section-content">${escapeHtml(entry.userPrompt)}</div>`; | |
| html += `</div>`; | |
| } | |
| // Structured Prompt | |
| if (entry.structuredPrompt) { | |
| html += `<div class="detail-section">`; | |
| html += `<div class="detail-section-title">Structured Prompt</div>`; | |
| html += `<div class="detail-section-content mono">${escapeHtml(entry.structuredPrompt)}</div>`; | |
| html += `</div>`; | |
| } | |
| // Params | |
| const cfgVal = entry.params.cfgScale || entry.params.cfgText || entry.params.cfg || '-'; | |
| html += `<div class="detail-section">`; | |
| html += `<div class="detail-section-title">Generation params</div>`; | |
| html += `<div class="detail-section-content">`; | |
| html += `CFG: ${cfgVal} Step: ${entry.params.steps} Size: ${entry.params.width} × ${entry.params.height} Seed: ${entry.params.seed}\n`; | |
| html += `</div></div>`; | |
| body.innerHTML = html; | |
| document.getElementById('historyModal').classList.add('visible'); | |
| } | |
| function closeHistoryModal() { | |
| document.getElementById('historyModal').classList.remove('visible'); | |
| } | |
| function restoreFromHistory() { | |
| const index = state.selectedHistoryIndex; | |
| if (index < 0 || index >= state.history.length) return; | |
| const entry = state.history[index]; | |
| // Restore image | |
| if (entry.inputImage) { | |
| loadImageFromSrc(entry.inputImage); | |
| } else { | |
| state.currentImage = null; | |
| updateImagePanel(); | |
| } | |
| // Restore User Prompt | |
| state.userPrompt = entry.userPrompt || ''; | |
| document.getElementById('userPromptInput').value = state.userPrompt; | |
| // Restore SP | |
| setSPContent(entry.structuredPrompt || ''); | |
| // Restore params | |
| state.params = { ...entry.params }; | |
| syncParamsToUI(); | |
| closeHistoryModal(); | |
| showToast('Restored history entry #' + (index + 1), 'success'); | |
| } | |
| function restoreImageFromHistory() { | |
| const index = state.selectedHistoryIndex; | |
| if (index < 0 || index >= state.history.length) return; | |
| const entry = state.history[index]; | |
| if (entry.inputImage) { | |
| loadImageFromSrc(entry.inputImage); | |
| autoUpdateHW(); | |
| } else { | |
| state.currentImage = null; | |
| updateImagePanel(); | |
| } | |
| closeHistoryModal(); | |
| showToast('Image restored', 'success'); | |
| } | |
| function restorePromptFromHistory() { | |
| const index = state.selectedHistoryIndex; | |
| if (index < 0 || index >= state.history.length) return; | |
| const entry = state.history[index]; | |
| state.userPrompt = entry.userPrompt || ''; | |
| document.getElementById('userPromptInput').value = state.userPrompt; | |
| setSPContent(entry.structuredPrompt || ''); | |
| closeHistoryModal(); | |
| showToast('Prompt restored', 'success'); | |
| } | |
| function restoreParamsFromHistory() { | |
| const index = state.selectedHistoryIndex; | |
| if (index < 0 || index >= state.history.length) return; | |
| const entry = state.history[index]; | |
| state.params = { ...entry.params }; | |
| syncParamsToUI(); | |
| closeHistoryModal(); | |
| showToast('Params restored', 'success'); | |
| } | |
| // ==================== Toast Notifications ==================== | |
| function showToast(message, type = 'info') { | |
| const container = document.getElementById('toastContainer'); | |
| const toast = document.createElement('div'); | |
| toast.className = `toast toast-${type}`; | |
| toast.textContent = message; | |
| container.appendChild(toast); | |
| setTimeout(() => { | |
| toast.style.animation = 'toast-out 0.3s ease forwards'; | |
| setTimeout(() => { | |
| if (toast.parentNode) toast.remove(); | |
| }, 300); | |
| }, 3000); | |
| } | |
| // ==================== BBox Drag & Resize ==================== | |
| function initBboxDrag(e, elemIndex, handle) { | |
| const container = document.getElementById('bboxContainer'); | |
| const containerRect = container.getBoundingClientRect(); | |
| const elem = state.parsedElements[elemIndex]; | |
| if (!elem || !elem.bbox) return; | |
| state.bboxDrag = { | |
| elemIndex, | |
| handle, | |
| startX: e.clientX, | |
| startY: e.clientY, | |
| startBbox: [...elem.bbox], | |
| containerRect, | |
| }; | |
| document.addEventListener('mousemove', onBboxDrag); | |
| document.addEventListener('mouseup', endBboxDrag); | |
| } | |
| function onBboxDrag(e) { | |
| const drag = state.bboxDrag; | |
| if (!drag) return; | |
| const { elemIndex, handle, startX, startY, startBbox, containerRect } = drag; | |
| const dx = ((e.clientX - startX) / containerRect.width) * 1000; | |
| const dy = ((e.clientY - startY) / containerRect.height) * 1000; | |
| let [x1, y1, x2, y2] = startBbox; | |
| if (handle === 'move') { | |
| const w = x2 - x1, h = y2 - y1; | |
| x1 = Math.max(0, Math.min(1000 - w, x1 + dx)); | |
| y1 = Math.max(0, Math.min(1000 - h, y1 + dy)); | |
| x2 = x1 + w; | |
| y2 = y1 + h; | |
| } else { | |
| // Resize | |
| if (handle.includes('n')) y1 = Math.max(0, Math.min(y2 - 10, y1 + dy)); | |
| if (handle.includes('s')) y2 = Math.max(y1 + 10, Math.min(1000, y2 + dy)); | |
| if (handle.includes('w')) x1 = Math.max(0, Math.min(x2 - 10, x1 + dx)); | |
| if (handle.includes('e')) x2 = Math.max(x1 + 10, Math.min(1000, x2 + dx)); | |
| } | |
| // Round | |
| x1 = Math.round(x1); y1 = Math.round(y1); | |
| x2 = Math.round(x2); y2 = Math.round(y2); | |
| // Update visual position | |
| const box = document.querySelector(`.bbox-box[data-elem-index="${elemIndex}"]`); | |
| if (box) { | |
| box.style.left = (x1 / 1000 * 100) + '%'; | |
| box.style.top = (y1 / 1000 * 100) + '%'; | |
| box.style.width = ((x2 - x1) / 1000 * 100) + '%'; | |
| box.style.height = ((y2 - y1) / 1000 * 100) + '%'; | |
| } | |
| // Store temp bbox for commit | |
| drag.currentBbox = [x1, y1, x2, y2]; | |
| } | |
| function endBboxDrag(e) { | |
| document.removeEventListener('mousemove', onBboxDrag); | |
| document.removeEventListener('mouseup', endBboxDrag); | |
| const drag = state.bboxDrag; | |
| if (!drag) return; | |
| const newBbox = drag.currentBbox || drag.startBbox; | |
| const elemIndex = drag.elemIndex; | |
| state.bboxDrag = null; | |
| // Check if actually changed | |
| const old = drag.startBbox; | |
| const moved = old[0] !== newBbox[0] || old[1] !== newBbox[1] || old[2] !== newBbox[2] || old[3] !== newBbox[3]; | |
| if (!moved) { | |
| // Was a click, not a drag — handle bbox click with overlap cycling | |
| handleBboxClick(elemIndex, e); | |
| return; | |
| } | |
| // Update parsed element | |
| state.parsedElements[elemIndex].bbox = newBbox; | |
| // Update SP JSON | |
| updateSPPosition(elemIndex, newBbox); | |
| } | |
| function updateSPPosition(elemIndex, newBbox) { | |
| if (!state.parsedSP) return; | |
| pushSPUndo(); | |
| const elem = state.parsedElements[elemIndex]; | |
| const newPosStr = `<bbox>${newBbox.join(' ')}</bbox>`; | |
| // Find and update in elements array | |
| let updated = false; | |
| if (state.parsedSP.elements) { | |
| for (const e of state.parsedSP.elements) { | |
| if (e.id === elem.id && e.position) { | |
| e.position = newPosStr; | |
| updated = true; | |
| break; | |
| } | |
| } | |
| } | |
| if (!updated && state.parsedSP.scene && state.parsedSP.scene.elements) { | |
| for (const e of state.parsedSP.scene.elements) { | |
| if (e.id === elem.id && e.position) { | |
| e.position = newPosStr; | |
| break; | |
| } | |
| } | |
| } | |
| state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt); | |
| // Re-render structured view if visible | |
| if (!state.spRawMode) { | |
| renderSPStructured(); | |
| } else { | |
| document.getElementById('spTextarea').value = state.structuredPrompt; | |
| } | |
| } | |
| // ==================== Window Events ==================== | |
| window.addEventListener('resize', () => { | |
| if (state.bboxVisible && state.currentImage) { | |
| renderBboxes(); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |