Scribble-Board-Fast / index.html
prithivMLmods's picture
Update index.html
26b202e verified
Raw History Blame Contribute Delete
101 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scribble Board Fast</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#000000;--panel:#0a0a0a;--panel2:#111111;--canvas:#050505;
--border:#1f1f1f;--border2:#2a2a2a;
--text:#ffffff;--muted:#b0b0b0;--dim:#707070;--faint:#404040;
--accent:#ffffff;--accent2:#e0e0e0;--accent3:#a0a0a0;
--menubar-h:48px;--statusbar-h:30px;--rail-w:52px;--inspector-w:360px;
}
html,body{height:100%}
body{
background:var(--bg);font-family:'Inter',system-ui,-apple-system,sans-serif;
font-size:13px;color:var(--text);overflow:hidden;
}
/* ══ Layout grid ══ */
.studio{
display:grid;height:100vh;
grid-template-rows:var(--menubar-h) 1fr var(--statusbar-h);
grid-template-columns:var(--rail-w) 1fr var(--inspector-w);
grid-template-areas:
"menubar menubar menubar"
"rail workspace inspector"
"statusbar statusbar statusbar";
}
/* ══ Menu bar ══ */
.menubar{
grid-area:menubar;display:flex;align-items:center;gap:14px;
background:var(--panel);border-bottom:1px solid var(--border);
padding:0 16px;z-index:50;
}
.menubar-logo{
width:32px;height:28px;
background:#ffffff; color:#000;
border-radius:8px;display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 8px rgba(255,255,255,.25);flex-shrink:0;
font-weight:800; font-size:16px; letter-spacing:-1px;
}
.menubar-title{font-size:14px;font-weight:700;letter-spacing:-.2px;white-space:nowrap}
.menubar-title span{color:var(--dim);font-weight:500}
.menubar-badge{
font-size:10px;font-weight:700;padding:2px 8px;border-radius:12px;
background:rgba(255,255,255,.1);color:var(--accent2);border:1px solid rgba(255,255,255,.2);
letter-spacing:.4px;white-space:nowrap;
}
.menubar-spacer{flex:1}
.menubar-status{
display:flex;align-items:center;gap:7px;font-family:'JetBrains Mono',monospace;
font-size:11px;color:var(--dim);
}
.menubar-status .dot{width:7px;height:7px;border-radius:50%;background:#fff;box-shadow:0 0 6px rgba(255,255,255,.6)}
.menubar-status .dot.off{background:#f87171;box-shadow:0 0 6px rgba(248,113,113,.5)}
.gh-btn{
display:inline-flex;align-items:center;gap:6px;
padding:5px 13px;border-radius:7px;text-decoration:none;
font-size:12px;font-weight:600;background:#fff;color:#000;
border:1px solid #333;
transition:all .15s ease;flex-shrink:0;
}
.gh-btn:hover{background:#e0e0e0}
.gh-btn svg{fill:#000;width:13px;height:13px}
/* ══ Left icon rail ══ */
.rail{
grid-area:rail;background:var(--panel);border-right:1px solid var(--border);
display:flex;flex-direction:column;align-items:center;padding:10px 0;gap:4px;
overflow-y:auto;z-index:40;
}
.rail-btn{
width:38px;height:38px;border-radius:9px;border:1px solid transparent;
background:transparent;color:var(--dim);cursor:pointer;
display:flex;align-items:center;justify-content:center;
transition:all .15s ease;position:relative;
}
.rail-btn:hover:not(:disabled){background:rgba(255,255,255,.1);color:var(--accent2);border-color:rgba(255,255,255,.25)}
.rail-btn.active{background:rgba(255,255,255,.2);color:var(--accent2);border-color:rgba(255,255,255,.4)}
.rail-btn:disabled{opacity:.3;cursor:not-allowed}
.rail-btn svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.rail-btn svg.fill{fill:currentColor;stroke:none}
.rail-sep{width:24px;height:1px;background:var(--border);margin:6px 0}
.rail-label{
font-size:8px;font-weight:700;color:var(--faint);letter-spacing:1px;
text-transform:uppercase;margin:8px 0 2px;user-select:none;
}
/* Custom inputs in rail */
.color-input-wrap {
width: 38px; height: 38px; border-radius: 9px;
border: 1px solid transparent; background: transparent;
display: flex; align-items: center; justify-content: center;
transition: all .15s ease;
}
.color-input-wrap:hover { background: rgba(255,255,255,.1); border-color: rgba(255,255,255,.25); }
.color-input {
width: 24px; height: 24px; border: none; cursor: pointer;
background: transparent; padding: 0;
}
.color-input::-webkit-color-swatch-wrapper { padding: 0; }
.color-input::-webkit-color-swatch { border: 2px solid var(--dim); border-radius: 6px; }
.size-slider-wrap {
width: 38px; height: 80px; display: flex; align-items: center; justify-content: center;
}
.size-slider {
-webkit-appearance: none; appearance: none;
width: 80px; height: 6px; background: var(--border);
border-radius: 3px; outline: none; transform: rotate(-90deg);
}
.size-slider::-webkit-slider-thumb {
-webkit-appearance: none; width: 14px; height: 14px; background: #fff;
border-radius: 50%; cursor: pointer; box-shadow: 0 2px 6px rgba(255,255,255,.4);
}
/* ══ Workspace ══ */
.workspace{
grid-area:workspace;display:flex;flex-direction:column;min-width:0;min-height:0;
background:var(--canvas);
}
.canvas-head{
display:flex;align-items:center;gap:10px;padding:8px 16px;
border-bottom:1px solid var(--border);background:var(--panel2);flex-shrink:0;
}
.seg-control{
display:inline-flex;background:var(--panel);border:1px solid var(--border);
border-radius:8px;padding:2px;gap:2px;
}
.seg-btn{
padding:4px 14px;font-size:11px;font-weight:600;color:var(--dim);
background:transparent;border:none;border-radius:6px;cursor:pointer;
font-family:'Inter',sans-serif;transition:all .15s;letter-spacing:.2px;
}
.seg-btn:hover:not(.active){color:var(--muted)}
.seg-btn.active{background:rgba(255,255,255,.18);color:var(--accent2)}
.canvas-meta{
font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--faint);
overflow:hidden;white-space:nowrap;text-overflow:ellipsis;flex:1;
}
.canvas-actions{display:flex;align-items:center;gap:6px}
.canvas-stage{
flex:1;position:relative;overflow:hidden;min-height:0;
display:flex;align-items:center;justify-content:center;
background:
repeating-conic-gradient(#0c0c0c 0% 25%, #050505 0% 50%) 0 0/24px 24px;
padding: 24px;
}
.canvas-stage.drag-over{outline:2px dashed rgba(255,255,255,.65);outline-offset:-8px}
#draw-canvas {
background: #ffffff;
border: 1px solid var(--border2);
border-radius: 4px;
max-width: 100%;
max-height: 100%;
width: 100%;
height: auto;
box-shadow: 0 12px 48px rgba(0,0,0,.9);
touch-action: none;
display: block;
}
#draw-canvas.cursor-brush { cursor: crosshair; }
#draw-canvas.cursor-select { cursor: default; }
#draw-canvas.cursor-shape { cursor: crosshair; }
#draw-canvas.cursor-eraser {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 21l-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21'/%3E%3Cpath d='M22 21H7'/%3E%3Cpath d='M5 11l9 9'/%3E%3C/svg%3E") 4 14, auto;
}
#result-img {
background: #ffffff;
border: 1px solid var(--border2);
border-radius: 4px;
max-width: 100%;
max-height: 100%;
width: 100%;
height: auto;
object-fit: contain;
box-shadow: 0 12px 48px rgba(0,0,0,.9);
display: none;
}
/* Loader */
.modern-loader{
display:none;position:absolute;inset:0;background:rgba(0,0,0,.92);
z-index:15;flex-direction:column;align-items:center;justify-content:center;gap:16px;backdrop-filter:blur(5px);
}
.modern-loader.active{display:flex}
.loader-spinner{
width:38px;height:38px;border:3px solid var(--border);border-top-color:var(--accent);
border-radius:50%;animation:spin .8s linear infinite;
}
@keyframes spin{to{transform:rotate(360deg)}}
.loader-text{font-size:13px;color:var(--muted);font-weight:500;text-align:center;padding:0 16px}
.loader-bar-track{width:220px;height:4px;background:var(--border);border-radius:2px;overflow:hidden}
.loader-bar-fill{
height:100%;width:100%;background:linear-gradient(90deg,#ffffff,#e0e0e0,#ffffff);
background-size:200% 100%;animation:shimmer 1.5s ease-in-out infinite;border-radius:2px;
transition:width .3s ease;
}
.loader-bar-fill.determinate{animation:none;background:var(--accent)}
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
/* ══ Floating panels (shared frame) ══ */
.sticker-panel{
position:absolute;left:16px;top:16px;width:276px;
background:var(--panel);border:1px solid var(--border2);border-radius:12px;
box-shadow:0 18px 56px rgba(0,0,0,.75);
display:none;flex-direction:column;z-index:14;overflow:hidden;
}
.sticker-panel.open{display:flex}
.sticker-panel-head{
display:flex;align-items:center;justify-content:space-between;
padding:10px 14px;border-bottom:1px solid var(--border);
font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--muted);
}
.sticker-panel-close{
background:transparent;border:none;color:var(--dim);cursor:pointer;
font-size:17px;line-height:1;padding:2px 7px;border-radius:5px;font-family:inherit;
}
.sticker-panel-close:hover{color:#fff;background:rgba(255,255,255,.12)}
.sticker-grid{
display:grid;grid-template-columns:repeat(6,1fr);gap:6px;
padding:12px;max-height:250px;overflow-y:auto;
}
.sticker-cell{
aspect-ratio:1;background:#ffffff;border:1px solid var(--border2);border-radius:8px;
display:flex;align-items:center;justify-content:center;
font-size:22px;line-height:1;cursor:pointer;padding:0;
font-family:"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease;
}
.sticker-cell:hover{transform:scale(1.14);border-color:#fff;box-shadow:0 4px 14px rgba(0,0,0,.6);position:relative;z-index:1}
.sticker-panel-foot{
padding:10px 12px;border-top:1px solid var(--border);
display:flex;flex-direction:column;gap:8px;
}
.sticker-upload-btn{
display:flex;align-items:center;justify-content:center;gap:7px;width:100%;
background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.25);border-radius:8px;
color:var(--accent2);font-family:'Inter',sans-serif;font-size:12px;font-weight:600;
padding:9px 12px;cursor:pointer;transition:all .15s;
}
.sticker-upload-btn:hover{background:rgba(255,255,255,.2);border-color:rgba(255,255,255,.45)}
.sticker-upload-btn svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.sticker-hint{
font-size:9.5px;color:var(--faint);line-height:1.6;
font-family:'JetBrains Mono',monospace;
}
/* ══ Shape panel ══ */
.shape-grid{
display:grid;grid-template-columns:repeat(6,1fr);gap:6px;
padding:12px;max-height:250px;overflow-y:auto;
}
.shape-cell{
aspect-ratio:1;background:#ffffff;border:1px solid var(--border2);border-radius:8px;
display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;
transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease,background .12s ease;
}
.shape-cell svg{width:20px;height:20px;fill:none;stroke:#111;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.shape-cell:hover{transform:scale(1.12);border-color:#fff;box-shadow:0 4px 14px rgba(0,0,0,.6);position:relative;z-index:1}
.shape-cell.active{background:#2563eb;border-color:#2563eb}
.shape-cell.active svg{stroke:#fff}
/* ══ Text panel ══ */
.text-panel{
position:absolute;right:16px;top:16px;width:252px;
background:var(--panel);border:1px solid var(--border2);border-radius:12px;
box-shadow:0 18px 56px rgba(0,0,0,.75);
display:none;flex-direction:column;z-index:14;overflow:hidden;
}
.text-panel.open{display:flex}
.text-panel-body{
padding:12px;display:flex;flex-direction:column;gap:10px;
}
.text-panel-input{
width:100%;background:var(--panel2);border:1px solid var(--border);border-radius:8px;
padding:9px 11px;font-family:'Inter',sans-serif;font-size:13px;color:var(--text);
resize:vertical;outline:none;min-height:58px;line-height:1.5;
}
.text-panel-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,255,255,.15)}
.text-panel-input::placeholder{color:var(--faint)}
.text-panel .lora-native-select{width:100%}
.text-style-row{display:flex;align-items:center;gap:8px}
.text-style-cluster{display:flex;align-items:center;gap:5px;flex-shrink:0}
.toggle-btn{
width:28px;height:28px;border-radius:6px;border:1px solid var(--border2);
background:var(--panel2);color:var(--muted);cursor:pointer;
font-family:'Inter',sans-serif;font-size:13px;font-weight:800;
display:flex;align-items:center;justify-content:center;transition:all .15s;padding:0;
}
.toggle-btn:hover{border-color:var(--accent);color:var(--accent2)}
.toggle-btn.active{background:#fff;color:#000;border-color:#fff}
.text-color-input{
width:28px;height:28px;border:1px solid var(--border2);border-radius:6px;
background:var(--panel2);cursor:pointer;padding:2px;
}
.text-color-input::-webkit-color-swatch-wrapper{padding:0}
.text-color-input::-webkit-color-swatch{border:none;border-radius:4px}
.mini-range{
-webkit-appearance:none;appearance:none;height:5px;background:var(--border);
border-radius:3px;outline:none;flex:1;min-width:0;
}
.mini-range::-webkit-slider-thumb{
-webkit-appearance:none;width:14px;height:14px;background:#ffffff;
border-radius:50%;cursor:pointer;box-shadow:0 2px 6px rgba(255,255,255,.4);
}
.mini-range::-moz-range-thumb{
width:14px;height:14px;background:#ffffff;border-radius:50%;cursor:pointer;
border:none;box-shadow:0 2px 6px rgba(255,255,255,.4);
}
/* ══ Inline text editor (opens on double-click to EDIT board text) ══ */
.inline-text-editor{
position:absolute;z-index:16;display:none;
background:rgba(255,255,255,.88);
border:1.5px dashed #2563eb;border-radius:4px;
outline:none;color:#000;padding:2px 6px;
font-family:'Inter',sans-serif;font-size:16px;line-height:1.3;
white-space:pre;overflow:hidden;resize:none;
min-width:60px;min-height:28px;
box-shadow:0 6px 24px rgba(0,0,0,.45);
}
/* ══ Inspector ══ */
.inspector{
grid-area:inspector;background:var(--panel);border-left:1px solid var(--border);
display:flex;flex-direction:column;min-height:0;z-index:40;
}
.inspector-tabs{
display:flex;border-bottom:1px solid var(--border);flex-shrink:0;
}
.insp-tab{
flex:1;padding:11px 8px;font-size:11px;font-weight:700;color:var(--dim);
background:transparent;border:none;border-bottom:2px solid transparent;
cursor:pointer;font-family:'Inter',sans-serif;text-transform:uppercase;letter-spacing:.8px;
transition:all .15s;
}
.insp-tab:hover:not(.active){color:var(--muted)}
.insp-tab.active{color:var(--accent2);border-bottom-color:var(--accent)}
.insp-page{display:none;flex:1;overflow-y:auto;min-height:0;flex-direction:column}
.insp-page.active{display:flex}
.insp-section{border-bottom:1px solid var(--border);padding:14px 18px}
.insp-section-title{
font-size:10px;font-weight:700;color:var(--dim);text-transform:uppercase;
letter-spacing:1px;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between;
}
.char-count{font-size:9px;font-weight:500;color:var(--faint);font-family:'JetBrains Mono',monospace;text-transform:none;letter-spacing:0}
.modern-textarea{
width:100%;background:var(--panel2);border:1px solid var(--border);border-radius:8px;
padding:10px 13px;font-family:'Inter',sans-serif;font-size:13px;color:var(--text);
resize:vertical;outline:none;min-height:64px;transition:border-color .2s;line-height:1.55;
}
.modern-textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,255,255,.15)}
.modern-textarea::placeholder{color:var(--faint)}
.modern-textarea.error-flash{
border-color:#ef4444!important;box-shadow:0 0 0 3px rgba(239,68,68,.2)!important;animation:shake .4s ease;
}
@keyframes shake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-4px)}40%,80%{transform:translateX(4px)}}
.suggestions-wrap{display:flex;flex-wrap:wrap;gap:5px}
.suggestion-chip{
display:inline-flex;align-items:center;padding:4px 11px;
background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.18);border-radius:14px;
color:var(--accent3);font-size:11px;font-weight:500;font-family:'Inter',sans-serif;
cursor:pointer;transition:all .15s;white-space:nowrap;
}
.suggestion-chip:hover{background:rgba(255,255,255,.15);border-color:rgba(255,255,255,.35);color:var(--accent2)}
.suggestion-chip.active{background:rgba(255,255,255,.25);border-color:var(--accent);color:#000}
.btn-run{
display:flex;align-items:center;justify-content:center;gap:8px;width:100%;
background:#ffffff;border:none;border-radius:9px;
padding:12px 24px;cursor:pointer;font-size:14px;font-weight:700;font-family:'Inter',sans-serif;
color:#000;transition:all .2s ease;letter-spacing:-.2px;
box-shadow:0 4px 16px rgba(255,255,255,.15),inset 0 1px 0 rgba(255,255,255,.1);
}
.btn-run:hover:not(:disabled){
background:#e0e0e0;transform:translateY(-1px);
box-shadow:0 6px 24px rgba(255,255,255,.25),inset 0 1px 0 rgba(255,255,255,.15);
}
.btn-run:active:not(:disabled){transform:translateY(0)}
.btn-run:disabled{opacity:.55;cursor:not-allowed}
.btn-cancel{
display:flex;align-items:center;justify-content:center;gap:6px;width:100%;
margin-top:8px;background:transparent;border:1px solid var(--border2);border-radius:9px;
padding:8px 24px;cursor:pointer;font-size:12px;font-weight:600;font-family:'Inter',sans-serif;
color:var(--muted);transition:all .15s;
}
.btn-cancel:hover{border-color:#ef4444;color:#ef4444;background:rgba(239,68,68,.08)}
.run-hint{
text-align:center;font-size:10px;color:var(--faint);margin-top:8px;
font-family:'JetBrains Mono',monospace;
}
.slider-row{display:flex;align-items:center;gap:9px;min-height:26px;margin-bottom:10px}
.slider-row:last-child{margin-bottom:0}
.slider-row label{font-size:12px;font-weight:500;color:var(--muted);min-width:64px;flex-shrink:0}
.slider-row input[type="range"]{
flex:1;-webkit-appearance:none;appearance:none;height:5px;background:var(--border);
border-radius:3px;outline:none;min-width:0;
}
.slider-row input[type="range"]::-webkit-slider-thumb{
-webkit-appearance:none;width:14px;height:14px;background:#ffffff;
border-radius:50%;cursor:pointer;box-shadow:0 2px 6px rgba(255,255,255,.4);transition:transform .15s;
}
.slider-row input[type="range"]::-webkit-slider-thumb:hover{transform:scale(1.2)}
.slider-row input[type="range"]::-moz-range-thumb{
width:14px;height:14px;background:#ffffff;
border-radius:50%;cursor:pointer;border:none;box-shadow:0 2px 6px rgba(255,255,255,.4);
}
.slider-val{
min-width:48px;text-align:right;font-family:'JetBrains Mono',monospace;font-size:11px;
font-weight:500;padding:2px 7px;background:var(--panel2);border:1px solid var(--border);
border-radius:5px;color:var(--muted);flex-shrink:0;
}
.dice-btn{
display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;
background:var(--panel2);border:1px solid var(--border);border-radius:5px;cursor:pointer;
color:var(--dim);transition:all .15s;flex-shrink:0;padding:0;
}
.dice-btn:hover{border-color:var(--accent);color:var(--accent2)}
.dice-btn svg{width:13px;height:13px;fill:currentColor}
.checkbox-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);margin-bottom:10px}
.checkbox-row input[type="checkbox"]{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}
.checkbox-row label{color:var(--muted);font-size:12px;cursor:pointer}
.lora-native-select{
width:100%;background:var(--panel2);
border:1px solid var(--border2);border-radius:8px;
padding:9px 34px 9px 13px;
font-family:'Inter',sans-serif;font-size:12px;font-weight:600;color:var(--text);
outline:none;appearance:none;-webkit-appearance:none;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat:no-repeat;background-position:right 11px center;
cursor:pointer;transition:border-color .2s;color-scheme:dark;
}
.lora-native-select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,255,255,.15)}
.lora-native-select option{background:var(--panel);color:var(--text)}
/* ══ Status bar ══ */
.statusbar{
grid-area:statusbar;background:var(--panel);border-top:1px solid var(--border);
display:flex;align-items:center;gap:4px;padding:0 14px;font-size:11px;z-index:50;
}
.statusbar .sb-section{
padding:0 10px;display:flex;align-items:center;font-family:'JetBrains Mono',monospace;
font-size:11px;color:var(--faint);overflow:hidden;white-space:nowrap;gap:6px;
}
.statusbar .sb-right{margin-left:auto;display:flex;align-items:center;gap:10px}
.sb-pill{
padding:2px 12px;border-radius:5px;font-weight:600;font-size:10px;
background:rgba(255,255,255,.1);color:var(--accent2);
}
.sb-pill.error{background:rgba(239,68,68,.12);color:#f87171}
.sb-pill.done{background:rgba(255,255,255,.12);color:#fff}
.sb-link{color:var(--faint);text-decoration:none;font-family:'JetBrains Mono',monospace;font-size:10px}
.sb-link:hover{color:var(--accent3)}
/* ══ Toast ══ */
.toast-notification{
position:fixed;top:60px;left:50%;transform:translateX(-50%) translateY(-140%);
z-index:9999;padding:10px 22px;border-radius:9px;font-family:'Inter',sans-serif;
font-size:13px;font-weight:600;display:flex;align-items:center;gap:8px;
box-shadow:0 8px 24px rgba(0,0,0,.5);
transition:transform .35s cubic-bezier(.34,1.56,.64,1),opacity .35s ease;opacity:0;pointer-events:none;
}
.toast-notification.visible{transform:translateX(-50%) translateY(0);opacity:1;pointer-events:auto}
.toast-notification.error{background:linear-gradient(135deg,#dc2626,#b91c1c);color:#fff;border:1px solid rgba(255,255,255,.15)}
.toast-notification.warning{background:linear-gradient(135deg,#d97706,#b45309);color:#fff;border:1px solid rgba(255,255,255,.15)}
.toast-notification.info{background:linear-gradient(135deg,#ffffff,#e0e0e0);color:#000;border:1px solid rgba(0,0,0,.15)}
.toast-notification .toast-icon{font-size:15px;line-height:1}
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
::-webkit-scrollbar-thumb:hover{background:var(--border2)}
@media (pointer:coarse){
.rail-btn{width:44px;height:44px}
.rail-btn svg{width:19px;height:19px}
}
@media(max-width:960px){
html,body{height:auto}
body{overflow:auto!important}
.studio{ display:flex;flex-direction:column;height:auto;min-height:100vh; }
.menubar{ flex-wrap:wrap;gap:8px;padding:10px 12px;min-height:var(--menubar-h); position:sticky;top:0; }
.menubar-title{font-size:13px} .menubar-status{display:none} .menubar-spacer{flex:1}
.gh-btn{padding:6px 10px} .gh-btn span{display:none}
.rail{ flex-direction:row;justify-content:flex-start;flex-wrap:nowrap; overflow-x:auto;overflow-y:hidden;border-right:none; border-bottom:1px solid var(--border);padding:6px 10px;gap:6px; }
.rail-sep{width:1px;height:26px;margin:0 4px;flex-shrink:0} .rail-label{display:none} .rail-btn{flex-shrink:0}
.workspace{min-height:0} .canvas-head{flex-wrap:wrap;gap:8px;padding:8px 12px}
.canvas-meta{flex-basis:100%;order:3;font-size:10px}
.canvas-stage{padding:12px}
.inspector{border-left:none;border-top:1px solid var(--border)}
.inspector-tabs{position:sticky;top:0;background:var(--panel);z-index:30}
.insp-tab{padding:13px 8px;font-size:12px} .insp-page{overflow-y:visible} .insp-section{padding:14px 16px}
.modern-textarea{font-size:16px} .btn-run{padding:14px 24px;font-size:15px}
.statusbar{flex-wrap:wrap;height:auto;min-height:var(--statusbar-h);padding:4px 10px;gap:2px}
.sb-link{display:none} .toast-notification{max-width:calc(100vw - 32px);font-size:12px;padding:10px 16px}
.sticker-panel{left:8px;top:8px;width:min(280px,calc(100vw - 40px));}
.sticker-grid{max-height:190px}
.shape-grid{max-height:190px}
.text-panel{right:8px;top:8px;width:min(252px,calc(100vw - 40px));}
}
</style>
</head>
<body>
<div class="studio">
<!-- ══ Menu bar ══ -->
<div class="menubar">
<div class="menubar-logo">S</div>
<div class="menubar-title">Scribble-Board <span>/ Fast</span></div>
<span class="menubar-badge">9B</span>
<span class="menubar-badge">4-STEP FAST</span>
<div class="menubar-spacer"></div>
<div class="menubar-status"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting…</span></div>
<a href="https://github.com/PRITHIVSAKTHIUR/Scribble-Board-Fast" target="_blank" rel="noopener" class="gh-btn">
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
<span>GitHub</span>
</a>
</div>
<!-- ══ Left icon rail ══ -->
<div class="rail">
<div class="rail-label">Tools</div>
<button class="rail-btn" id="btn-select" title="Select / Move Objects">
<svg viewBox="0 0 24 24"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/></svg>
</button>
<button class="rail-btn active" id="btn-brush" title="Brush">
<svg viewBox="0 0 24 24"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>
</button>
<button class="rail-btn" id="btn-eraser" title="Eraser">
<svg viewBox="0 0 24 24"><path d="M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z"/><line x1="16" y1="8" x2="8" y2="16"/></svg>
</button>
<div class="color-input-wrap">
<input type="color" id="color-picker" class="color-input" value="#000000" title="Brush Color">
</div>
<div class="size-slider-wrap">
<input type="range" id="size-slider" class="size-slider" min="1" max="50" value="5" title="Brush Size">
</div>
<div class="rail-sep"></div>
<button class="rail-btn" id="btn-undo" title="Undo">
<svg viewBox="0 0 24 24"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
</button>
<button class="rail-btn" id="btn-redo" title="Redo">
<svg viewBox="0 0 24 24"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 15-6.7L21 13"/></svg>
</button>
<button class="rail-btn" id="btn-clear" title="Clear All">
<svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
<div class="rail-sep"></div>
<div class="rail-label">Assets</div>
<button class="rail-btn" id="btn-stickers" title="Stickers &amp; Assets">
<svg viewBox="0 0 24 24"><path d="M15.5 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8.5L15.5 3Z"/><path d="M14 3v4a2 2 0 0 0 2 2h4"/><path d="M8 13h.01"/><path d="M16 13h.01"/><path d="M10 16s.8 1 2 1c1.3 0 2-1 2-1"/></svg>
</button>
<button class="rail-btn" id="btn-shapes" title="Shapes &amp; Lines">
<svg viewBox="0 0 24 24"><rect x="3.5" y="3.5" width="8" height="8" rx="1"/><circle cx="16.5" cy="16.5" r="4.5"/></svg>
</button>
<button class="rail-btn" id="btn-text-panel" title="Text — type or paste text">
<svg viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg>
</button>
<div class="rail-sep"></div>
<div class="rail-label">Result</div>
<button class="rail-btn" id="btn-download" title="Download Result" disabled>
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>
<!-- ══ Workspace ══ -->
<div class="workspace">
<div class="canvas-head">
<div class="seg-control">
<button class="seg-btn active" id="seg-input">Sketch</button>
<button class="seg-btn" id="seg-result">Result</button>
</div>
<div class="canvas-meta" id="canvas-meta">Draw a sketch to begin</div>
<div class="canvas-actions"></div>
</div>
<div class="canvas-stage" id="canvas-stage">
<div class="modern-loader" id="output-loader">
<div class="loader-spinner"></div>
<div class="loader-text" id="loader-text">Processing image&hellip;</div>
<div class="loader-bar-track"><div class="loader-bar-fill" id="loader-bar-fill"></div></div>
</div>
<!-- Sticker / asset panel -->
<div class="sticker-panel" id="sticker-panel">
<div class="sticker-panel-head">
<span>Stickers &amp; Assets</span>
<button class="sticker-panel-close" id="sticker-panel-close" title="Close">&times;</button>
</div>
<div class="sticker-grid" id="sticker-grid"></div>
<div class="sticker-panel-foot">
<button class="sticker-upload-btn" id="sticker-upload-btn" type="button">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Upload Image
</button>
<input type="file" id="sticker-file-input" accept="image/*" multiple hidden>
<div class="sticker-hint">click to add &middot; drag to move &middot; drag corners to resize (&#8679; = keep ratio) &middot; &times; / Delete removes &middot; drop files or paste images onto the board</div>
</div>
</div>
<!-- Shapes & lines panel -->
<div class="sticker-panel" id="shape-panel">
<div class="sticker-panel-head">
<span>Shapes &amp; Lines</span>
<button class="sticker-panel-close" id="shape-panel-close" title="Close">&times;</button>
</div>
<div class="shape-grid" id="shape-grid"></div>
<div class="sticker-panel-foot">
<div class="text-style-row">
<div class="text-style-cluster">
<input type="color" id="shape-stroke" class="text-color-input" value="#111111" title="Stroke color">
<input type="color" id="shape-fill" class="text-color-input" value="#ffffff" title="Fill color">
<button id="shape-fill-toggle" class="toggle-btn" type="button" title="Fill on / off">F</button>
</div>
<input type="range" id="shape-width" class="mini-range" min="1" max="24" step="1" value="4" title="Stroke width">
<span class="slider-val" id="shape-width-val">4</span>
</div>
<div class="sticker-hint">pick a shape, then <b>drag on the board</b> to draw it (click = default size; arrows point along your drag) &middot; drag to move &middot; corners resize (&#8679; = ratio) &middot; select a shape and restyle it live</div>
</div>
</div>
<!-- Text panel (the ONLY way to add text now) -->
<div class="text-panel" id="text-panel">
<div class="sticker-panel-head">
<span>Text</span>
<button class="sticker-panel-close" id="text-panel-close" title="Close">&times;</button>
</div>
<div class="text-panel-body">
<textarea id="text-input" class="text-panel-input" rows="3"
placeholder="Type or paste text here…"></textarea>
<select id="text-font" class="lora-native-select" title="Font">
<option value="Inter, system-ui, sans-serif" selected>Inter (Sans)</option>
<option value="'JetBrains Mono', monospace">JetBrains Mono</option>
<option value="Georgia, 'Times New Roman', serif">Serif</option>
<option value="'Comic Sans MS', 'Comic Sans', 'Chalkboard SE', cursive">Cursive / Comic</option>
<option value="Impact, 'Arial Black', sans-serif">Impact</option>
</select>
<div class="text-style-row">
<div class="text-style-cluster">
<button id="text-bold" class="toggle-btn" type="button" title="Bold">B</button>
<button id="text-italic" class="toggle-btn" type="button" title="Italic"><i>I</i></button>
<input type="color" id="text-color" class="text-color-input" value="#111111" title="Text color">
</div>
<input type="range" id="text-size" class="mini-range" min="12" max="200" step="1" value="48" title="Font size">
<span class="slider-val" id="text-size-val">48</span>
</div>
<button id="text-add-btn" class="sticker-upload-btn" type="button">
<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
Add to Board
</button>
<div class="sticker-hint">style your text, then <b>Add to Board</b> &middot; drag to move &middot; corners resize &middot; <b>double-click text on the board to edit</b> (Enter commits, &#8679;Enter = new line, Esc cancels) &middot; &#8984;/Ctrl+V pastes clipboard text onto the board</div>
</div>
</div>
<!-- Inline editor: opens ONLY on double-click of an existing board text -->
<textarea id="inline-text-editor" class="inline-text-editor" rows="1" wrap="off"
spellcheck="false" style="display:none;"></textarea>
<canvas id="draw-canvas" width="1024" height="576" class="cursor-brush" style="aspect-ratio: 16 / 9;"></canvas>
<img id="result-img" alt="Result Image" style="display:none; aspect-ratio: 16 / 9;">
</div>
</div>
<!-- ══ Inspector ══ -->
<div class="inspector">
<div class="inspector-tabs">
<button class="insp-tab active" id="tab-edit">Generate</button>
</div>
<!-- Edit page -->
<div class="insp-page active" id="page-edit">
<div class="insp-section">
<div class="insp-section-title"><span>Prompt</span><span class="char-count" id="char-count">0</span></div>
<textarea id="custom-prompt-input" class="modern-textarea" rows="4"
placeholder="Describe what you want to generate from your sketch..."></textarea>
</div>
<div class="insp-section">
<div class="insp-section-title"><span>Quick Prompts</span></div>
<div class="suggestions-wrap" id="suggestions-wrap"></div>
</div>
<div class="insp-section">
<div class="insp-section-title"><span>Aspect Ratio</span></div>
<select id="custom-ratio" class="lora-native-select">
<option value="16:9" selected>16:9 (Landscape)</option>
<option value="9:16">9:16 (Portrait)</option>
<option value="1:1">1:1 (Square)</option>
<option value="4:3">4:3 (Standard)</option>
<option value="3:4">3:4 (Portrait)</option>
</select>
</div>
<div class="insp-section">
<button id="custom-run-btn" class="btn-run">
<span id="run-btn-label">Generate Image</span>
</button>
<button id="custom-cancel-btn" class="btn-cancel" style="display:none;">Cancel</button>
<div class="run-hint">⌘/Ctrl + Enter</div>
</div>
<div class="insp-section">
<div class="insp-section-title"><span>Advanced</span></div>
<div class="slider-row">
<label>Seed</label>
<input type="range" id="custom-seed" min="0" max="2147483647" step="1" value="0">
<span class="slider-val" id="custom-seed-val">0</span>
<button class="dice-btn" id="dice-btn" title="Random seed">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM7.5 18c-.83 0-1.5-.67-1.5-1.5S6.67 15 7.5 15s1.5.67 1.5 1.5S8.33 18 7.5 18zm0-9C6.67 9 6 8.33 6 7.5S6.67 6 7.5 6 9 6.67 9 7.5 8.33 9 7.5 9zm4.5 4.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5 4.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm0-9c-.83 0-1.5-.67-1.5-1.5S15.67 6 16.5 6s1.5.67 1.5 1.5S17.33 9 16.5 9z"/></svg>
</button>
</div>
<div class="checkbox-row">
<input type="checkbox" id="custom-randomize" checked>
<label for="custom-randomize">Randomize seed</label>
</div>
<div class="slider-row">
<label>Guidance</label>
<input type="range" id="custom-guidance" min="1" max="10" step="0.1" value="1.0">
<span class="slider-val" id="custom-guidance-val">1.0</span>
</div>
<div class="slider-row">
<label>Steps</label>
<input type="range" id="custom-steps" min="1" max="50" step="1" value="4">
<span class="slider-val" id="custom-steps-val">4</span>
</div>
</div>
</div>
</div>
<!-- ══ Status bar ══ -->
<div class="statusbar">
<div class="sb-section" id="sb-image-count">Sketch Ready</div>
<div class="sb-right">
<a class="sb-link" href="https://huggingface.co/black-forest-labs/FLUX.2-klein-9B" target="_blank" rel="noopener">FLUX.2-klein-9B</a>
<span class="sb-pill" id="sb-status">Ready</span>
</div>
</div>
</div>
<script type="module">
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
/* ── DOM refs ── */
const canvas = document.getElementById('draw-canvas');
const ctx = canvas.getContext('2d');
const stage = document.getElementById('canvas-stage');
const resultImg = document.getElementById('result-img');
const btnDownload = document.getElementById('btn-download');
const btnSelect = document.getElementById('btn-select');
const btnBrush = document.getElementById('btn-brush');
const btnEraser = document.getElementById('btn-eraser');
const btnUndo = document.getElementById('btn-undo');
const btnRedo = document.getElementById('btn-redo');
const btnClear = document.getElementById('btn-clear');
const colorPicker = document.getElementById('color-picker');
const sizeSlider = document.getElementById('size-slider');
const btnStickers = document.getElementById('btn-stickers');
const stickerPanel = document.getElementById('sticker-panel');
const stickerGrid = document.getElementById('sticker-grid');
const panelCloseBtn = document.getElementById('sticker-panel-close');
const uploadBtn = document.getElementById('sticker-upload-btn');
const fileInput = document.getElementById('sticker-file-input');
const btnShapes = document.getElementById('btn-shapes');
const shapePanel = document.getElementById('shape-panel');
const shapePanelClose = document.getElementById('shape-panel-close');
const shapeGridEl = document.getElementById('shape-grid');
const shapeStrokeInput = document.getElementById('shape-stroke');
const shapeFillInput = document.getElementById('shape-fill');
const shapeFillToggle = document.getElementById('shape-fill-toggle');
const shapeWidthSlider = document.getElementById('shape-width');
const shapeWidthVal = document.getElementById('shape-width-val');
const btnTextPanel = document.getElementById('btn-text-panel');
const textPanel = document.getElementById('text-panel');
const textPanelClose = document.getElementById('text-panel-close');
const textInput = document.getElementById('text-input');
const textFontSel = document.getElementById('text-font');
const textSizeSlider = document.getElementById('text-size');
const textSizeVal = document.getElementById('text-size-val');
const textColorInput = document.getElementById('text-color');
const textBoldBtn = document.getElementById('text-bold');
const textItalicBtn = document.getElementById('text-italic');
const textAddBtn = document.getElementById('text-add-btn');
const ed = document.getElementById('inline-text-editor');
const promptInput = document.getElementById('custom-prompt-input');
const charCount = document.getElementById('char-count');
const runBtn = document.getElementById('custom-run-btn');
const runBtnLabel = document.getElementById('run-btn-label');
const cancelBtn = document.getElementById('custom-cancel-btn');
const sbStatus = document.getElementById('sb-status');
const sbImageCount = document.getElementById('sb-image-count');
const loader = document.getElementById('output-loader');
const loaderText = document.getElementById('loader-text');
const loaderFill = document.getElementById('loader-bar-fill');
const seedSlider = document.getElementById('custom-seed');
const seedVal = document.getElementById('custom-seed-val');
const canvasMeta = document.getElementById('canvas-meta');
const segInput = document.getElementById('seg-input');
const segResult = document.getElementById('seg-result');
const connDot = document.getElementById('conn-dot');
const connText = document.getElementById('conn-text');
const ratioSelect = document.getElementById('custom-ratio');
/* ── State ── */
// Offscreen layer holding ONLY the free-hand strokes. The visible canvas is a
// live composite: strokes + stickers + text + shapes. Because everything is
// rendered INTO the canvas bitmap, canvas.toDataURL() (what we send to the
// backend) always contains the WHOLE board.
const sketchCanvas = document.createElement('canvas');
const sketchCtx = sketchCanvas.getContext('2d');
let currentTool = 'brush';
let brushColor = '#000000';
let brushSize = 5;
let stickers = []; // all board objects: images, text, shapes
let selectedSticker = null;
let dragMode = null; // null | 'draw' | 'move' | 'resize' | 'shapedraw'
let dragSticker = null;
let dragStart = null;
let dragOffX = 0, dragOffY = 0;
let dragChanged = false;
let lastX = 0, lastY = 0;
let editorSticker = null; // text object currently being edited (or null)
let editorStyle = null; // live style object for the open editor
let pendingTextCommit = null; // in-flight commit promise
let shapeKind = null; // currently selected shape kind
let pendingShape = null; // in-progress shape while dragging
let shapeStyleDirty = false;
let undoStack = [];
let redoStack = [];
const MAX_HISTORY = 20;
const MIN_SIZE = 20;
const LINE_KINDS = new Set(['line', 'arrow', 'double', 'dashed']);
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
function loadImage(src){
return new Promise((res, rej) => {
const im = new Image();
im.onload = () => res(im);
im.onerror = rej;
im.src = src;
});
}
function readFileAsDataURL(file){
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = rej;
r.readAsDataURL(file);
});
}
/* ── Canvas core ── */
function resetSketchLayer(){
sketchCanvas.width = canvas.width;
sketchCanvas.height = canvas.height;
sketchCtx.fillStyle = '#ffffff';
sketchCtx.fillRect(0, 0, sketchCanvas.width, sketchCanvas.height);
sketchCtx.lineCap = 'round';
sketchCtx.lineJoin = 'round';
}
function handleSize(){ return Math.max(10, Math.round(canvas.width / 85)); }
function corners(s){
return {
nw: [s.x, s.y],
ne: [s.x + s.w, s.y],
sw: [s.x, s.y + s.h],
se: [s.x + s.w, s.y + s.h]
};
}
function deleteBtnPos(s){
const r = Math.max(9, Math.round(canvas.width / 110));
return {
r,
x: clamp(s.x + s.w, r + 3, canvas.width - r - 3),
y: Math.max(s.y - r - 10, r + 3)
};
}
/* ════════════════════════════════════════════════════════════════════════
SHAPE RENDERING — shapes are vector objects re-rendered into the canvas
bitmap on every composite, so they are flattened into the exported PNG.
════════════════════════════════════════════════════════════════════════ */
function makeShape(kind, x, y, w, h, flip, rev, style){
return { kind, x, y, w, h, flip: !!flip, rev: !!rev, style: { ...style } };
}
/* Directional endpoints of a line/arrow, honouring the drawn direction */
function shapeEndpoints(s){
const a = s.flip ? { x: s.x + s.w, y: s.y } : { x: s.x, y: s.y };
const b = s.flip ? { x: s.x, y: s.y + s.h } : { x: s.x + s.w, y: s.y + s.h };
return s.rev ? [b, a] : [a, b];
}
function roundedRectPath(c, x, y, w, h, r){
r = Math.min(r, w / 2, h / 2);
c.moveTo(x + r, y);
c.lineTo(x + w - r, y);
c.quadraticCurveTo(x + w, y, x + w, y + r);
c.lineTo(x + w, y + h - r);
c.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
c.lineTo(x + r, y + h);
c.quadraticCurveTo(x, y + h, x, y + h - r);
c.lineTo(x, y + r);
c.quadraticCurveTo(x, y, x + r, y);
c.closePath();
}
function starPath(c, x, y, w, h){
const cx = x + w / 2, cy = y + h / 2;
const ox = w / 2, oy = h / 2;
const ix = ox * 0.42, iy = oy * 0.42;
c.moveTo(cx, cy - oy);
for (let i = 0; i < 5; i++){
const ia = (-90 + i * 72 + 36) * Math.PI / 180;
const oa = (-90 + (i + 1) * 72) * Math.PI / 180;
c.lineTo(cx + Math.cos(ia) * ix, cy + Math.sin(ia) * iy);
c.lineTo(cx + Math.cos(oa) * ox, cy + Math.sin(oa) * oy);
}
c.closePath();
}
function heartPath(c, x, y, w, h){
c.moveTo(x + w * 0.5, y + h * 0.98);
c.bezierCurveTo(x + w * 0.02, y + h * 0.68, x - w * 0.05, y + h * 0.28, x + w * 0.22, y + h * 0.10);
c.bezierCurveTo(x + w * 0.38, y, x + w * 0.5, y + h * 0.14, x + w * 0.5, y + h * 0.26);
c.bezierCurveTo(x + w * 0.5, y + h * 0.14, x + w * 0.62, y, x + w * 0.78, y + h * 0.10);
c.bezierCurveTo(x + w * 1.05, y + h * 0.28, x + w * 0.98, y + h * 0.68, x + w * 0.5, y + h * 0.98);
c.closePath();
}
function bubblePath(c, x, y, w, h){
const bodyH = h * 0.78;
const r = Math.min(w, bodyH) * 0.25;
c.moveTo(x + r, y);
c.lineTo(x + w - r, y);
c.quadraticCurveTo(x + w, y, x + w, y + r);
c.lineTo(x + w, y + bodyH - r);
c.quadraticCurveTo(x + w, y + bodyH, x + w - r, y + bodyH);
c.lineTo(x + w * 0.40, y + bodyH);
c.lineTo(x + w * 0.26, y + h); // tail tip
c.lineTo(x + w * 0.22, y + bodyH);
c.lineTo(x + r, y + bodyH);
c.quadraticCurveTo(x, y + bodyH, x, y + bodyH - r);
c.lineTo(x, y + r);
c.quadraticCurveTo(x, y, x + r, y);
c.closePath();
}
function buildShapePath(c, s){
const x = s.x, y = s.y, w = s.w, h = s.h;
switch (s.kind){
case 'rect': c.rect(x, y, w, h); break;
case 'round': roundedRectPath(c, x, y, w, h, Math.min(w, h) * 0.2); break;
case 'ellipse': c.ellipse(x + w / 2, y + h / 2, Math.max(0.5, w / 2), Math.max(0.5, h / 2), 0, 0, Math.PI * 2); break;
case 'triangle': c.moveTo(x + w / 2, y); c.lineTo(x + w, y + h); c.lineTo(x, y + h); c.closePath(); break;
case 'diamond': c.moveTo(x + w / 2, y); c.lineTo(x + w, y + h / 2); c.lineTo(x + w / 2, y + h); c.lineTo(x, y + h / 2); c.closePath(); break;
case 'hexagon': {
const m = w * 0.25;
c.moveTo(x + m, y); c.lineTo(x + w - m, y); c.lineTo(x + w, y + h / 2);
c.lineTo(x + w - m, y + h); c.lineTo(x + m, y + h); c.lineTo(x, y + h / 2);
c.closePath(); break;
}
case 'star': starPath(c, x, y, w, h); break;
case 'heart': heartPath(c, x, y, w, h); break;
case 'bubble': bubblePath(c, x, y, w, h); break;
}
}
function arrowHead(c, tx, ty, ang, size){
const L = size, W = L * 0.5;
c.beginPath();
c.moveTo(tx, ty);
c.lineTo(tx - Math.cos(ang - 0.45) * L, ty - Math.sin(ang - 0.45) * L);
c.lineTo(tx - Math.cos(ang + 0.45) * L, ty - Math.sin(ang + 0.45) * L);
c.closePath();
c.fill();
}
function drawShapeObject(c, s){
const st = s.style || {};
const stroke = st.stroke || '#000000';
const lw = Math.max(1, st.strokeWidth || 4);
c.save();
c.lineJoin = 'round';
c.lineCap = 'round';
if (LINE_KINDS.has(s.kind)){
const [a, b] = shapeEndpoints(s);
const ang = Math.atan2(b.y - a.y, b.x - a.x);
const hasHead = (s.kind === 'arrow' || s.kind === 'double');
const headLen = hasHead ? Math.max(10, lw * 3.2) : 0;
let ax = a.x, ay = a.y, bx = b.x, by = b.y;
if (headLen){
const L = Math.hypot(b.x - a.x, b.y - a.y);
if (L > headLen * 1.6){
bx -= Math.cos(ang) * headLen * 0.8;
by -= Math.sin(ang) * headLen * 0.8;
if (s.kind === 'double'){ ax += Math.cos(ang) * headLen * 0.8; ay += Math.sin(ang) * headLen * 0.8; }
}
}
c.strokeStyle = stroke;
c.lineWidth = lw;
if (s.kind === 'dashed') c.setLineDash([Math.max(6, lw * 2.4), Math.max(5, lw * 2)]);
c.beginPath();
c.moveTo(ax, ay);
c.lineTo(bx, by);
c.stroke();
c.setLineDash([]);
if (hasHead){
c.fillStyle = stroke;
arrowHead(c, b.x, b.y, ang, headLen);
if (s.kind === 'double') arrowHead(c, a.x, a.y, ang + Math.PI, headLen);
}
} else {
c.beginPath();
buildShapePath(c, s);
if (st.filled){ c.fillStyle = st.fill || '#ffffff'; c.fill(); }
c.strokeStyle = stroke;
c.lineWidth = lw;
c.stroke();
}
c.restore();
}
function drawBoardObject(c, s){
if (s.kind) drawShapeObject(c, s);
else c.drawImage(s.img, s.x, s.y, s.w, s.h);
}
/* Composite render: white bg + stroke layer + all objects (+ selection UI).
The object currently being text-edited is hidden while its editor is open. */
function renderComposite(){
ctx.setLineDash([]);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(sketchCanvas, 0, 0);
for (const s of stickers){
if (s === editorSticker) continue;
drawBoardObject(ctx, s);
}
if (pendingShape) drawBoardObject(ctx, pendingShape); // live draw preview
if (selectedSticker && selectedSticker !== editorSticker) drawSelectionUI(selectedSticker);
}
function drawSelectionUI(s){
const lw = Math.max(1.5, canvas.width / 640);
ctx.save();
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = lw;
ctx.setLineDash([7, 5]);
ctx.strokeRect(s.x, s.y, s.w, s.h);
ctx.setLineDash([]);
const hs = handleSize();
ctx.fillStyle = '#ffffff';
for (const [hx, hy] of Object.values(corners(s))){
ctx.beginPath();
ctx.rect(hx - hs / 2, hy - hs / 2, hs, hs);
ctx.fill();
ctx.stroke();
}
// red delete button above the top-right corner
const d = deleteBtnPos(s);
ctx.fillStyle = '#ef4444';
ctx.beginPath();
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = Math.max(1.5, d.r * 0.22);
const k = d.r * 0.42;
ctx.beginPath();
ctx.moveTo(d.x - k, d.y - k); ctx.lineTo(d.x + k, d.y + k);
ctx.moveTo(d.x + k, d.y - k); ctx.lineTo(d.x - k, d.y + k);
ctx.stroke();
ctx.restore();
}
/* ── Hit testing ── */
function hitHandle(pos, s){
const hs = handleSize();
const slack = hs / 2 + 6;
for (const [name, [hx, hy]] of Object.entries(corners(s))){
if (Math.abs(pos.x - hx) <= slack && Math.abs(pos.y - hy) <= slack) return name;
}
return null;
}
function hitDeleteBtn(pos, s){
const d = deleteBtnPos(s);
const dx = pos.x - d.x, dy = pos.y - d.y;
return (dx * dx + dy * dy) <= (d.r + 5) * (d.r + 5);
}
function distToSegment(px, py, x1, y1, x2, y2){
const dx = x2 - x1, dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let t = len2 ? ((px - x1) * dx + (py - y1) * dy) / len2 : 0;
t = clamp(t, 0, 1);
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}
function hitSticker(pos){
for (let i = stickers.length - 1; i >= 0; i--){
const s = stickers[i];
if (s === editorSticker) continue;
if (s.kind && LINE_KINDS.has(s.kind)){
// precise test for lines/arrows so thin lines don't block the board
const [a, b] = shapeEndpoints(s);
if (distToSegment(pos.x, pos.y, a.x, a.y, b.x, b.y) <= Math.max(10, (s.style.strokeWidth || 4) / 2 + 6)) return s;
} else if (pos.x >= s.x && pos.x <= s.x + s.w && pos.y >= s.y && pos.y <= s.y + s.h){
return s;
}
}
return null;
}
/* ── Pointer / pen / touch interaction (unified pointer events) ── */
function getPos(e){
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
}
function clientToCanvas(cx, cy){
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return { x: (cx - rect.left) * scaleX, y: (cy - rect.top) * scaleY };
}
canvas.addEventListener('pointerdown', (e) => {
if (dragMode) return; // one interaction at a time
if (isEditorOpen()){ // an open text editor wins: commit + swallow
commitTextEditor();
return;
}
e.preventDefault();
try { canvas.setPointerCapture(e.pointerId); } catch (_) {}
const pos = getPos(e);
// 1) resize handles / delete button of the selected object
if (selectedSticker){
const h = hitHandle(pos, selectedSticker);
if (h){ beginResize(h, pos); return; }
if (hitDeleteBtn(pos, selectedSticker)){ removeSticker(selectedSticker); return; }
}
// 2) shape tool: grab an existing object, or drag out a new shape
if (currentTool === 'shape'){
const st = hitSticker(pos);
if (st){ selectSticker(st); beginMove(st, pos); return; }
beginShapeDraw(pos);
return;
}
// 3) grab any object under the pointer — works in EVERY tool mode
const st = hitSticker(pos);
if (st){ selectSticker(st); beginMove(st, pos); return; }
// 4) empty area
if (currentTool === 'select'){ selectSticker(null); return; }
beginStroke(pos);
});
canvas.addEventListener('pointermove', (e) => {
if (isEditorOpen()) return;
if (!dragMode){ updateHover(getPos(e)); return; }
const pos = getPos(e);
if (dragMode === 'draw') extendStroke(pos);
else if (dragMode === 'move') updateMove(pos);
else if (dragMode === 'resize') updateResize(pos, e);
else if (dragMode === 'shapedraw') updatePendingShape(pos);
});
function endDrag(){
if (!dragMode) return;
const mode = dragMode;
const start = dragStart;
dragMode = null; dragSticker = null; dragStart = null;
canvas.style.cursor = '';
if (mode === 'shapedraw'){
commitPendingShape(start);
pendingShape = null;
renderComposite();
return;
}
if (dragChanged){ saveState(); updateBoardStatus(); dragChanged = false; }
}
canvas.addEventListener('pointerup', endDrag);
canvas.addEventListener('pointercancel', endDrag);
// double-click text on the board → edit it inline (works in every tool mode)
canvas.addEventListener('dblclick', (e) => {
if (isEditorOpen()) return;
const pos = getPos(e);
const st = hitSticker(pos);
if (st && st.textMeta){
endDrag();
openTextEditor(pos.x, pos.y, st);
}
});
function updateHover(pos){
let cur = '';
if (selectedSticker){
const h = hitHandle(pos, selectedSticker);
if (h) cur = (h === 'nw' || h === 'se') ? 'nwse-resize' : 'nesw-resize';
else if (hitDeleteBtn(pos, selectedSticker)) cur = 'pointer';
}
if (!cur){
const st = hitSticker(pos);
if (st) cur = st.textMeta ? 'text' : 'move'; // I-beam hints at double-click editing
}
canvas.style.cursor = cur; // '' falls back to the tool cursor class
}
/* Free-hand strokes (drawn on the stroke layer, composited live) */
function strokeColor(){ return currentTool === 'eraser' ? '#ffffff' : brushColor; }
function strokeWidth(){ return currentTool === 'eraser' ? brushSize * 2 : brushSize; }
function beginStroke(pos){
dragMode = 'draw';
dragChanged = true;
lastX = pos.x; lastY = pos.y;
sketchCtx.strokeStyle = strokeColor();
sketchCtx.lineWidth = strokeWidth();
sketchCtx.beginPath();
sketchCtx.moveTo(lastX, lastY);
sketchCtx.lineTo(lastX + 0.1, lastY + 0.1);
sketchCtx.stroke();
renderComposite();
}
function extendStroke(pos){
sketchCtx.strokeStyle = strokeColor();
sketchCtx.lineWidth = strokeWidth();
sketchCtx.beginPath();
sketchCtx.moveTo(lastX, lastY);
sketchCtx.lineTo(pos.x, pos.y);
sketchCtx.stroke();
lastX = pos.x; lastY = pos.y;
renderComposite();
}
/* Object move */
function beginMove(st, pos){
dragMode = 'move';
dragSticker = st;
dragChanged = false;
dragOffX = pos.x - st.x;
dragOffY = pos.y - st.y;
canvas.style.cursor = 'grabbing';
}
function updateMove(pos){
const st = dragSticker;
if (!st) return;
st.x = clamp(Math.round(pos.x - dragOffX), 0, canvas.width - st.w);
st.y = clamp(Math.round(pos.y - dragOffY), 0, canvas.height - st.h);
dragChanged = true;
renderComposite();
}
/* Object resize (opposite corner stays anchored; Shift keeps aspect ratio) */
function beginResize(handle, pos){
const s = selectedSticker;
dragMode = 'resize';
dragSticker = s;
dragChanged = false;
const fx = (handle === 'nw' || handle === 'sw') ? s.x + s.w : s.x;
const fy = (handle === 'nw' || handle === 'ne') ? s.y + s.h : s.y;
dragStart = { fx, fy, sw: s.w, sh: s.h };
canvas.style.cursor = (handle === 'nw' || handle === 'se') ? 'nwse-resize' : 'nesw-resize';
}
function updateResize(pos, e){
const s = dragSticker;
if (!s || !dragStart) return;
const { fx, fy, sw, sh } = dragStart;
const isLine = s.kind && LINE_KINDS.has(s.kind);
const minD = isLine ? 0 : MIN_SIZE; // lines may be perfectly horizontal/vertical
let x1, y1, x2, y2;
if (e.shiftKey && !isLine && sw > 0 && sh > 0){
const a = sw / sh;
let w = Math.abs(pos.x - fx), h = Math.abs(pos.y - fy);
if (w / a >= h) h = w / a; else w = h * a;
w = Math.max(w, minD); h = Math.max(h, minD);
const sx = pos.x < fx ? -1 : 1, sy = pos.y < fy ? -1 : 1;
x1 = Math.min(fx, fx + sx * w); x2 = Math.max(fx, fx + sx * w);
y1 = Math.min(fy, fy + sy * h); y2 = Math.max(fy, fy + sy * h);
} else {
x1 = Math.min(pos.x, fx); x2 = Math.max(pos.x, fx);
y1 = Math.min(pos.y, fy); y2 = Math.max(pos.y, fy);
}
// keep min size anchored at the fixed corner
if (x2 - x1 < minD){ if (fx <= x1) x2 = x1 + minD; else x1 = x2 - minD; }
if (y2 - y1 < minD){ if (fy <= y1) y2 = y1 + minD; else y1 = y2 - minD; }
// keep inside the board
x1 = clamp(x1, 0, canvas.width - minD);
x2 = clamp(x2, x1 + minD, canvas.width);
y1 = clamp(y1, 0, canvas.height - minD);
y2 = clamp(y2, y1 + minD, canvas.height);
s.x = Math.round(x1); s.y = Math.round(y1);
s.w = Math.round(x2 - x1); s.h = Math.round(y2 - y1);
dragChanged = true;
renderComposite();
}
/* ── Shape drawing (drag to define, click for default size) ── */
function beginShapeDraw(pos){
dragMode = 'shapedraw';
dragStart = { sx: pos.x, sy: pos.y };
pendingShape = null;
}
function updatePendingShape(pos){
const x1 = dragStart.sx, y1 = dragStart.sy, x2 = pos.x, y2 = pos.y;
const x = Math.min(x1, x2), y = Math.min(y1, y2);
const w = Math.abs(x2 - x1), h = Math.abs(y2 - y1);
const flip = (x2 > x1) !== (y2 > y1);
// preserve the drawn direction (arrow heads point where the user dragged)
const canon = flip ? { x: x + w, y: y } : { x: x, y: y };
const other = flip ? { x: x, y: y + h } : { x: x + w, y: y + h };
const d1 = Math.hypot(x1 - canon.x, y1 - canon.y);
const d2 = Math.hypot(x1 - other.x, y1 - other.y);
const rev = d1 > d2;
pendingShape = makeShape(
shapeKind || 'rect',
Math.round(x), Math.round(y),
Math.max(0, Math.round(w)), Math.max(0, Math.round(h)),
flip, rev, currentShapeStyle()
);
renderComposite();
}
function commitPendingShape(start){
const style = currentShapeStyle();
let s = pendingShape;
if (!s || (s.w < 8 && s.h < 8)){
// simple click → default-sized shape centred on the click
const def = Math.round(Math.min(canvas.width, canvas.height) * 0.3);
const cx = clamp(Math.round(start.sx - def / 2), 0, canvas.width - def);
const cy = clamp(Math.round(start.sy - def / 2), 0, canvas.height - def);
s = makeShape(shapeKind || 'rect', cx, cy, def, def, false, false, style);
} else if (!LINE_KINDS.has(s.kind)){
if (s.w < MIN_SIZE){ const c = s.x + s.w / 2; s.x = Math.round(c - MIN_SIZE / 2); s.w = MIN_SIZE; }
if (s.h < MIN_SIZE){ const c = s.y + s.h / 2; s.y = Math.round(c - MIN_SIZE / 2); s.h = MIN_SIZE; }
s.x = clamp(s.x, 0, canvas.width - s.w);
s.y = clamp(s.y, 0, canvas.height - s.h);
} else {
s.x = clamp(s.x, 0, Math.max(0, canvas.width - s.w));
s.y = clamp(s.y, 0, Math.max(0, canvas.height - s.h));
}
stickers.push(s);
selectSticker(s);
saveState();
updateBoardStatus();
}
/* ── Object operations (shared by stickers, text AND shapes) ── */
function selectSticker(st){
if (st){
const i = stickers.indexOf(st);
if (i >= 0 && i < stickers.length - 1){ stickers.splice(i, 1); stickers.push(st); } // bring to front
if (st.kind) syncShapePanelFromStyle(st.style); // panel reflects the selected shape's style
}
selectedSticker = st || null;
renderComposite();
}
function removeSticker(st){
const i = stickers.indexOf(st);
if (i < 0) return;
stickers.splice(i, 1);
if (selectedSticker === st) selectedSticker = null;
if (editorSticker === st) editorSticker = null;
saveState();
renderComposite();
updateBoardStatus();
showToast('Object removed', 'info');
}
function updateBoardStatus(){
const n = stickers.length;
if (!n){ sbImageCount.textContent = 'Sketch Ready'; return; }
const t = stickers.filter(s => s.textMeta).length;
const sh = stickers.filter(s => s.kind).length;
const a = n - t - sh;
const parts = [];
if (a) parts.push(a + (a > 1 ? ' assets' : ' asset'));
if (sh) parts.push(sh + (sh > 1 ? ' shapes' : ' shape'));
if (t) parts.push(t + (t > 1 ? ' texts' : ' text'));
sbImageCount.textContent = parts.join(' · ') + ' on board';
}
function normalizeStickerSource(img){
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
if (!w || !h) return img;
const MAXDIM = 512;
if (Math.max(w, h) <= MAXDIM) return img;
const sc = MAXDIM / Math.max(w, h);
const c = document.createElement('canvas');
c.width = Math.max(1, Math.round(w * sc));
c.height = Math.max(1, Math.round(h * sc));
const cc = c.getContext('2d');
cc.imageSmoothingEnabled = true;
cc.imageSmoothingQuality = 'high';
cc.drawImage(img, 0, 0, c.width, c.height);
return c;
}
function addSticker(src, cx, cy){
const sw = src.width || src.naturalWidth;
const sh = src.height || src.naturalHeight;
if (!sw || !sh){ showToast('Could not read the image', 'error'); return; }
const target = Math.min(canvas.width, canvas.height) * 0.35;
const sc = target / Math.max(sw, sh);
const w = clamp(Math.round(sw * sc), 24, canvas.width);
const h = clamp(Math.round(sh * sc), 24, canvas.height);
let x, y;
if (cx == null){
x = (canvas.width - w) / 2 + (Math.random() * 80 - 40);
y = (canvas.height - h) / 2 + (Math.random() * 80 - 40);
} else {
x = cx - w / 2;
y = cy - h / 2;
}
x = clamp(Math.round(x), 0, canvas.width - w);
y = clamp(Math.round(y), 0, canvas.height - h);
const st = { x, y, w, h, img: src };
stickers.push(st);
selectSticker(st);
setTool('select');
saveState();
updateBoardStatus();
renderComposite();
return st;
}
/* ── Built-in emoji sticker library (rendered locally, trimmed to content) ── */
const STICKER_EMOJIS = [
'😀','😎','🤔','🤖','👽','💀','🙈',
'🐱','🐶','🐰','🦊','🐻','🐼','🐨','🦁','🐸','🦄','🐢','🦋','🐝','🐞','🐦','🦉',
'🌵','🌲','🌳','🌴','🍀','🌻','🌹','🌷','🌈','☀️','☁️','🌙','⭐','✨','🔥','💧','❄️',
'❤️','👑','💎','🎩','🏆','🎵','🎸','🍕','🍩','🍎','☕','🎈','🎁','⚽','🏀','🚗','🚀','✈️','⛵','🏠','🏰'
];
function emojiToStickerImage(emoji){
const size = 200;
const c = document.createElement('canvas');
c.width = size; c.height = size;
const cc = c.getContext('2d');
cc.font = `${Math.floor(size * 0.78)}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`;
cc.textAlign = 'center';
cc.textBaseline = 'middle';
cc.fillText(emoji, size / 2, size / 2 + size * 0.03);
try {
const data = cc.getImageData(0, 0, size, size).data;
let minX = size, minY = size, maxX = -1, maxY = -1;
for (let y = 0; y < size; y++){
for (let x = 0; x < size; x++){
if (data[(y * size + x) * 4 + 3] > 8){
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX >= 0 && (maxX - minX) > 6 && (maxY - minY) > 6){
const w = maxX - minX + 1, h = maxY - minY + 1;
const out = document.createElement('canvas');
out.width = w; out.height = h;
out.getContext('2d').drawImage(c, minX, minY, w, h, 0, 0, w, h);
return out;
}
} catch (err) { console.warn('emoji trim failed', err); }
return c;
}
STICKER_EMOJIS.forEach(em => {
const b = document.createElement('button');
b.className = 'sticker-cell';
b.type = 'button';
b.title = 'Add ' + em;
b.textContent = em;
b.addEventListener('click', () => {
const img = emojiToStickerImage(em);
addSticker(img);
});
stickerGrid.appendChild(b);
});
/* ════════════════════════════════════════════════════════════════════════
SHAPES & LINES PANEL
════════════════════════════════════════════════════════════════════════ */
const SHAPE_LIBRARY = [
['line', 'Line', '<line x1="5" y1="19" x2="19" y2="5"/>'],
['arrow', 'Arrow', '<path d="M5 19L19 5"/><path d="M12 5h7v7"/>'],
['double', 'Double arrow', '<path d="M6 18L18 6"/><path d="M11 6h7v7"/><path d="M13 18H6v-7"/>'],
['dashed', 'Dashed line', '<path d="M5 19L19 5" stroke-dasharray="3 3"/>'],
['rect', 'Rectangle', '<rect x="5" y="6" width="14" height="13"/>'],
['round', 'Rounded rect', '<rect x="5" y="6" width="14" height="13" rx="3"/>'],
['ellipse', 'Ellipse / Circle', '<ellipse cx="12" cy="12.5" rx="7.5" ry="6.5"/>'],
['triangle', 'Triangle', '<path d="M12 5l8 14H4z"/>'],
['diamond', 'Diamond', '<path d="M12 4l8 8-8 8-8-8z"/>'],
['hexagon', 'Hexagon', '<path d="M8 5h8l4 7-4 7H8l-4-7z"/>'],
['star', 'Star', '<path d="M12 3l2.7 5.7 6.3.8-4.6 4.3 1.2 6.2L12 17l-5.6 3 1.2-6.2L3 9.5l6.3-.8z"/>'],
['heart', 'Heart', '<path d="M12 20s-7.5-4.8-7.5-10A4.2 4.2 0 0 1 12 7.6 4.2 4.2 0 0 1 19.5 10c0 5.2-7.5 10-7.5 10z"/>'],
['bubble', 'Speech bubble', '<path d="M5 5h14v10H10l-4 4v-4H5z"/>']
];
SHAPE_LIBRARY.forEach(([kind, label, svg]) => {
const b = document.createElement('button');
b.className = 'shape-cell';
b.type = 'button';
b.dataset.kind = kind;
b.title = label + ' — click, then drag on the board';
b.innerHTML = '<svg viewBox="0 0 24 24">' + svg + '</svg>';
b.addEventListener('click', () => selectShapeKind(kind));
shapeGridEl.appendChild(b);
});
function selectShapeKind(kind){
shapeKind = kind;
shapeGridEl.querySelectorAll('.shape-cell').forEach(c => c.classList.toggle('active', c.dataset.kind === kind));
closePanelsExcept('shape');
shapePanel.classList.add('open');
setTool('shape');
updateShapesBtnState();
}
function currentShapeStyle(){
return {
stroke: shapeStrokeInput.value,
fill: shapeFillInput.value,
filled: shapeFillToggle.classList.contains('active'),
strokeWidth: parseInt(shapeWidthSlider.value) || 4
};
}
function syncShapePanelFromStyle(st){
if (!st) return;
shapeStrokeInput.value = st.stroke || '#111111';
shapeFillInput.value = st.fill || '#ffffff';
shapeFillToggle.classList.toggle('active', !!st.filled);
shapeWidthSlider.value = clamp(parseInt(st.strokeWidth) || 4, 1, 24);
shapeWidthVal.textContent = shapeWidthSlider.value;
}
/* live-restyle the selected shape from the panel */
function applyShapeStyleToSelection(){
if (selectedSticker && selectedSticker.kind){
selectedSticker.style = currentShapeStyle();
renderComposite();
shapeStyleDirty = true;
}
}
function commitShapeStyleHistory(){
if (shapeStyleDirty){ saveState(); shapeStyleDirty = false; }
}
shapeStrokeInput.addEventListener('input', applyShapeStyleToSelection);
shapeFillInput.addEventListener('input', applyShapeStyleToSelection);
shapeWidthSlider.addEventListener('input', () => {
shapeWidthVal.textContent = shapeWidthSlider.value;
applyShapeStyleToSelection();
});
shapeStrokeInput.addEventListener('change', commitShapeStyleHistory);
shapeFillInput.addEventListener('change', commitShapeStyleHistory);
shapeWidthSlider.addEventListener('change', commitShapeStyleHistory);
shapeFillToggle.addEventListener('click', () => {
shapeFillToggle.classList.toggle('active');
applyShapeStyleToSelection();
commitShapeStyleHistory();
});
/* ════════════════════════════════════════════════════════════════════════
TEXT SYSTEM (single entry point: the Text panel)
Text is rasterised into a canvas bitmap and placed on the board as an
object — exactly like stickers and shapes — so it is draggable, resizable,
undoable AND included in the flattened image sent to the backend.
Adding: Text panel / Ctrl+V paste. Editing: double-click on the board.
════════════════════════════════════════════════════════════════════════ */
function currentTextStyle(){
return {
family: textFontSel.value,
size: parseInt(textSizeSlider.value) || 48,
color: textColorInput.value,
bold: textBoldBtn.classList.contains('active'),
italic: textItalicBtn.classList.contains('active')
};
}
/* Rasterise text (with style) to an offscreen canvas at full board resolution */
async function renderTextBitmap(text, style){
const font = `${style.italic ? 'italic ' : ''}${style.bold ? '700' : '400'} ${style.size}px ${style.family}`;
try {
if (document.fonts && document.fonts.load){
await document.fonts.load(font, text);
}
} catch (_) { /* system fonts etc. — canvas will fall back gracefully */ }
const lines = text.split('\n');
const m = document.createElement('canvas').getContext('2d');
m.font = font;
let maxW = 0;
for (const l of lines) maxW = Math.max(maxW, m.measureText(l).width);
const lineH = Math.round(style.size * 1.3);
const pad = Math.max(4, Math.ceil(style.size * 0.15));
const c = document.createElement('canvas');
c.width = Math.max(2, Math.ceil(maxW) + pad * 2);
c.height = Math.max(2, lines.length * lineH + pad * 2);
const cc = c.getContext('2d');
cc.font = font;
cc.fillStyle = style.color;
cc.textAlign = 'left';
cc.textBaseline = 'middle';
lines.forEach((l, i) => {
cc.fillText(l, pad, pad + lineH * i + lineH / 2);
});
return c;
}
/* Place a pre-rendered text bitmap as a board object */
function placeTextSticker(img, meta, x, y){
let w = img.width, h = img.height;
if (w > canvas.width || h > canvas.height){
const sc = Math.min(canvas.width / w, canvas.height / h);
w = Math.round(w * sc); h = Math.round(h * sc);
}
const px = clamp(Math.round(x), 0, canvas.width - w);
const py = clamp(Math.round(y), 0, canvas.height - h);
const st = { x: px, y: py, w, h, img, textMeta: meta };
stickers.push(st);
selectSticker(st);
return st;
}
/* Add a text object from a raw string (panel button / clipboard paste) */
async function addTextObject(text, x, y){
text = (text || '').replace(/^\n+/, '').replace(/\s+$/, '');
if (!text.trim()){ showToast('Text is empty', 'warning'); return null; }
if (text.length > 2000){
text = text.slice(0, 2000);
showToast('Text truncated to 2000 characters', 'warning');
}
const style = currentTextStyle();
const img = await renderTextBitmap(text, style);
if (!img) return null;
let px = x, py = y;
if (px == null){
px = (canvas.width - img.width) / 2 + (Math.random() * 60 - 30);
py = (canvas.height - img.height) / 2 + (Math.random() * 60 - 30);
}
const st = placeTextSticker(img, { text, ...style }, px, py);
saveState();
updateBoardStatus();
renderComposite();
return st;
}
/* ── Inline editor (opens on double-click to EDIT existing board text) ── */
function isEditorOpen(){ return ed.style.display !== 'none'; }
function displayScale(){
const rect = canvas.getBoundingClientRect();
return (rect.width / canvas.width) || 1;
}
function fitTextEditor(){
// classic autosize trick: shrink, measure overflow, grow
ed.style.width = '10px';
ed.style.height = '10px';
const w = Math.max(70, ed.scrollWidth + 6);
const h = Math.max(30, ed.scrollHeight + 6);
ed.style.width = w + 'px';
ed.style.height = h + 'px';
}
function applyEditorStyle(){
const scale = displayScale();
ed.style.fontFamily = editorStyle.family;
ed.style.fontWeight = editorStyle.bold ? '700' : '400';
ed.style.fontStyle = editorStyle.italic ? 'italic' : 'normal';
ed.style.color = editorStyle.color;
ed.style.fontSize = Math.max(16, Math.round(editorStyle.size * scale)) + 'px';
fitTextEditor();
}
/* panel controls → live-update the open editor */
function syncEditorStyle(){
if (!isEditorOpen() || !editorStyle) return;
editorStyle.family = textFontSel.value;
editorStyle.size = parseInt(textSizeSlider.value) || 48;
editorStyle.color = textColorInput.value;
editorStyle.bold = textBoldBtn.classList.contains('active');
editorStyle.italic = textItalicBtn.classList.contains('active');
applyEditorStyle();
}
/* editor style → panel controls (when editing an existing text object) */
function syncPanelFromStyle(st){
for (const o of textFontSel.options){
if (o.value === st.family){ textFontSel.value = st.family; break; }
}
textSizeSlider.value = clamp(st.size, 12, 200);
textSizeVal.textContent = textSizeSlider.value;
textColorInput.value = st.color || '#111111';
textBoldBtn.classList.toggle('active', !!st.bold);
textItalicBtn.classList.toggle('active', !!st.italic);
}
function openTextEditor(cx, cy, sticker){
if (isEditorOpen()) commitTextEditor();
selectSticker(null);
const scale = displayScale();
editorSticker = sticker || null;
editorStyle = (sticker && sticker.textMeta) ? { ...sticker.textMeta } : currentTextStyle();
if (sticker && sticker.textMeta) syncPanelFromStyle(sticker.textMeta);
ed.value = sticker ? sticker.textMeta.text : '';
applyEditorStyle();
// position the editor over the canvas at the anchor point
const rect = canvas.getBoundingClientRect();
const stageRect = stage.getBoundingClientRect();
const ax = sticker ? sticker.x : cx;
const ay = sticker ? sticker.y : cy;
const left = (rect.left - stageRect.left) + ax * scale;
const top = (rect.top - stageRect.top) + ay * scale;
ed.style.display = 'block';
fitTextEditor();
ed.style.left = Math.max(4, Math.min(left, stage.clientWidth - ed.offsetWidth - 4)) + 'px';
ed.style.top = Math.max(4, Math.min(top, stage.clientHeight - ed.offsetHeight - 4)) + 'px';
ed.focus();
const len = ed.value.length;
try { ed.setSelectionRange(len, len); } catch (_) {}
renderComposite(); // hides the object being edited while its editor is open
}
function hideEditor(){
ed.style.display = 'none';
ed.value = '';
editorSticker = null;
editorStyle = null;
}
function editorCanvasPos(){
const rect = canvas.getBoundingClientRect();
const stageRect = stage.getBoundingClientRect();
const scale = canvas.width / rect.width;
return {
x: (ed.offsetLeft - (rect.left - stageRect.left)) * scale,
y: (ed.offsetTop - (rect.top - stageRect.top)) * scale
};
}
async function doCommitTextEditor(){
// capture everything synchronously BEFORE any await
const sticker = editorSticker;
const style = editorStyle ? { ...editorStyle } : currentTextStyle();
const raw = ed.value;
const anchor = editorCanvasPos();
hideEditor();
renderComposite();
const text = raw.split('\n').map(l => l.replace(/\s+$/, '')).join('\n')
.replace(/^\n+/, '').replace(/\n+$/, '');
if (!text.trim()){
if (sticker) removeSticker(sticker); // emptied an existing text → remove it
return;
}
let img;
try { img = await renderTextBitmap(text, style); } catch (_) { return; }
if (!img) return;
if (sticker){
// update the existing text object in place
sticker.img = img;
sticker.textMeta = { text, ...style };
sticker.w = img.width;
sticker.h = img.height;
if (img.width > canvas.width || img.height > canvas.height){
const sc = Math.min(canvas.width / img.width, canvas.height / img.height);
sticker.w = Math.round(img.width * sc);
sticker.h = Math.round(img.height * sc);
}
selectSticker(sticker);
} else {
placeTextSticker(img, { text, ...style }, anchor.x, anchor.y);
}
saveState();
updateBoardStatus();
renderComposite();
}
function commitTextEditor(){
if (isEditorOpen()){
pendingTextCommit = doCommitTextEditor();
}
return pendingTextCommit || Promise.resolve();
}
function cancelTextEditor(){
if (!isEditorOpen()) return;
hideEditor();
renderComposite();
}
ed.addEventListener('input', fitTextEditor);
ed.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter' && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey){
e.preventDefault();
commitTextEditor();
} else if (e.key === 'Escape'){
e.preventDefault();
cancelTextEditor();
}
});
// clicking outside the editor commits it — except when adjusting the panels
ed.addEventListener('blur', (e) => {
const rt = e.relatedTarget;
if (rt && (textPanel.contains(rt) || stickerPanel.contains(rt) || shapePanel.contains(rt) || rt === ed)) return;
commitTextEditor();
});
/* ── Text panel bindings ── */
btnTextPanel.addEventListener('click', () => {
const willOpen = !textPanel.classList.contains('open');
closePanelsExcept('text');
textPanel.classList.toggle('open', willOpen);
});
textPanelClose.addEventListener('click', () => textPanel.classList.remove('open'));
textFontSel.addEventListener('change', syncEditorStyle);
textColorInput.addEventListener('input', syncEditorStyle);
textSizeSlider.addEventListener('input', () => {
textSizeVal.textContent = textSizeSlider.value;
syncEditorStyle();
});
textBoldBtn.addEventListener('click', () => { textBoldBtn.classList.toggle('active'); syncEditorStyle(); });
textItalicBtn.addEventListener('click', () => { textItalicBtn.classList.toggle('active'); syncEditorStyle(); });
textAddBtn.addEventListener('click', async () => {
const t = textInput.value;
if (!t.trim()){ showToast('Type or paste some text first', 'warning'); return; }
const st = await addTextObject(t, null, null);
if (st){
textInput.value = '';
setTool('select');
showToast('Text added to the board', 'info');
}
});
/* ── Panel management (only one floating panel open at a time) ── */
function closePanelsExcept(name){
if (name !== 'sticker') stickerPanel.classList.remove('open');
if (name !== 'text') textPanel.classList.remove('open');
if (name !== 'shape') shapePanel.classList.remove('open');
}
function updateShapesBtnState(){
btnShapes.classList.toggle('active', shapePanel.classList.contains('open') || currentTool === 'shape');
}
/* ── Panel / upload / drag-drop / paste ── */
btnStickers.addEventListener('click', () => {
const willOpen = !stickerPanel.classList.contains('open');
closePanelsExcept('sticker');
stickerPanel.classList.toggle('open', willOpen);
});
panelCloseBtn.addEventListener('click', () => stickerPanel.classList.remove('open'));
btnShapes.addEventListener('click', () => {
const willOpen = !shapePanel.classList.contains('open');
closePanelsExcept('shape');
shapePanel.classList.toggle('open', willOpen);
if (willOpen) selectShapeKind(shapeKind || 'rect');
updateShapesBtnState();
});
shapePanelClose.addEventListener('click', () => {
shapePanel.classList.remove('open');
updateShapesBtnState();
});
uploadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
fileInput.value = '';
});
async function handleFiles(fileList, cx, cy){
const files = Array.from(fileList || []);
let added = 0;
for (const f of files){
if (!f.type || !f.type.startsWith('image/')) continue;
if (f.size > 15 * 1024 * 1024){
showToast(`${f.name} is too large (max 15 MB)`, 'warning');
continue;
}
try {
const dataUrl = await readFileAsDataURL(f);
const img = await loadImage(dataUrl);
addSticker(normalizeStickerSource(img), cx, cy);
added++;
} catch (err){
console.warn('asset load failed', err);
}
}
if (added) showToast(`Added ${added} asset${added > 1 ? 's' : ''} to the board`, 'info');
else if (files.length) showToast('No supported image files found', 'warning');
}
stage.addEventListener('dragover', (e) => {
e.preventDefault();
stage.classList.add('drag-over');
});
stage.addEventListener('dragleave', () => stage.classList.remove('drag-over'));
stage.addEventListener('drop', (e) => {
e.preventDefault();
stage.classList.remove('drag-over');
const files = Array.from((e.dataTransfer && e.dataTransfer.files) || []);
if (!files.length) return;
const pos = clientToCanvas(e.clientX, e.clientY);
const inside = pos.x >= 0 && pos.x <= canvas.width && pos.y >= 0 && pos.y <= canvas.height;
handleFiles(files, inside ? pos.x : null, inside ? pos.y : null);
});
/* Paste: images become stickers, plain TEXT becomes a board text object.
(Normal pasting into the prompt / panel / editor fields is preserved.) */
window.addEventListener('paste', (e) => {
const cb = e.clipboardData;
if (!cb) return;
const active = document.activeElement;
const inField = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable);
if (inField) return; // let inputs receive their normal paste
// 1) pasted image files
const items = cb.items || [];
const files = [];
for (let i = 0; i < items.length; i++){
const it = items[i];
if (it.kind === 'file' && it.type && it.type.startsWith('image/')){
const f = it.getAsFile();
if (f) files.push(f);
}
}
if (files.length){
e.preventDefault();
handleFiles(files);
return;
}
// 2) pasted plain text → text object on the board
const text = cb.getData('text/plain');
if (text && text.trim()){
e.preventDefault();
addTextObject(text, null, null).then(st => {
if (st){
setTool('select');
showToast('Text pasted onto the board', 'info');
}
});
}
});
/* ── History (undo/redo) — snapshots include sketch, stickers, text AND shapes ── */
function snapshot(){
return {
sketch: sketchCanvas.toDataURL(),
stickers: stickers.map(s => s.kind
? { kind: s.kind, x: s.x, y: s.y, w: s.w, h: s.h, flip: !!s.flip, rev: !!s.rev, style: { ...s.style } }
: { x: s.x, y: s.y, w: s.w, h: s.h, img: s.img, textMeta: s.textMeta || null })
};
}
function saveState(){
if (undoStack.length >= MAX_HISTORY) undoStack.shift();
undoStack.push(snapshot());
redoStack = [];
}
function restoreSketch(dataUrl){
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
sketchCtx.fillStyle = '#ffffff';
sketchCtx.fillRect(0, 0, sketchCanvas.width, sketchCanvas.height);
sketchCtx.drawImage(img, 0, 0);
sketchCtx.lineCap = 'round';
sketchCtx.lineJoin = 'round';
resolve();
};
img.onerror = () => resolve();
img.src = dataUrl;
});
}
async function restoreState(snap){
await restoreSketch(snap.sketch);
stickers = snap.stickers.map(s => s.kind ? { ...s, style: { ...s.style } } : { ...s });
selectedSticker = null;
renderComposite();
updateBoardStatus();
}
btnUndo.addEventListener('click', async () => {
if (undoStack.length > 1){
redoStack.push(undoStack.pop());
await restoreState(undoStack[undoStack.length - 1]);
}
});
btnRedo.addEventListener('click', async () => {
if (redoStack.length > 0){
const next = redoStack.pop();
undoStack.push(next);
await restoreState(next);
}
});
/* ── Tools (brush / eraser / select / shape — the T text tool was removed;
text is added via the Text panel and edited via double-click) ── */
function setTool(tool){
currentTool = tool;
btnSelect.classList.toggle('active', tool === 'select');
btnBrush.classList.toggle('active', tool === 'brush');
btnEraser.classList.toggle('active', tool === 'eraser');
canvas.classList.toggle('cursor-select', tool === 'select');
canvas.classList.toggle('cursor-brush', tool === 'brush');
canvas.classList.toggle('cursor-eraser', tool === 'eraser');
canvas.classList.toggle('cursor-shape', tool === 'shape');
updateShapesBtnState();
}
btnSelect.addEventListener('click', () => setTool('select'));
btnBrush.addEventListener('click', () => setTool('brush'));
btnEraser.addEventListener('click', () => setTool('eraser'));
colorPicker.addEventListener('input', (e) => {
brushColor = e.target.value;
setTool('brush');
});
sizeSlider.addEventListener('input', (e) => {
brushSize = parseInt(e.target.value);
});
btnClear.addEventListener('click', () => {
cancelTextEditor();
resetSketchLayer();
stickers = [];
selectedSticker = null;
pendingShape = null;
saveState();
renderComposite();
updateBoardStatus();
showToast('Board cleared', 'info');
});
btnDownload.addEventListener('click', () => {
if (resultImg.src && resultImg.src !== window.location.href){
const a = document.createElement('a');
a.href = resultImg.src;
a.download = 'scribble_board_result.png';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
}
});
/* ── Aspect Ratio Logic ── */
const dims = {
"16:9": [1024, 576],
"9:16": [576, 1024],
"1:1": [1024, 1024],
"4:3": [1024, 768],
"3:4": [768, 1024]
};
function updateCanvasRatio(ratio){
cancelTextEditor();
const [w, h] = dims[ratio];
const oldW = canvas.width, oldH = canvas.height;
canvas.width = w;
canvas.height = h;
canvas.style.aspectRatio = ratio.replace(":", " / ");
resultImg.style.aspectRatio = ratio.replace(":", " / ");
resetSketchLayer(); // sketch is reset on ratio change (original behaviour)
// carry objects over, rescaled to the new board size
if (oldW > 0 && oldH > 0 && stickers.length){
const sx = w / oldW, sy = h / oldH;
stickers = stickers.map(s => {
const minD = s.kind ? 1 : 24;
const nw = Math.min(w, Math.max(minD, Math.round(s.w * sx)));
const nh = Math.min(h, Math.max(minD, Math.round(s.h * sy)));
const base = s.kind
? { kind: s.kind, flip: s.flip, rev: s.rev, style: s.style }
: { img: s.img, textMeta: s.textMeta || null };
return {
...base,
x: clamp(Math.round(s.x * sx), 0, Math.max(0, w - nw)),
y: clamp(Math.round(s.y * sy), 0, Math.max(0, h - nh)),
w: nw, h: nh
};
});
}
selectedSticker = null;
undoStack = [];
redoStack = [];
saveState();
renderComposite();
updateBoardStatus();
}
ratioSelect.addEventListener('change', (e) => {
updateCanvasRatio(e.target.value);
showToast(`Canvas set to ${e.target.value} — sketch cleared`, 'info');
});
/* ── UI Toggles ── */
function setViewMode(mode){
if (mode === 'result'){
canvas.style.display = 'none';
ed.style.display = 'none';
resultImg.style.display = 'block';
segResult.classList.add('active');
segInput.classList.remove('active');
canvasMeta.textContent = 'Generated Result';
} else {
canvas.style.display = 'block';
resultImg.style.display = 'none';
segResult.classList.remove('active');
segInput.classList.add('active');
canvasMeta.textContent = 'Draw a sketch to begin';
}
}
segInput.addEventListener('click', () => setViewMode('input'));
segResult.addEventListener('click', () => setViewMode('result'));
/* ── Quick Prompts ── */
const SUGGESTIONS = [
['Realistic Photo', 'a highly detailed, realistic photograph'],
['Anime Style', 'anime style illustration, vibrant colors, sharp lines'],
['3D Render', '3D render, octane render, cinema4d, soft lighting'],
['Oil Painting', 'traditional oil painting, visible brush strokes, canvas texture'],
['Cyberpunk', 'cyberpunk style, neon lights, dark moody atmosphere, futuristic'],
['Watercolor', 'watercolor painting, soft edges, pastel colors, artistic']
];
const suggWrap = document.getElementById('suggestions-wrap');
SUGGESTIONS.forEach(([label, prompt]) => {
const chip = document.createElement('button');
chip.className = 'suggestion-chip';
chip.textContent = label;
chip.addEventListener('click', () => {
promptInput.value = prompt;
updateCharCount();
suggWrap.querySelectorAll('.suggestion-chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
});
suggWrap.appendChild(chip);
});
function updateCharCount(){ charCount.textContent = promptInput.value.length; }
promptInput.addEventListener('input', () => {
updateCharCount();
suggWrap.querySelectorAll('.suggestion-chip').forEach(c => c.classList.remove('active'));
});
updateCharCount();
/* ── Toast & Loader ── */
let toastTimer = null;
function showToast(message, type){
let toast = document.getElementById('app-toast');
if (!toast){
toast = document.createElement('div');
toast.id = 'app-toast';
toast.className = 'toast-notification';
toast.innerHTML = '<span class="toast-icon"></span><span class="toast-text"></span>';
document.body.appendChild(toast);
}
const icon = toast.querySelector('.toast-icon');
const text = toast.querySelector('.toast-text');
toast.className = 'toast-notification ' + (type || 'error');
icon.textContent = type === 'warning' ? '⚠' : type === 'info' ? 'ℹ' : '✗';
text.textContent = message;
if (toastTimer) clearTimeout(toastTimer);
void toast.offsetWidth;
toast.classList.add('visible');
toastTimer = setTimeout(() => toast.classList.remove('visible'), 3500);
}
function setStatus(text, cls){
sbStatus.textContent = text;
sbStatus.className = 'sb-pill' + (cls ? ' ' + cls : '');
}
function showLoader(text){
loaderText.textContent = text || 'Processing image…';
loaderFill.classList.remove('determinate');
loaderFill.style.width = '100%';
loader.classList.add('active');
setStatus('Processing…');
}
function setLoaderProgress(text, frac){
loaderText.textContent = text;
if (frac != null && frac >= 0 && frac <= 1){
loaderFill.classList.add('determinate');
loaderFill.style.width = Math.max(4, Math.round(frac * 100)) + '%';
}
}
function hideLoader(statusText, cls){
loader.classList.remove('active');
setStatus(statusText || 'Done', cls || 'done');
}
/* ── Sliders ── */
function bindSlider(id){
const slider = document.getElementById(id);
const valSpan = document.getElementById(id + '-val');
slider.addEventListener('input', () => { valSpan.textContent = slider.value; });
}
bindSlider('custom-seed');
bindSlider('custom-guidance');
bindSlider('custom-steps');
document.getElementById('dice-btn').addEventListener('click', () => {
const s = Math.floor(Math.random() * 2147483647);
seedSlider.value = s; seedVal.textContent = s;
});
/* ── Init board ── */
resetSketchLayer();
renderComposite();
saveState();
updateBoardStatus();
updateShapesBtnState();
/* ── Backend connection ── */
let client = null;
try {
client = await Client.connect(window.location.origin);
setStatus('Ready');
connDot.style.background = '#fff';
connText.textContent = 'connected';
} catch (e) {
setStatus('Offline', 'error');
connDot.style.background = '#f87171';
connText.textContent = 'offline';
showToast('Could not connect to the backend API', 'error');
}
/* ── Run Generation ── */
let running = false;
let currentJob = null;
function setRunningUI(on){
running = on;
runBtn.disabled = on;
runBtnLabel.textContent = on ? 'Generating…' : 'Generate Image';
cancelBtn.style.display = on ? 'flex' : 'none';
}
async function runEdit(){
if (running) return;
const promptVal = promptInput.value.trim();
if (!promptVal){ showToast('Please enter a prompt', 'warning'); return; }
setRunningUI(true);
setViewMode('result');
showLoader('Submitting to queue…');
btnDownload.disabled = true;
/* Commit any open text editor FIRST so its text is on the board,
then flatten everything (sketch + assets + text + shapes) into one PNG. */
await commitTextEditor();
selectedSticker = null;
renderComposite();
const sketch_b64 = canvas.toDataURL('image/png');
const selectedRatio = ratioSelect.value;
const [targetW, targetH] = dims[selectedRatio];
const payload = {
image_b64: sketch_b64,
prompt: promptVal,
seed: parseInt(seedSlider.value) || 0,
randomize_seed: document.getElementById('custom-randomize').checked,
width: targetW,
height: targetH,
steps: parseInt(document.getElementById('custom-steps').value),
guidance_scale: parseFloat(document.getElementById('custom-guidance').value),
};
try {
currentJob = client.submit('/generate_image', payload);
for await (const msg of currentJob){
if (msg.type === 'status'){
if (msg.status === 'pending'){
const pos = (msg.position != null && msg.position >= 0)
? 'In queue — position ' + (msg.position + 1) + (msg.queue_size ? ' of ' + msg.queue_size : '')
: 'Waiting in queue…';
setLoaderProgress(pos, null);
setStatus('Queued');
} else if (msg.status === 'generating'){
setStatus('Processing…');
const pd = msg.progress_data;
if (pd && pd.length > 0){
const p = pd[pd.length - 1];
if (p.index != null && p.length){
const pct = Math.round((p.index / p.length) * 100);
setLoaderProgress('Generating… ' + p.index + '/' + p.length + ' (' + pct + '%)', p.index / p.length);
} else {
setLoaderProgress('Generating…', null);
}
} else {
setLoaderProgress('Generating image…', null);
}
} else if (msg.status === 'error'){
throw new Error(msg.message || 'Generation failed');
}
} else if (msg.type === 'data'){
const out = msg.data && msg.data[0];
if (out && out.image){
resultImg.src = out.image;
if (out.seed != null){
seedSlider.value = out.seed;
seedVal.textContent = out.seed;
}
hideLoader('Done', 'done');
showToast('Image generated successfully', 'info');
btnDownload.disabled = false;
} else {
hideLoader('Done', 'done');
}
}
}
} catch (e) {
console.error('Edit error:', e);
hideLoader('Error', 'error');
const msg = (e && e.message) ? e.message : 'Generation failed';
showToast(msg.length > 120 ? msg.slice(0, 120) + '…' : msg, 'error');
setViewMode('input');
} finally {
setRunningUI(false);
currentJob = null;
}
}
runBtn.addEventListener('click', runEdit);
cancelBtn.addEventListener('click', async () => {
if (currentJob){
try { await currentJob.cancel(); } catch (e) { console.warn('Cancel error:', e); }
currentJob = null;
}
setRunningUI(false);
hideLoader('Cancelled');
showToast('Generation cancelled', 'warning');
setViewMode('input');
});
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter'){
e.preventDefault();
runEdit();
return;
}
// Delete / Backspace removes the selected object (but never while typing)
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedSticker){
const tag = (document.activeElement && document.activeElement.tagName) || '';
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT'){
e.preventDefault();
removeSticker(selectedSticker);
}
}
});
</script>
</body>
</html>